This is an automated email from the ASF dual-hosted git repository.

bbejeck pushed a commit to branch trunk
in repository https://gitbox.apache.org/repos/asf/kafka.git


The following commit(s) were added to refs/heads/trunk by this push:
     new 2a0efd08b98 KAFKA-20749: Stop offset writes leaking into data 
iteration (#22710)
2a0efd08b98 is described below

commit 2a0efd08b984738cc054e916e193521d5a57ede2
Author: Nick Telford <[email protected]>
AuthorDate: Tue Jun 30 20:06:05 2026 +0100

    KAFKA-20749: Stop offset writes leaking into data iteration (#22710)
    
    A KStream-KStream OUTER join crashes with a `SerializationException`
    under `exactly_once_v2` when `enable.transactional.statestores=true`. As
    soon as the join emits a non-joined (outer) record, the StreamThread
    dies and the outer result is never produced.
    
    `RocksDBTransactionBuffer` stages every write into a single in-memory
    `pendingWrites` map regardless of which column family the write targets.
    Writes to the offsets column family (the persisted `Position`, written
    under the `"position"` key) therefore landed in the same map as data-CF
    writes, so subsequent data-CF `range()`/`all()`/`prefixScan()` scans
    surfaced the offsets entry and tried to deserialize the serialized
    `Position` as a data record. The outer-join `ListValueStore` could not
    deserialize it as a value list and threw. The `get()` path was already
    gated against the offsets CF; the iterator paths were not.
    Non-transactional stores write straight to the offsets CF and never leak
    through the buffer, which is why this is transactional-only.
    
    The fix stages a write into `pendingWrites` only when its column family
    is the data column family (`cfHandle`). Offset-CF writes still go to the
    underlying `writeBatch` so they commit atomically with the data, but
    they no longer pollute data-CF iteration.
    
    A `RocksDBStoreTest` regression test asserts that
    `all`/`range`/`prefixScan` return only the data key after
    `writePosition()`, and a new integration test reproduces the original
    outer-join crash end to end (parameterized over
    `enable.transactional.statestores`, so the non-transactional case acts
    as a passing control).
    
    The tests in this PR originated from @bbejeck's #22709, which fixes the
    same bug via a separate `stageOffsetWrite` path; this PR takes a smaller
    approach that guards the single `stage` entry point and needs no changes
    to `RocksDBStore`.
    
    Reviewers: Bill Bejeck <[email protected]>
    
    Co-authored-by: Bill Bejeck <[email protected]>
---
 ...OuterJoinTransactionalStoreIntegrationTest.java | 222 +++++++++++++++++++++
 .../state/internals/RocksDBTransactionBuffer.java  |   6 +-
 .../streams/state/internals/RocksDBStoreTest.java  |  34 ++++
 .../internals/RocksDBTransactionBufferTest.java    |   9 +-
 4 files changed, 264 insertions(+), 7 deletions(-)

diff --git 
a/streams/integration-tests/src/test/java/org/apache/kafka/streams/integration/KStreamKStreamOuterJoinTransactionalStoreIntegrationTest.java
 
b/streams/integration-tests/src/test/java/org/apache/kafka/streams/integration/KStreamKStreamOuterJoinTransactionalStoreIntegrationTest.java
new file mode 100644
index 00000000000..ef3f6302e7a
--- /dev/null
+++ 
b/streams/integration-tests/src/test/java/org/apache/kafka/streams/integration/KStreamKStreamOuterJoinTransactionalStoreIntegrationTest.java
@@ -0,0 +1,222 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.kafka.streams.integration;
+
+import org.apache.kafka.clients.consumer.ConsumerConfig;
+import org.apache.kafka.clients.producer.ProducerConfig;
+import org.apache.kafka.common.serialization.Serdes;
+import org.apache.kafka.common.serialization.StringDeserializer;
+import org.apache.kafka.common.serialization.StringSerializer;
+import org.apache.kafka.streams.KafkaStreams;
+import org.apache.kafka.streams.KeyValue;
+import org.apache.kafka.streams.StreamsBuilder;
+import org.apache.kafka.streams.StreamsConfig;
+import org.apache.kafka.streams.errors.StreamsUncaughtExceptionHandler;
+import org.apache.kafka.streams.integration.utils.EmbeddedKafkaCluster;
+import org.apache.kafka.streams.integration.utils.IntegrationTestUtils;
+import org.apache.kafka.streams.kstream.JoinWindows;
+import org.apache.kafka.streams.kstream.KStream;
+import org.apache.kafka.streams.kstream.StreamJoined;
+import org.apache.kafka.test.TestUtils;
+
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Tag;
+import org.junit.jupiter.api.TestInfo;
+import org.junit.jupiter.api.Timeout;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import java.io.PrintWriter;
+import java.io.StringWriter;
+import java.time.Duration;
+import java.util.List;
+import java.util.Properties;
+import java.util.concurrent.atomic.AtomicReference;
+
+import static 
org.apache.kafka.streams.integration.utils.IntegrationTestUtils.startApplicationAndWaitUntilRunning;
+import static 
org.apache.kafka.streams.integration.utils.IntegrationTestUtils.waitUntilMinKeyValueRecordsReceived;
+import static org.apache.kafka.streams.utils.TestUtils.safeUniqueTestName;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+
+/**
+ * Reproduces a bug KAFKA-20749: a KStream-KStream OUTER join crashes when it
+ * emits non-joined (outer) records under {@code exactly_once_v2} <em>and</em>
+ * {@code enable.transactional.statestores=true}. Reading the outer-join buffer
+ * ({@link org.apache.kafka.streams.state.internals.ListValueStore}) throws a 
{@code SerializationException}
+ * because the transactional persistent-store value round-trip does not 
preserve the exact bytes that
+ * {@code ListValueStore} wrote with {@code Serdes.ListSerde(ArrayList.class, 
Serdes.ByteArray())}.
+ *
+ * <p>The test is parameterized over {@code enable.transactional.statestores}: 
the {@code false} case is a
+ * control that passes today (same topology, non-transactional stores), while 
the {@code true} case fails on
+ * current trunk (the {@code SerializationException} kills the StreamThread, 
so the outer result is never
+ * emitted) and passes once the bug is fixed.
+ */
+@Timeout(600)
+@Tag("integration")
+public class KStreamKStreamOuterJoinTransactionalStoreIntegrationTest {
+
+    private static final int NUM_BROKERS = 1;
+    public static final EmbeddedKafkaCluster CLUSTER = new 
EmbeddedKafkaCluster(NUM_BROKERS);
+
+    private String applicationId;
+    private String leftTopic;
+    private String rightTopic;
+    private String outputTopic;
+    private Properties streamsConfig;
+    private KafkaStreams streams;
+
+    @BeforeAll
+    public static void startCluster() throws Exception {
+        CLUSTER.start();
+    }
+
+    @AfterAll
+    public static void closeCluster() {
+        CLUSTER.stop();
+    }
+
+    @BeforeEach
+    public void before(final TestInfo testInfo) throws Exception {
+        applicationId = "outer-join-txn-store-" + safeUniqueTestName(testInfo);
+        leftTopic = applicationId + "-left";
+        rightTopic = applicationId + "-right";
+        outputTopic = applicationId + "-output";
+        CLUSTER.createTopic(leftTopic, 1, 1);
+        CLUSTER.createTopic(rightTopic, 1, 1);
+        CLUSTER.createTopic(outputTopic, 1, 1);
+
+        streamsConfig = new Properties();
+        streamsConfig.put(StreamsConfig.APPLICATION_ID_CONFIG, applicationId);
+        streamsConfig.put(StreamsConfig.BOOTSTRAP_SERVERS_CONFIG, 
CLUSTER.bootstrapServers());
+        streamsConfig.put(StreamsConfig.STATE_DIR_CONFIG, 
TestUtils.tempDirectory().getPath());
+        streamsConfig.put(StreamsConfig.DEFAULT_KEY_SERDE_CLASS_CONFIG, 
Serdes.String().getClass());
+        streamsConfig.put(StreamsConfig.DEFAULT_VALUE_SERDE_CLASS_CONFIG, 
Serdes.String().getClass());
+        streamsConfig.put(StreamsConfig.COMMIT_INTERVAL_MS_CONFIG, 100L);
+        streamsConfig.put(StreamsConfig.PROCESSING_GUARANTEE_CONFIG, 
StreamsConfig.EXACTLY_ONCE_V2);
+        // Disable the store cache so emitted records are not 
buffered/absorbed by the cache.
+        streamsConfig.put(StreamsConfig.STATESTORE_CACHE_MAX_BYTES_CONFIG, 0);
+        // Emit non-joined outer records on every stream-time advance (no 
wall-clock throttle), so a single
+        // window-advancing record deterministically triggers 
emitNonJoinedOuterRecords over the buffer.
+        
streamsConfig.put(StreamsConfig.InternalConfig.EMIT_INTERVAL_MS_KSTREAMS_OUTER_JOIN_SPURIOUS_RESULTS_FIX,
 0L);
+        streamsConfig.put(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, "earliest");
+    }
+
+    @AfterEach
+    public void after() throws Exception {
+        if (streams != null) {
+            streams.close(Duration.ofSeconds(30));
+            streams.cleanUp();
+        }
+        CLUSTER.deleteAllTopics();
+    }
+
+    @ParameterizedTest
+    @ValueSource(booleans = {false, true})
+    public void shouldEmitOuterRecordWithTransactionalStateStores(final 
boolean transactionalStateStores) throws Exception {
+        streamsConfig.put(StreamsConfig.TRANSACTIONAL_STATE_STORES_CONFIG, 
transactionalStateStores);
+
+        final StreamsBuilder builder = new StreamsBuilder();
+        final KStream<String, String> leftStream = builder.stream(leftTopic);
+        final KStream<String, String> rightStream = builder.stream(rightTopic);
+
+        // Default (persistent RocksDB) stores; with 
enable.transactional.statestores=true the outer-join
+        // ListValueStore is wrapped by the transactional accessor — the path 
that corrupts the value round-trip.
+        leftStream.outerJoin(
+            rightStream,
+            (leftValue, rightValue) -> leftValue + "/" + rightValue,
+            JoinWindows.ofTimeDifferenceWithNoGrace(Duration.ofSeconds(60)),
+            StreamJoined.with(Serdes.String(), Serdes.String(), 
Serdes.String())
+        ).to(outputTopic);
+
+        streams = new KafkaStreams(builder.build(), streamsConfig);
+
+        final AtomicReference<Throwable> uncaught = new AtomicReference<>();
+        streams.setUncaughtExceptionHandler(throwable -> {
+            uncaught.compareAndSet(null, throwable);
+            return 
StreamsUncaughtExceptionHandler.StreamThreadExceptionResponse.SHUTDOWN_CLIENT;
+        });
+
+        startApplicationAndWaitUntilRunning(streams);
+
+        // Unmatched left record -> goes into the outer-join buffer 
(ListValueStore), no matching right.
+        produceRecord(leftTopic, "A", "L1", 1000L);
+
+        // Advance stream time well past the 60s window so 
emitNonJoinedOuterRecords() runs and reads the
+        // buffer. This is where the SerializationException is thrown when 
stores are transactional.
+        produceRecord(leftTopic, "B", "L2", 62000L);
+
+        final List<KeyValue<String, String>> results;
+        try {
+            results = waitUntilMinKeyValueRecordsReceived(getConsumerConfig(), 
outputTopic, 1, 60000);
+        } catch (final AssertionError timeout) {
+            // Most informative failure: surface the StreamThread crash that 
prevented the emit.
+            final Throwable crash = uncaught.get();
+            if (crash != null) {
+                throw new AssertionError(
+                    "emitNonJoinedOuterRecords failed to emit the outer 
record; StreamThread crashed with:\n"
+                        + stackTraceToString(crash), crash);
+            }
+            throw timeout;
+        }
+
+        assertNull(uncaught.get(),
+            uncaught.get() == null ? "" : "StreamThread crashed:\n" + 
stackTraceToString(uncaught.get()));
+
+        final KeyValue<String, String> outerResult = results.stream()
+            .filter(kv -> "A".equals(kv.key))
+            .findFirst()
+            .orElse(null);
+
+        assertEquals(KeyValue.pair("A", "L1/null"), outerResult,
+            "The unmatched left record A should be emitted as the outer result 
(A, \"L1/null\")");
+    }
+
+    private void produceRecord(final String topic, final String key, final 
String value, final long timestamp) {
+        final Properties producerConfig = new Properties();
+        producerConfig.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, 
CLUSTER.bootstrapServers());
+        producerConfig.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, 
StringSerializer.class);
+        producerConfig.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, 
StringSerializer.class);
+        producerConfig.put(ProducerConfig.ACKS_CONFIG, "all");
+
+        IntegrationTestUtils.produceKeyValuesSynchronouslyWithTimestamp(
+            topic,
+            List.of(new KeyValue<>(key, value)),
+            producerConfig,
+            timestamp
+        );
+    }
+
+    private Properties getConsumerConfig() {
+        final Properties consumerConfig = new Properties();
+        consumerConfig.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, 
CLUSTER.bootstrapServers());
+        consumerConfig.put(ConsumerConfig.GROUP_ID_CONFIG, "test-consumer-" + 
applicationId);
+        consumerConfig.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, 
StringDeserializer.class);
+        consumerConfig.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, 
StringDeserializer.class);
+        consumerConfig.put(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, 
"earliest");
+        return consumerConfig;
+    }
+
+    private static String stackTraceToString(final Throwable throwable) {
+        final StringWriter sw = new StringWriter();
+        throwable.printStackTrace(new PrintWriter(sw));
+        return sw.toString();
+    }
+}
diff --git 
a/streams/src/main/java/org/apache/kafka/streams/state/internals/RocksDBTransactionBuffer.java
 
