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

swaminathanmanish pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/pinot.git


The following commit(s) were added to refs/heads/master by this push:
     new a902d97ff6d Fix false StreamDataLoss on transactional Kafka topics 
(#19161)
a902d97ff6d is described below

commit a902d97ff6dc3c7ebcce044ae464991ab934660b
Author: swaminathanmanish <[email protected]>
AuthorDate: Wed Aug 12 16:25:20 2026 +0530

    Fix false StreamDataLoss on transactional Kafka topics (#19161)
    
    * Fix false StreamDataLoss on transactional Kafka topics
    
    An offset gap between the requested startOffset and the first returned
    record was treated as data loss under read_uncommitted. Transactional
    producers write commit/abort control records that occupy offsets but are
    never delivered to the consumer, so a healthy contiguous stream
    legitimately has gaps, raising false StreamDataLoss alerts.
    
    Only flag data loss when the requested startOffset is below the broker's
    log start offset (beginningOffsets), i.e. records at/after startOffset
    were actually deleted via retention or truncation. When the log start
    offset cannot be determined, default to no data loss to avoid false
    positives. Applied to both kafka-3.0 and kafka-4.0, with unit tests
    covering transactional gaps, real truncation, contiguous batches,
    read_committed, and lookup failure.
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
    
    * Replace mocked data-loss unit tests with real-broker integration tests
    
    Remove the Mockito-based KafkaPartitionLevelConsumerDataLossTest in 
kafka-3.0 and
    kafka-4.0 and add KafkaPartitionLevelConsumerDataLossIntegrationTest in 
each, exercising
    the transactional false-StreamDataLoss fix against a real broker 
(in-process embedded
    KRaft for 3.0, Testcontainers for 4.0):
    - transactional commit-marker gap with data retained -> no data loss
    - startOffset below log start (records deleted) -> data loss flagged
    - read_committed gap -> no data loss
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
    
    * Log a WARN when the log-start-offset lookup fails during gap check
    
    Address review feedback: previously the beginningOffsets failure in 
getLogStartOffset was
    logged only at DEBUG, so a failure that defaults the gap to no-data-loss 
was effectively silent
    in production and could mask a genuine loss. Log it at WARN instead. 
Applied to kafka-3.0 and
    kafka-4.0.
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
    
    ---------
    
    Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
---
 .../kafka30/KafkaPartitionLevelConsumer.java       |  30 ++-
 ...titionLevelConsumerDataLossIntegrationTest.java | 242 +++++++++++++++++++++
 .../kafka40/KafkaPartitionLevelConsumer.java       |  30 ++-
 ...titionLevelConsumerDataLossIntegrationTest.java | 239 ++++++++++++++++++++
 4 files changed, 531 insertions(+), 10 deletions(-)

diff --git 
a/pinot-plugins/pinot-stream-ingestion/pinot-kafka-3.0/src/main/java/org/apache/pinot/plugin/stream/kafka30/KafkaPartitionLevelConsumer.java
 
b/pinot-plugins/pinot-stream-ingestion/pinot-kafka-3.0/src/main/java/org/apache/pinot/plugin/stream/kafka30/KafkaPartitionLevelConsumer.java
index 6e81b19d23c..538a191d135 100644
--- 
a/pinot-plugins/pinot-stream-ingestion/pinot-kafka-3.0/src/main/java/org/apache/pinot/plugin/stream/kafka30/KafkaPartitionLevelConsumer.java
+++ 
b/pinot-plugins/pinot-stream-ingestion/pinot-kafka-3.0/src/main/java/org/apache/pinot/plugin/stream/kafka30/KafkaPartitionLevelConsumer.java
@@ -165,15 +165,35 @@ public class KafkaPartitionLevelConsumer extends 
KafkaPartitionLevelConnectionHa
       }
     }
     long offsetOfNextBatch = _nextReadOffset;
-    // For read_uncommitted (the default), a non-contiguous returned batch 
implies data
-    // loss (records dropped before being read). For read_committed the offset 
gap is
-    // expected because the broker filters aborted transactional records, so 
we don't flag
-    // it as data loss.
-    boolean hasDataLoss = !_isReadCommitted && firstOffset > startOffset;
+    // A gap between the requested startOffset and the first returned offset 
does NOT by itself
+    // imply data loss. Transactional producers write commit/abort control 
records that occupy
+    // offsets but are never delivered to the consumer (even under 
read_uncommitted), so a
+    // contiguous stream of user records legitimately has offset gaps. Real 
data loss only
+    // happens when the requested startOffset is below the log's start offset, 
i.e. the broker
+    // has already deleted (via retention or truncation) records at or after 
startOffset. For
+    // read_committed we never flag loss because aborted-record gaps are 
always expected.
+    boolean hasDataLoss = false;
+    if (!_isReadCommitted && firstOffset > startOffset) {
+      hasDataLoss = getLogStartOffset(timeoutMs) > startOffset;
+    }
     return new KafkaMessageBatch(filteredRecords, records.size(), 
offsetOfNextBatch, firstOffset, lastMessageMetadata,
         hasDataLoss, batchSizeInBytes);
   }
 
