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 8de5c3af83d KAFKA-20503: Add integration tests for transaction 
buffering (#22958)
8de5c3af83d is described below

commit 8de5c3af83da6b11c0d1dc3c332d77fe6dcf5672
Author: Nick Telford <[email protected]>
AuthorDate: Tue Aug 4 15:45:41 2026 +0100

    KAFKA-20503: Add integration tests for transaction buffering (#22958)
    
    We need to verify that, when transactional state stores are enabled and
    `processing.guarantee` is `exactly_once_v2`, records are buffered in the
    transaction buffer (invisible to a READ_COMMITTED reader) until the
    Streams commit cycle completes, and then committed to the store as
    expected.
    
    This reuses `EosIntegrationTest`, which already owns the EOS invariant
    machinery and a `transactionalStateStores` parameter. A new
    `shouldBufferStateStoreWritesUntilCommitUnderEos` test drives three
    bursts of writes across a commit boundary and asserts the
    READ_COMMITTED/READ_UNCOMMITTED store views at each step, via a new
    isolation-aware `verifyStateStore`/`queryStateStore` and a
    `waitForStateStore` helper that polls for the buffer flush
    (`context.commit()` only requests a commit; the actual flush happens
    asynchronously afterwards).
    
    The transactional dimension is orthogonal to group protocol and
    processing-threads, so — consistent with this file's existing
    sparse-matrix convention — the new test and the existing transactional
    parameterization are each exercised via a single representative
    combination rather than the full matrix, to avoid unnecessary
    integration-test runtime.
    
    Reviewers: Bill Bejeck <[email protected]>
---
 .../streams/integration/EosIntegrationTest.java    | 118 ++++++++++++++++++++-
 1 file changed, 115 insertions(+), 3 deletions(-)

diff --git 
a/streams/integration-tests/src/test/java/org/apache/kafka/streams/integration/EosIntegrationTest.java
 
b/streams/integration-tests/src/test/java/org/apache/kafka/streams/integration/EosIntegrationTest.java
index f39a937bd8e..2ea213789f3 100644
--- 
a/streams/integration-tests/src/test/java/org/apache/kafka/streams/integration/EosIntegrationTest.java
+++ 
b/streams/integration-tests/src/test/java/org/apache/kafka/streams/integration/EosIntegrationTest.java
@@ -72,6 +72,7 @@ import org.junit.jupiter.api.AfterAll;
 import org.junit.jupiter.api.BeforeAll;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Tag;
+import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.Timeout;
 import org.junit.jupiter.params.ParameterizedTest;
 import org.junit.jupiter.params.provider.Arguments;
@@ -92,6 +93,7 @@ import java.util.HashMap;
 import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
+import java.util.Optional;
 import java.util.Properties;
 import java.util.Set;
 import java.util.concurrent.CountDownLatch;
@@ -632,6 +634,77 @@ public class EosIntegrationTest {
         }
     }
 
+    @Test
+    public void shouldBufferStateStoreWritesUntilCommitUnderEos() throws 
Exception {
+        // this test writes 10 + 3 + 7 records per key (running with 2 
partitions, one key per partition)
+        //
+        // the first burst crosses the processor's commit boundary (value % 10 
== 9) and is committed
+        // the second burst does not cross a commit boundary, so it is left 
staged in the transaction
+        // buffer: it must be visible to a READ_UNCOMMITTED reader but NOT to 
a READ_COMMITTED reader
+        // the third burst crosses the next commit boundary, flushing the 
buffer to the store: the
+        // READ_COMMITTED view must then reflect both staged bursts
+        //
+        // buffer/commit behaviour is independent of group protocol and 
processing-threads (see
+        // groupProtocolProcessingThreadsAndTransactionalParameters), so a 
single representative
+        // combination is sufficient here.
+        try (final KafkaStreams streams = getKafkaStreams("dummy", true, 
"appDir", 2, "classic", false, true)) {
+            startApplicationAndWaitUntilRunning(streams);
+
+            final List<KeyValue<Long, Long>> firstCommittedBurst = 
prepareData(0L, 10L, 0L, 1L);
+            writeInputData(firstCommittedBurst);
+
+            waitForCondition(
+                () -> commitRequested.get() == 2, MAX_WAIT_TIME_MS,
+                "StreamsTasks did not request commit for the first burst.");
+
+            final Set<KeyValue<Long, Long>> expectedAfterFirstCommit = 
getMaxPerKey(computeExpectedResult(firstCommittedBurst));
+
+            // context.commit() only requests a commit; the transaction buffer 
is flushed to the
+            // store asynchronously afterwards, so wait for the READ_COMMITTED 
view to catch up.
+            // This also guarantees the commit has fully completed before the 
staged burst below is
+            // written, so it cannot be swept into this same commit.
+            waitForStateStore(
+                streams, new HashSet<>(expectedAfterFirstCommit), 
IsolationLevel.READ_COMMITTED,
+                "The state store should reflect the first committed burst at 
READ_COMMITTED");
+            verifyStateStore(
+                streams, new HashSet<>(expectedAfterFirstCommit), 
IsolationLevel.READ_UNCOMMITTED,
+                "The state store should reflect the first committed burst at 
READ_UNCOMMITTED");
+
+            final List<KeyValue<Long, Long>> stagedBurst = prepareData(10L, 
13L, 0L, 1L);
+            writeInputData(stagedBurst);
+
+            final List<KeyValue<Long, Long>> dataSoFar = new 
ArrayList<>(firstCommittedBurst);
+            dataSoFar.addAll(stagedBurst);
+
+            // the staged burst is visible in the output topic at 
READ_UNCOMMITTED ...
+            readResult(SINGLE_PARTITION_OUTPUT_TOPIC, dataSoFar.size(), null);
+
+            // ... and in the store at READ_UNCOMMITTED, but READ_COMMITTED 
must still only reflect
+            // the already-committed burst: this is the core buffering 
invariant under test
+            verifyStateStore(
+                streams, getMaxPerKey(computeExpectedResult(dataSoFar)), 
IsolationLevel.READ_UNCOMMITTED,
+                "The state store should reflect the staged burst at 
READ_UNCOMMITTED");
+            verifyStateStore(
+                streams, new HashSet<>(expectedAfterFirstCommit), 
IsolationLevel.READ_COMMITTED,
+                "The state store must not reflect the staged burst at 
READ_COMMITTED before commit");
+
+            // cross the next commit boundary, forcing the transaction buffer 
to flush to the store
+            final List<KeyValue<Long, Long>> secondCommittedBurst = 
prepareData(13L, 20L, 0L, 1L);
+            writeInputData(secondCommittedBurst);
+
+            waitForCondition(
+                () -> commitRequested.get() == 4, MAX_WAIT_TIME_MS,
+                "StreamsTasks did not request commit for the second burst.");
+
+            final List<KeyValue<Long, Long>> allData = new 
ArrayList<>(dataSoFar);
+            allData.addAll(secondCommittedBurst);
+
+            waitForStateStore(
+                streams, getMaxPerKey(computeExpectedResult(allData)), 
IsolationLevel.READ_COMMITTED,
+                "The state store should reflect the second committed burst at 
READ_COMMITTED");
+        }
+    }
+
     @ParameterizedTest
     @MethodSource("groupProtocolAndProcessingThreadsParameters")
     public void 
shouldNotViolateEosIfOneTaskGetsFencedUsingIsolatedAppInstances(final String 
groupProtocol, final boolean processingThreadsEnabled) throws Exception {
@@ -1422,20 +1495,59 @@ public class EosIntegrationTest {
     private void verifyStateStore(final KafkaStreams streams,
                                   final Set<KeyValue<Long, Long>> 
expectedStoreContent,
                                   final String reason) {
-        final StateQueryRequest<KeyValueIterator<Long, Long>> request =
+        verifyStateStore(streams, expectedStoreContent, Optional.empty(), 
reason);
+    }
+
+    private void verifyStateStore(final KafkaStreams streams,
+                                  final Set<KeyValue<Long, Long>> 
expectedStoreContent,
+                                  final IsolationLevel isolationLevel,
+                                  final String reason) {
+        verifyStateStore(streams, expectedStoreContent, 
Optional.of(isolationLevel), reason);
+    }
+
+    private void verifyStateStore(final KafkaStreams streams,
+                                  final Set<KeyValue<Long, Long>> 
expectedStoreContent,
+                                  final Optional<IsolationLevel> 
isolationLevel,
+                                  final String reason) {
+        for (final KeyValue<Long, Long> actual : queryStateStore(streams, 
isolationLevel)) {
+            assertTrue(expectedStoreContent.remove(actual), reason);
+        }
+
+        assertTrue(expectedStoreContent.isEmpty(), reason);
+    }
+
+    private Set<KeyValue<Long, Long>> queryStateStore(final KafkaStreams 
streams,
+                                                       final 
Optional<IsolationLevel> isolationLevel) {
+        StateQueryRequest<KeyValueIterator<Long, Long>> request =
                 inStore(storeName).withQuery(RangeQuery.withNoBounds());
+        if (isolationLevel.isPresent()) {
+            request = request.withIsolationLevel(isolationLevel.get());
+        }
 
         final StateQueryResult<KeyValueIterator<Long, Long>> result =
                 IntegrationTestUtils.iqv2WaitForResult(streams, request);
 
+        final Set<KeyValue<Long, Long>> actualStoreContent = new HashSet<>();
         for (final QueryResult<KeyValueIterator<Long, Long>> partitionResult: 
result.getPartitionResults().values()) {
             try (final KeyValueIterator<Long, Long> it = 
partitionResult.getResult()) {
                 while (it.hasNext()) {
-                    assertTrue(expectedStoreContent.remove(it.next()), reason);
+                    actualStoreContent.add(it.next());
                 }
             }
         }
+        return actualStoreContent;
+    }
 
-        assertTrue(expectedStoreContent.isEmpty(), reason);
+    // Buffered writes are only flushed to the store when the Streams commit 
cycle completes, which
+    // happens asynchronously after context.commit() is requested. Poll until 
the READ_COMMITTED view
+    // catches up rather than asserting immediately.
+    private void waitForStateStore(final KafkaStreams streams,
+                                   final Set<KeyValue<Long, Long>> 
expectedStoreContent,
+                                   final IsolationLevel isolationLevel,
+                                   final String reason) throws Exception {
+        waitForCondition(
+            () -> queryStateStore(streams, 
Optional.of(isolationLevel)).equals(expectedStoreContent),
+            MAX_WAIT_TIME_MS,
+            reason);
     }
 }

Reply via email to