b/streams/src/main/java/org/apache/kafka/streams/state/internals/RocksDBTransactionBuffer.java
index 088d8d412b8..29da08db2d5 100644
--- 
a/streams/src/main/java/org/apache/kafka/streams/state/internals/RocksDBTransactionBuffer.java
+++ 
b/streams/src/main/java/org/apache/kafka/streams/state/internals/RocksDBTransactionBuffer.java
@@ -95,8 +95,10 @@ class RocksDBTransactionBuffer extends 
AbstractTransactionBuffer<Bytes> {
     void stage(final ColumnFamilyHandle cf, final Bytes key, final byte[] 
value) {
         snapshotLock.writeLock().lock();
         try {
-            pendingWrites.put(key, Optional.ofNullable(value));
-            pendingWritesBytes += estimateKeySize(key) + (value != null ? 
value.length : 0);
+            if (cf == cfHandle) {
+                pendingWrites.put(key, Optional.ofNullable(value));
+                pendingWritesBytes += estimateKeySize(key) + (value != null ? 
value.length : 0);
+            }
             try {
                 if (value != null) {
                     writeBatch.put(cf, key.get(), value);
diff --git 
a/streams/src/test/java/org/apache/kafka/streams/state/internals/RocksDBStoreTest.java
 
b/streams/src/test/java/org/apache/kafka/streams/state/internals/RocksDBStoreTest.java
index 70edbf9345c..fd001efa4ee 100644
--- 
a/streams/src/test/java/org/apache/kafka/streams/state/internals/RocksDBStoreTest.java
+++ 
b/streams/src/test/java/org/apache/kafka/streams/state/internals/RocksDBStoreTest.java
@@ -1616,6 +1616,40 @@ public class RocksDBStoreTest extends 
AbstractKeyValueStoreTest {
         assertTrue(rocksDBStore.getPosition().getTopics().isEmpty());
     }
 
+    @Test
+    public void offsetColumnFamilyWritesShouldNotLeakIntoDataIteration() {
+        // Regression test for the transactional-store outer-join bug - 
KAFKA-20749
+        rocksDBStore.close();
+        final InternalMockProcessorContext<?, ?> eosContext = 
getTransactionalEOSProcessorContext(dir);
+        rocksDBStore = getRocksDBStore();
+        rocksDBStore.init(eosContext, rocksDBStore);
+
+        eosContext.setRecordContext(new ProcessorRecordContext(0, 1L, 0, 
"input", new RecordHeaders()));
+        rocksDBStore.put(new Bytes(stringSerializer.serialize(null, "k1")), 
stringSerializer.serialize(null, "v1"));
+
+        // Persist the Position into the offsets column family; this stays 
staged in the buffer (not committed).
+        rocksDBStore.writePosition();
+
+        // Data-CF scans must surface only the data key, never the offsets-CF 
"position" entry.
+        try (KeyValueIterator<Bytes, byte[]> it = rocksDBStore.all()) {
+            assertEquals(List.of("k1"), keysOf(it));
+        }
+        try (KeyValueIterator<Bytes, byte[]> it = rocksDBStore.range(null, 
null)) {
+            assertEquals(List.of("k1"), keysOf(it));
+        }
+        try (KeyValueIterator<Bytes, byte[]> it = rocksDBStore.prefixScan("k", 
stringSerializer)) {
+            assertEquals(List.of("k1"), keysOf(it));
+        }
+    }
+
+    private List<String> keysOf(final KeyValueIterator<Bytes, byte[]> it) {
+        final List<String> keys = new ArrayList<>();
+        while (it.hasNext()) {
+            keys.add(stringDeserializer.deserialize(null, 
it.next().key.get()));
+        }
+        return keys;
+    }
+
     public static class TestingBloomFilterRocksDBConfigSetter implements 
RocksDBConfigSetter {
 
         static boolean bloomFiltersSet;
diff --git 
a/streams/src/test/java/org/apache/kafka/streams/state/internals/RocksDBTransactionBufferTest.java
 
b/streams/src/test/java/org/apache/kafka/streams/state/internals/RocksDBTransactionBufferTest.java
index b0f7f11e37b..db15e266c7a 100644
--- 
a/streams/src/test/java/org/apache/kafka/streams/state/internals/RocksDBTransactionBufferTest.java
+++ 
b/streams/src/test/java/org/apache/kafka/streams/state/internals/RocksDBTransactionBufferTest.java
@@ -420,9 +420,8 @@ public class RocksDBTransactionBufferTest {
     public void shouldStageWriteToOtherCF() throws RocksDBException {
         buffer.stage(otherCfHandle, key("x"), val("other-x"));
 
-        // Shared read buffer sees the staged value
-        assertTrue(buffer.get(key("x")).isPresent());
-        assertArrayEquals(val("other-x"), buffer.get(key("x")).get());
+        // Non-primary CF writes are not visible in the staging read buffer
+        assertNull(buffer.get(key("x")));
         // Not yet flushed to RocksDB
         assertNull(db.get(otherCfHandle, key("x").get()));
 
@@ -437,8 +436,8 @@ public class RocksDBTransactionBufferTest {
 
         buffer.stage(otherCfHandle, key("x"), null);
 
-        // Shared read buffer shows tombstone
-        assertEquals(Optional.empty(), buffer.get(key("x")));
+        // Non-primary CF deletes are not visible in the staging read buffer
+        assertNull(buffer.get(key("x")));
         // Not yet flushed
         assertArrayEquals(val("other-x"), db.get(otherCfHandle, 
key("x").get()));
 

Reply via email to