+  /// Returns the log start (earliest available) offset for the partition, 
bounded by the same
+  /// timeout as [#poll]. Returns [Long#MIN_VALUE] when it cannot be 
determined so the caller
+  /// treats an offset gap as expected (no data loss) rather than raising a 
false positive.
+  private long getLogStartOffset(int timeoutMs) {
+    try {
+      return _consumer.beginningOffsets(List.of(_topicPartition), 
Duration.ofMillis(timeoutMs))
+          .getOrDefault(_topicPartition, Long.MIN_VALUE);
+    } catch (Exception e) {
+      LOGGER.warn("Failed to read log start offset for {}; treating the offset 
gap as no data loss "
+          + "(this can mask genuine data loss if it persists)", 
_topicPartition, e);
+      return Long.MIN_VALUE;
+    }
+  }
+
   private static boolean isReadCommitted(KafkaPartitionLevelStreamConfig 
config) {
     String level = config.getKafkaIsolationLevel();
     return level != null
diff --git 
a/pinot-plugins/pinot-stream-ingestion/pinot-kafka-3.0/src/test/java/org/apache/pinot/plugin/stream/kafka30/KafkaPartitionLevelConsumerDataLossIntegrationTest.java
 
b/pinot-plugins/pinot-stream-ingestion/pinot-kafka-3.0/src/test/java/org/apache/pinot/plugin/stream/kafka30/KafkaPartitionLevelConsumerDataLossIntegrationTest.java
new file mode 100644
index 00000000000..08fb6e884a7
--- /dev/null
+++ 
b/pinot-plugins/pinot-stream-ingestion/pinot-kafka-3.0/src/test/java/org/apache/pinot/plugin/stream/kafka30/KafkaPartitionLevelConsumerDataLossIntegrationTest.java
@@ -0,0 +1,242 @@
+/**
+ * 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.pinot.plugin.stream.kafka30;
+
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Properties;
+import java.util.UUID;
+import org.apache.kafka.clients.producer.KafkaProducer;
+import org.apache.kafka.clients.producer.ProducerConfig;
+import org.apache.kafka.clients.producer.ProducerRecord;
+import org.apache.kafka.common.serialization.StringSerializer;
+import org.apache.pinot.plugin.stream.kafka.KafkaMessageBatch;
+import org.apache.pinot.plugin.stream.kafka30.server.EmbeddedKafkaCluster;
+import org.apache.pinot.spi.stream.LongMsgOffset;
+import org.apache.pinot.spi.stream.StreamConfig;
+import org.testng.annotations.AfterClass;
+import org.testng.annotations.BeforeClass;
+import org.testng.annotations.Test;
+
+import static org.testng.Assert.assertFalse;
+import static org.testng.Assert.assertTrue;
+
+
+/// End-to-end (real embedded broker) regression tests for 
[KafkaPartitionLevelConsumer] data-loss
+/// detection (see the fix for false `StreamDataLoss` on transactional Kafka 
topics).
+///
+/// Unlike [KafkaPartitionLevelConsumerDataLossTest] (which mocks the Kafka 
consumer), these tests
+/// run against an in-process [EmbeddedKafkaCluster], so they exercise the 
real transactional
+/// control-record offset gaps and the real `beginningOffsets` round-trip 
added by the fix.
+///
+/// Two scenarios, mirroring the reviewer's request:
+/// 1. Perform a real transaction and confirm no data loss is reported for the 
(expected) offset gap
+///    left by commit control records while the data is still retained.
+/// 2. Delete offsets (advance the log start via 
[EmbeddedKafkaCluster#deleteRecordsBeforeOffset])
+///    and confirm data loss IS reported.
+///
+/// Speed/stability: setup uses only synchronous broker calls 
(createTopics().all().get(),
+/// commitTransaction()/flush(), deleteRecords().all().get()), so there are no 
fixed sleeps. Reads
+/// use [#fetchUntilRecords] which polls at the same offset until data 
arrives, tolerating an empty
+/// first poll (and the offset reset in the truncation case) without racing.
+public class KafkaPartitionLevelConsumerDataLossIntegrationTest {
+  // Short per-poll timeout so an (occasional) empty first poll retries 
quickly instead of blocking;
+  // the happy path returns data on the first poll well within this bound.
+  private static final int FETCH_TIMEOUT_MS = 2000;
+  // Overall budget for a single logical fetch to return records (covers 
metadata propagation and,
+  // for the truncation case, the offset reset taking effect).
+  private static final long FETCH_MAX_WAIT_MS = 30000;
+
+  // Transactional topic: two committed transactions of 10 records each. Under 
the default
+  // read_uncommitted isolation the commit control record after txn-1 occupies 
offset 10 (never
+  // delivered to the consumer), so txn-2's user records start at offset 11 -> 
a legitimate gap.
+  private static final String TXN_TOPIC = "txn-gap";
+  private static final int RECORDS_PER_TXN = 10;
+  private static final long TXN1_COMMIT_MARKER_OFFSET = 10;
+  private static final long TXN2_FIRST_RECORD_OFFSET = 11;
+
+  // Truncated topic: 30 contiguous records, then everything before offset 20 
is deleted, so the
+  // log start offset advances to 20 (records at/after the requested 
startOffset were removed).
+  private static final String TRUNCATED_TOPIC = "truncated";
+  private static final int TRUNCATED_TOPIC_RECORDS = 30;
+  private static final long TRUNCATE_BEFORE_OFFSET = 20;
+
+  private EmbeddedKafkaCluster _kafkaCluster;
+  private String _kafkaBrokerAddress;
+
+  @BeforeClass
+  public void setUp()
+      throws Exception {
+    Properties props = new Properties();
+    props.setProperty(EmbeddedKafkaCluster.BROKER_COUNT_PROP, "1");
+    _kafkaCluster = new EmbeddedKafkaCluster();
+    _kafkaCluster.init(props);
+    _kafkaCluster.start();
+    _kafkaBrokerAddress = _kafkaCluster.bootstrapServers();
+
+    // createTopic uses AdminClient.createTopics().all().get() -> synchronous, 
no sleep needed.
+    _kafkaCluster.createTopic(TXN_TOPIC, 1);
+    _kafkaCluster.createTopic(TRUNCATED_TOPIC, 1);
+
+    // commitTransaction()/flush() are synchronous -> records are durable on 
return, no sleep needed.
+    produceTransactional(TXN_TOPIC, 2, RECORDS_PER_TXN);
+    producePlain(TRUNCATED_TOPIC, TRUNCATED_TOPIC_RECORDS);
+
+    // deleteRecords().all().get() is synchronous -> log start offset advanced 
on return.
+    _kafkaCluster.deleteRecordsBeforeOffset(TRUNCATED_TOPIC, 0, 
TRUNCATE_BEFORE_OFFSET);
+  }
+
+  @AfterClass
+  public void tearDown() {
+    try {
+      _kafkaCluster.deleteTopic(TXN_TOPIC);
+      _kafkaCluster.deleteTopic(TRUNCATED_TOPIC);
+    } finally {
+      _kafkaCluster.stop();
+    }
+  }
+
+  /// Scenario 1: a real committed transaction leaves an offset gap at the 
commit control record,
+  /// but all user data at/after the requested startOffset is still retained. 
This must NOT be
+  /// flagged as data loss (the pre-fix code did, raising false StreamDataLoss 
alerts).
+  @Test
+  public void testTransactionalGapWithRetainedDataIsNotDataLoss()
+      throws Exception {
+    // read_uncommitted (default) is the only mode where the pre-fix bug 
manifested.
+    StreamConfig streamConfig = streamConfig(TXN_TOPIC, null, null);
+    try (KafkaPartitionLevelConsumer consumer =
+        new KafkaPartitionLevelConsumer("txn-gap-client", streamConfig, 0)) {
+      // Seek to the commit-marker offset; the first delivered user record is 
txn-2's at offset 11.
+      KafkaMessageBatch batch = fetchUntilRecords(consumer, 
TXN1_COMMIT_MARKER_OFFSET);
+
+      assertTrue(batch.getMessageCount() > 0, "Expected txn-2 records to be 
returned");
+      // An offset gap MUST exist (first delivered offset is past the 
requested commit-marker offset)
+      // -- otherwise the test would pass without exercising the data-loss 
code path at all.
+      assertTrue(firstOffset(batch) > TXN1_COMMIT_MARKER_OFFSET,
+          "Expected an offset gap over the commit control record (first user 
record is offset "
+              + TXN2_FIRST_RECORD_OFFSET + ")");
+      assertFalse(batch.hasDataLoss(),
+          "Offset gap from a transactional commit marker (data retained, 
logStart <= startOffset) "
+              + "must not be reported as data loss");
+    }
+  }
+
+  /// Scenario 2: records at/after the requested startOffset were deleted (log 
start offset advanced
+  /// past it). This IS genuine data loss and must be flagged.
+  @Test
+  public void testTruncatedStartOffsetIsDataLoss()
+      throws Exception {
+    // auto.offset.reset=earliest so the expired startOffset resets to the 
(advanced) log start.
+    StreamConfig streamConfig = streamConfig(TRUNCATED_TOPIC, null, 
"earliest");
+    try (KafkaPartitionLevelConsumer consumer =
+        new KafkaPartitionLevelConsumer("truncated-client", streamConfig, 0)) {
+      // Request offset 0, which has been deleted (log start is now 20).
+      KafkaMessageBatch batch = fetchUntilRecords(consumer, 0);
+
+      assertTrue(batch.getMessageCount() > 0, "Expected the retained tail of 
records to be returned");
+      assertTrue(firstOffset(batch) >= TRUNCATE_BEFORE_OFFSET,
+          "First returned offset should be at/after the advanced log start");
+      assertTrue(batch.hasDataLoss(),
+          "startOffset below the log start offset (records truncated) must be 
reported as data loss");
+    }
+  }
+
+  /// Scenario 3: under read_committed the same transactional gap must never 
be flagged as loss
+  /// (aborted/commit control gaps are always expected). This exercises the 
short-circuit that
+  /// skips the beginningOffsets round-trip entirely.
+  @Test
+  public void testReadCommittedGapIsNotDataLoss()
+      throws Exception {
+    StreamConfig streamConfig = streamConfig(TXN_TOPIC, "read_committed", 
null);
+    try (KafkaPartitionLevelConsumer consumer =
+        new KafkaPartitionLevelConsumer("txn-gap-rc-client", streamConfig, 0)) 
{
+      KafkaMessageBatch batch = fetchUntilRecords(consumer, 
TXN1_COMMIT_MARKER_OFFSET);
+
+      assertTrue(batch.getMessageCount() > 0, "Expected txn-2 records to be 
returned");
+      assertTrue(firstOffset(batch) > TXN1_COMMIT_MARKER_OFFSET, "Sanity: an 
offset gap must exist");
+      assertFalse(batch.hasDataLoss(), "read_committed must never flag an 
offset gap as data loss");
+    }
+  }
+
+  /// Polls repeatedly at the same startOffset until a non-empty batch is 
returned (or the wait
+  /// budget elapses). Repeating the same startOffset hits the consumer's "no 
re-seek" path, so this
+  /// does not disturb offset positioning; it only tolerates an empty first 
poll while data is
+  /// fetched (and, for the truncation case, while the offset reset takes 
effect).
+  private KafkaMessageBatch fetchUntilRecords(KafkaPartitionLevelConsumer 
consumer, long startOffset) {
+    long deadlineMs = System.currentTimeMillis() + FETCH_MAX_WAIT_MS;
+    KafkaMessageBatch batch = consumer.fetchMessages(new 
LongMsgOffset(startOffset), FETCH_TIMEOUT_MS);
+    while (batch.getMessageCount() == 0 && System.currentTimeMillis() < 
deadlineMs) {
+      batch = consumer.fetchMessages(new LongMsgOffset(startOffset), 
FETCH_TIMEOUT_MS);
+    }
+    return batch;
+  }
+
+  private static long firstOffset(KafkaMessageBatch batch) {
+    return Long.parseLong(batch.getFirstMessageOffset().toString());
+  }
+
+  private StreamConfig streamConfig(String topic, String isolationLevel, 
String autoOffsetReset) {
+    Map<String, String> streamConfigMap = new HashMap<>();
+    streamConfigMap.put("streamType", "kafka");
+    streamConfigMap.put("stream.kafka.topic.name", topic);
+    streamConfigMap.put("stream.kafka.broker.list", _kafkaBrokerAddress);
+    streamConfigMap.put("stream.kafka.consumer.factory.class.name", 
KafkaConsumerFactory.class.getName());
+    streamConfigMap.put("stream.kafka.decoder.class.name", "decoderClass");
+    if (isolationLevel != null) {
+      streamConfigMap.put("stream.kafka.isolation.level", isolationLevel);
+    }
+    if (autoOffsetReset != null) {
+      streamConfigMap.put("auto.offset.reset", autoOffsetReset);
+    }
+    return new StreamConfig("tableName_REALTIME", streamConfigMap);
+  }
+
+  private void produceTransactional(String topic, int numTransactions, int 
recordsPerTransaction) {
+    Properties props = producerProps();
+    props.put(ProducerConfig.TRANSACTIONAL_ID_CONFIG, "test-transaction-" + 
UUID.randomUUID());
+    int seq = 0;
+    try (KafkaProducer<String, String> producer = new KafkaProducer<>(props)) {
+      producer.initTransactions();
+      for (int t = 0; t < numTransactions; t++) {
+        producer.beginTransaction();
+        for (int i = 0; i < recordsPerTransaction; i++) {
+          producer.send(new ProducerRecord<>(topic, 0, null, "msg-" + 
(seq++)));
+        }
+        producer.commitTransaction();
+      }
+    }
+  }
+
+  private void producePlain(String topic, int count) {
+    try (KafkaProducer<String, String> producer = new 
KafkaProducer<>(producerProps())) {
+      for (int i = 0; i < count; i++) {
+        producer.send(new ProducerRecord<>(topic, 0, null, "msg-" + i));
+      }
+      producer.flush();
+    }
+  }
+
+  private Properties producerProps() {
+    Properties props = new Properties();
+    props.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, _kafkaBrokerAddress);
+    props.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, 
StringSerializer.class.getName());
+    props.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, 
StringSerializer.class.getName());
+    return props;
+  }
+}
diff --git 
a/pinot-plugins/pinot-stream-ingestion/pinot-kafka-4.0/src/main/java/org/apache/pinot/plugin/stream/kafka40/KafkaPartitionLevelConsumer.java
 
b/pinot-plugins/pinot-stream-ingestion/pinot-kafka-4.0/src/main/java/org/apache/pinot/plugin/stream/kafka40/KafkaPartitionLevelConsumer.java
index d85539436c6..efd5f4daf52 100644
--- 
a/pinot-plugins/pinot-stream-ingestion/pinot-kafka-4.0/src/main/java/org/apache/pinot/plugin/stream/kafka40/KafkaPartitionLevelConsumer.java
+++ 
b/pinot-plugins/pinot-stream-ingestion/pinot-kafka-4.0/src/main/java/org/apache/pinot/plugin/stream/kafka40/KafkaPartitionLevelConsumer.java
@@ -166,15 +166,35 @@ public class KafkaPartitionLevelConsumer extends 
KafkaPartitionLevelConnectionHa
     }
     long offsetOfNextBatch = _nextReadOffset;
 
-    // For read_uncommitted (the default), a non-contiguous returned batch 
implies data
-    // loss (records dropped before being read). For read_committed the offset 
gap is
-    // expected because the broker filters aborted transactional records, so 
we don't flag
-    // it as data loss.
-    boolean hasDataLoss = !_isReadCommitted && firstOffset > startOffset;
+    // A gap between the requested startOffset and the first returned offset 
does NOT by itself
+    // imply data loss. Transactional producers write commit/abort control 
records that occupy
+    // offsets but are never delivered to the consumer (even under 
read_uncommitted), so a
+    // contiguous stream of user records legitimately has offset gaps. Real 
data loss only
+    // happens when the requested startOffset is below the log's start offset, 
i.e. the broker
+    // has already deleted (via retention or truncation) records at or after 
startOffset. For
+    // read_committed we never flag loss because aborted-record gaps are 
always expected.
+    boolean hasDataLoss = false;
+    if (!_isReadCommitted && firstOffset > startOffset) {
+      hasDataLoss = getLogStartOffset(timeoutMs) > startOffset;
+    }
     return new KafkaMessageBatch(filteredRecords, records.size(), 
offsetOfNextBatch, firstOffset, lastMessageMetadata,
         hasDataLoss, batchSizeInBytes);
   }
 
+  /// Returns the log start (earliest available) offset for the partition, 
bounded by the same
+  /// timeout as [#poll]. Returns [Long#MIN_VALUE] when it cannot be 
determined so the caller
+  /// treats an offset gap as expected (no data loss) rather than raising a 
false positive.
+  private long getLogStartOffset(int timeoutMs) {
+    try {
+      return _consumer.beginningOffsets(List.of(_topicPartition), 
Duration.ofMillis(timeoutMs))
+          .getOrDefault(_topicPartition, Long.MIN_VALUE);
+    } catch (Exception e) {
+      LOGGER.warn("Failed to read log start offset for {}; treating the offset 
gap as no data loss "
+          + "(this can mask genuine data loss if it persists)", 
_topicPartition, e);
+      return Long.MIN_VALUE;
+    }
+  }
+
   private static boolean isReadCommitted(KafkaPartitionLevelStreamConfig 
config) {
     String level = config.getKafkaIsolationLevel();
     return level != null
diff --git 
a/pinot-plugins/pinot-stream-ingestion/pinot-kafka-4.0/src/test/java/org/apache/pinot/plugin/stream/kafka40/KafkaPartitionLevelConsumerDataLossIntegrationTest.java
 
b/pinot-plugins/pinot-stream-ingestion/pinot-kafka-4.0/src/test/java/org/apache/pinot/plugin/stream/kafka40/KafkaPartitionLevelConsumerDataLossIntegrationTest.java
new file mode 100644
index 00000000000..4d6316f6298
--- /dev/null
+++ 
b/pinot-plugins/pinot-stream-ingestion/pinot-kafka-4.0/src/test/java/org/apache/pinot/plugin/stream/kafka40/KafkaPartitionLevelConsumerDataLossIntegrationTest.java
@@ -0,0 +1,239 @@
+/**
+ * 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.pinot.plugin.stream.kafka40;
+
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Properties;
+import java.util.UUID;
+import org.apache.kafka.clients.producer.KafkaProducer;
+import org.apache.kafka.clients.producer.ProducerConfig;
+import org.apache.kafka.clients.producer.ProducerRecord;
+import org.apache.kafka.common.serialization.StringSerializer;
+import org.apache.pinot.plugin.stream.kafka.KafkaMessageBatch;
+import org.apache.pinot.plugin.stream.kafka40.utils.MiniKafkaCluster;
+import org.apache.pinot.spi.stream.LongMsgOffset;
+import org.apache.pinot.spi.stream.StreamConfig;
+import org.testng.annotations.AfterClass;
+import org.testng.annotations.BeforeClass;
+import org.testng.annotations.Test;
+
+import static org.testng.Assert.assertFalse;
+import static org.testng.Assert.assertTrue;
+
+
+/// End-to-end (real broker) regression tests for 
[KafkaPartitionLevelConsumer] data-loss detection
+/// (see the fix for false `StreamDataLoss` on transactional Kafka topics).
+///
+/// Unlike [KafkaPartitionLevelConsumerDataLossTest] (which mocks the Kafka 
consumer), these tests
+/// run against a real [MiniKafkaCluster], so they exercise the real 
transactional control-record
+/// offset gaps and the real `beginningOffsets` round-trip added by the fix.
+///
+/// Two scenarios, mirroring the reviewer's request:
+/// 1. Perform a real transaction and confirm no data loss is reported for the 
(expected) offset gap
+///    left by commit control records while the data is still retained.
+/// 2. Delete offsets (advance the log start via 
[MiniKafkaCluster#deleteRecordsBeforeOffset]) and
+///    confirm data loss IS reported.
+///
+/// Speed/stability: setup uses only synchronous broker calls 
(createTopics().all().get(),
+/// commitTransaction()/flush(), deleteRecords().all().get()), so there are no 
fixed sleeps. Reads
+/// use [#fetchUntilRecords] which polls at the same offset until data 
arrives, tolerating an empty
+/// first poll (and the offset reset in the truncation case) without racing.
+public class KafkaPartitionLevelConsumerDataLossIntegrationTest {
+  // Short per-poll timeout so an (occasional) empty first poll retries 
quickly instead of blocking;
+  // the happy path returns data on the first poll well within this bound.
+  private static final int FETCH_TIMEOUT_MS = 2000;
+  // Overall budget for a single logical fetch to return records (covers 
metadata propagation and,
+  // for the truncation case, the offset reset taking effect).
+  private static final long FETCH_MAX_WAIT_MS = 30000;
+
+  // Transactional topic: two committed transactions of 10 records each. Under 
the default
+  // read_uncommitted isolation the commit control record after txn-1 occupies 
offset 10 (never
+  // delivered to the consumer), so txn-2's user records start at offset 11 -> 
a legitimate gap.
+  private static final String TXN_TOPIC = "txn-gap";
+  private static final int RECORDS_PER_TXN = 10;
+  private static final long TXN1_COMMIT_MARKER_OFFSET = 10;
+  private static final long TXN2_FIRST_RECORD_OFFSET = 11;
+
+  // Truncated topic: 30 contiguous records, then everything before offset 20 
is deleted, so the
+  // log start offset advances to 20 (records at/after the requested 
startOffset were removed).
+  private static final String TRUNCATED_TOPIC = "truncated";
+  private static final int TRUNCATED_TOPIC_RECORDS = 30;
+  private static final long TRUNCATE_BEFORE_OFFSET = 20;
+
+  private MiniKafkaCluster _kafkaCluster;
+  private String _kafkaBrokerAddress;
+
+  @BeforeClass
+  public void setUp()
+      throws Exception {
+    _kafkaCluster = new MiniKafkaCluster("0");
+    _kafkaCluster.start();
+    _kafkaBrokerAddress = _kafkaCluster.getKafkaServerAddress();
+
+    // createTopic uses AdminClient.createTopics().all().get() -> synchronous, 
no sleep needed.
+    _kafkaCluster.createTopic(TXN_TOPIC, 1);
+    _kafkaCluster.createTopic(TRUNCATED_TOPIC, 1);
+
+    // commitTransaction()/flush() are synchronous -> records are durable on 
return, no sleep needed.
+    produceTransactional(TXN_TOPIC, 2, RECORDS_PER_TXN);
+    producePlain(TRUNCATED_TOPIC, TRUNCATED_TOPIC_RECORDS);
+
+    // deleteRecords().all().get() is synchronous -> log start offset advanced 
on return.
+    _kafkaCluster.deleteRecordsBeforeOffset(TRUNCATED_TOPIC, 0, 
TRUNCATE_BEFORE_OFFSET);
+  }
+
+  @AfterClass
+  public void tearDown() {
+    try {
+      _kafkaCluster.deleteTopic(TXN_TOPIC);
+      _kafkaCluster.deleteTopic(TRUNCATED_TOPIC);
+    } finally {
+      _kafkaCluster.stop();
+    }
+  }
+
+  /// Scenario 1: a real committed transaction leaves an offset gap at the 
commit control record,
+  /// but all user data at/after the requested startOffset is still retained. 
This must NOT be
+  /// flagged as data loss (the pre-fix code did, raising false StreamDataLoss 
alerts).
+  @Test
+  public void testTransactionalGapWithRetainedDataIsNotDataLoss()
+      throws Exception {
+    // read_uncommitted (default) is the only mode where the pre-fix bug 
manifested.
+    StreamConfig streamConfig = streamConfig(TXN_TOPIC, null, null);
+    try (KafkaPartitionLevelConsumer consumer =
+        new KafkaPartitionLevelConsumer("txn-gap-client", streamConfig, 0)) {
+      // Seek to the commit-marker offset; the first delivered user record is 
txn-2's at offset 11.
+      KafkaMessageBatch batch = fetchUntilRecords(consumer, 
TXN1_COMMIT_MARKER_OFFSET);
+
+      assertTrue(batch.getMessageCount() > 0, "Expected txn-2 records to be 
returned");
+      // An offset gap MUST exist (first delivered offset is past the 
requested commit-marker offset)
+      // -- otherwise the test would pass without exercising the data-loss 
code path at all.
+      assertTrue(firstOffset(batch) > TXN1_COMMIT_MARKER_OFFSET,
+          "Expected an offset gap over the commit control record (first user 
record is offset "
+              + TXN2_FIRST_RECORD_OFFSET + ")");
+      assertFalse(batch.hasDataLoss(),
+          "Offset gap from a transactional commit marker (data retained, 
logStart <= startOffset) "
+              + "must not be reported as data loss");
+    }
+  }
+
+  /// Scenario 2: records at/after the requested startOffset were deleted (log 
start offset advanced
+  /// past it). This IS genuine data loss and must be flagged.
+  @Test
+  public void testTruncatedStartOffsetIsDataLoss()
+      throws Exception {
+    // auto.offset.reset=earliest so the expired startOffset resets to the 
(advanced) log start.
+    StreamConfig streamConfig = streamConfig(TRUNCATED_TOPIC, null, 
"earliest");
+    try (KafkaPartitionLevelConsumer consumer =
+        new KafkaPartitionLevelConsumer("truncated-client", streamConfig, 0)) {
+      // Request offset 0, which has been deleted (log start is now 20).
+      KafkaMessageBatch batch = fetchUntilRecords(consumer, 0);
+
+      assertTrue(batch.getMessageCount() > 0, "Expected the retained tail of 
records to be returned");
+      assertTrue(firstOffset(batch) >= TRUNCATE_BEFORE_OFFSET,
+          "First returned offset should be at/after the advanced log start");
+      assertTrue(batch.hasDataLoss(),
+          "startOffset below the log start offset (records truncated) must be 
reported as data loss");
+    }
+  }
+
+  /// Scenario 3: under read_committed the same transactional gap must never 
be flagged as loss
+  /// (aborted/commit control gaps are always expected). This exercises the 
short-circuit that
+  /// skips the beginningOffsets round-trip entirely.
+  @Test
+  public void testReadCommittedGapIsNotDataLoss()
+      throws Exception {
+    StreamConfig streamConfig = streamConfig(TXN_TOPIC, "read_committed", 
null);
+    try (KafkaPartitionLevelConsumer consumer =
+        new KafkaPartitionLevelConsumer("txn-gap-rc-client", streamConfig, 0)) 
{
+      KafkaMessageBatch batch = fetchUntilRecords(consumer, 
TXN1_COMMIT_MARKER_OFFSET);
+
+      assertTrue(batch.getMessageCount() > 0, "Expected txn-2 records to be 
returned");
+      assertTrue(firstOffset(batch) > TXN1_COMMIT_MARKER_OFFSET, "Sanity: an 
offset gap must exist");
+      assertFalse(batch.hasDataLoss(), "read_committed must never flag an 
offset gap as data loss");
+    }
+  }
+
+  /// Polls repeatedly at the same startOffset until a non-empty batch is 
returned (or the wait
+  /// budget elapses). Repeating the same startOffset hits the consumer's "no 
re-seek" path, so this
+  /// does not disturb offset positioning; it only tolerates an empty first 
poll while data is
+  /// fetched (and, for the truncation case, while the offset reset takes 
effect).
+  private KafkaMessageBatch fetchUntilRecords(KafkaPartitionLevelConsumer 
consumer, long startOffset) {
+    long deadlineMs = System.currentTimeMillis() + FETCH_MAX_WAIT_MS;
+    KafkaMessageBatch batch = consumer.fetchMessages(new 
LongMsgOffset(startOffset), FETCH_TIMEOUT_MS);
+    while (batch.getMessageCount() == 0 && System.currentTimeMillis() < 
deadlineMs) {
+      batch = consumer.fetchMessages(new LongMsgOffset(startOffset), 
FETCH_TIMEOUT_MS);
+    }
+    return batch;
+  }
+
+  private static long firstOffset(KafkaMessageBatch batch) {
+    return Long.parseLong(batch.getFirstMessageOffset().toString());
+  }
+
+  private StreamConfig streamConfig(String topic, String isolationLevel, 
String autoOffsetReset) {
+    Map<String, String> streamConfigMap = new HashMap<>();
+    streamConfigMap.put("streamType", "kafka");
+    streamConfigMap.put("stream.kafka.topic.name", topic);
+    streamConfigMap.put("stream.kafka.broker.list", _kafkaBrokerAddress);
+    streamConfigMap.put("stream.kafka.consumer.factory.class.name", 
KafkaConsumerFactory.class.getName());
+    streamConfigMap.put("stream.kafka.decoder.class.name", "decoderClass");
+    if (isolationLevel != null) {
+      streamConfigMap.put("stream.kafka.isolation.level", isolationLevel);
+    }
+    if (autoOffsetReset != null) {
+      streamConfigMap.put("auto.offset.reset", autoOffsetReset);
+    }
+    return new StreamConfig("tableName_REALTIME", streamConfigMap);
+  }
+
+  private void produceTransactional(String topic, int numTransactions, int 
recordsPerTransaction) {
+    Properties props = producerProps();
+    props.put(ProducerConfig.TRANSACTIONAL_ID_CONFIG, "test-transaction-" + 
UUID.randomUUID());
+    int seq = 0;
+    try (KafkaProducer<String, String> producer = new KafkaProducer<>(props)) {
+      producer.initTransactions();
+      for (int t = 0; t < numTransactions; t++) {
+        producer.beginTransaction();
+        for (int i = 0; i < recordsPerTransaction; i++) {
+          producer.send(new ProducerRecord<>(topic, 0, null, "msg-" + 
(seq++)));
+        }
+        producer.commitTransaction();
+      }
+    }
+  }
+
+  private void producePlain(String topic, int count) {
+    try (KafkaProducer<String, String> producer = new 
KafkaProducer<>(producerProps())) {
+      for (int i = 0; i < count; i++) {
+        producer.send(new ProducerRecord<>(topic, 0, null, "msg-" + i));
+      }
+      producer.flush();
+    }
+  }
+
+  private Properties producerProps() {
+    Properties props = new Properties();
+    props.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, _kafkaBrokerAddress);
+    props.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, 
StringSerializer.class.getName());
+    props.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, 
StringSerializer.class.getName());
+    return props;
+  }
+}


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to