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

markap14 pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/nifi.git


The following commit(s) were added to refs/heads/main by this push:
     new bb9e46c779d NIFI-16350 Skip Kafka tombstones in RECORD mode instead of 
routing them to parse failure (#11857)
bb9e46c779d is described below

commit bb9e46c779d29094c2027687bcf418e28372124a
Author: Paweł Rzepiński <[email protected]>
AuthorDate: Mon Nov 9 14:58:52 2026 +0100

    NIFI-16350 Skip Kafka tombstones in RECORD mode instead of routing them to 
parse failure (#11857)
    
    * NIFI-16350 Skip Kafka tombstones in ConsumeKafka RECORD mode
    
    Null values were indistinguishable from empty payloads, so RECORD mode and 
Verify treated tombstones as parse failures. Keep the distinction and skip only 
real tombstones while still committing their offsets.
    
    Co-authored-by: Cursor <[email protected]>
    
    * NIFI-16350 Address review feedback on ConsumeKafka tombstone handling
    
    Remove the ByteRecord constructor that defaulted tombstone to false.
    Callers now pass the tombstone flag explicitly. Consolidate the
    tombstone integration and converter tests, and rename test helpers
    for clarity.
    
    Co-authored-by: Cursor <[email protected]>
    
    ---------
    
    Co-authored-by: Cursor <[email protected]>
---
 .../kafka/processors/AbstractConsumeKafkaIT.java   |  14 +++
 .../processors/ConsumeKafkaRecordTombstoneIT.java  | 135 +++++++++++++++++++++
 .../apache/nifi/kafka/processors/ConsumeKafka.java |  26 +++-
 .../consumer/bundle/ByteRecordBundler.java         |   2 +-
 .../AbstractRecordStreamKafkaMessageConverter.java |   7 ++
 .../additionalDetails.md                           |  10 ++
 .../nifi/kafka/processors/ConsumeKafkaTest.java    |  92 +++++++++++++-
 .../kafka/processors/common/KafkaUtilsTest.java    |   3 +-
 .../processors/consumer/OffsetTrackerTest.java     |   2 +-
 .../convert/CreateNewFlowFileGroupingTest.java     |  16 +--
 .../consumer/convert/MergeSchemaGroupingTest.java  |  16 +--
 .../RecordStreamKafkaMessageConverterTest.java     |  27 +++--
 ...apperRecordStreamKafkaMessageConverterTest.java |   8 +-
 .../nifi/kafka/service/api/record/ByteRecord.java  |  11 +-
 .../service/consumer/Kafka3ConsumerService.java    |  12 +-
 .../kafka/service/consumer/RecordIterable.java     |  12 +-
 .../consumer/Kafka3ConsumerServiceTest.java        |  27 ++++-
 17 files changed, 364 insertions(+), 56 deletions(-)

diff --git 
a/nifi-extension-bundles/nifi-kafka-bundle/nifi-kafka-3-integration/src/test/java/org/apache/nifi/kafka/processors/AbstractConsumeKafkaIT.java
 
b/nifi-extension-bundles/nifi-kafka-bundle/nifi-kafka-3-integration/src/test/java/org/apache/nifi/kafka/processors/AbstractConsumeKafkaIT.java
index c4e481b7822..6e4edd169c4 100644
--- 
a/nifi-extension-bundles/nifi-kafka-bundle/nifi-kafka-3-integration/src/test/java/org/apache/nifi/kafka/processors/AbstractConsumeKafkaIT.java
+++ 
b/nifi-extension-bundles/nifi-kafka-bundle/nifi-kafka-3-integration/src/test/java/org/apache/nifi/kafka/processors/AbstractConsumeKafkaIT.java
@@ -21,6 +21,7 @@ import org.apache.kafka.clients.producer.ProducerConfig;
 import org.apache.kafka.clients.producer.ProducerRecord;
 import org.apache.kafka.clients.producer.RecordMetadata;
 import org.apache.kafka.common.header.Header;
+import org.apache.kafka.common.serialization.ByteArraySerializer;
 import org.apache.kafka.common.serialization.StringSerializer;
 import org.apache.nifi.util.TestRunner;
 
@@ -60,6 +61,19 @@ public abstract class AbstractConsumeKafkaIT extends 
AbstractKafkaBaseIT {
         }
     }
 
+    protected void produceBytes(final String topic, final int partition, final 
String key, final byte[] value)
+            throws ExecutionException, InterruptedException {
+        final Properties properties = getKafkaProducerProperties();
+        properties.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, 
ByteArraySerializer.class.getName());
+
+        try (final KafkaProducer<String, byte[]> producer = new 
KafkaProducer<>(properties)) {
+            final ProducerRecord<String, byte[]> record = new 
ProducerRecord<>(topic, partition, key, value);
+            final RecordMetadata metadata = producer.send(record).get();
+            assertEquals(topic, metadata.topic());
+            assertTrue(metadata.hasOffset());
+        }
+    }
+
     protected void produce(final String topic, final 
Collection<ProducerRecord<String, String>> records)
             throws ExecutionException, InterruptedException {
         final Collection<RecordMetadata> metadatas = new ArrayList<>();
diff --git 
a/nifi-extension-bundles/nifi-kafka-bundle/nifi-kafka-3-integration/src/test/java/org/apache/nifi/kafka/processors/ConsumeKafkaRecordTombstoneIT.java
 
b/nifi-extension-bundles/nifi-kafka-bundle/nifi-kafka-3-integration/src/test/java/org/apache/nifi/kafka/processors/ConsumeKafkaRecordTombstoneIT.java
new file mode 100644
index 00000000000..1a35de4394b
--- /dev/null
+++ 
b/nifi-extension-bundles/nifi-kafka-bundle/nifi-kafka-3-integration/src/test/java/org/apache/nifi/kafka/processors/ConsumeKafkaRecordTombstoneIT.java
@@ -0,0 +1,135 @@
+/*
+ * 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.nifi.kafka.processors;
+
+import com.fasterxml.jackson.databind.node.ArrayNode;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import org.apache.avro.Schema;
+import org.apache.avro.file.DataFileWriter;
+import org.apache.avro.generic.GenericData;
+import org.apache.avro.generic.GenericDatumWriter;
+import org.apache.avro.generic.GenericRecord;
+import org.apache.nifi.avro.AvroReader;
+import org.apache.nifi.kafka.processors.consumer.ProcessingStrategy;
+import org.apache.nifi.kafka.service.api.consumer.AutoOffsetReset;
+import org.apache.nifi.reporting.InitializationException;
+import org.apache.nifi.schema.access.SchemaAccessUtils;
+import org.apache.nifi.util.MockFlowFile;
+import org.apache.nifi.util.TestRunner;
+import org.apache.nifi.util.TestRunners;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.time.Duration;
+import java.util.UUID;
+import java.util.concurrent.ExecutionException;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+
+/**
+ * RECORD output for a batch that contains a non-null empty value, a null 
tombstone, and a real Avro value.
+ * The tombstone is omitted from success and parse.failure. The empty value is 
routed to parse.failure.
+ * The real value is written to success.
+ */
+class ConsumeKafkaRecordTombstoneIT extends AbstractConsumeKafkaIT {
+
+    private static final byte[] EMPTY_BYTES = new byte[0];
+
+    private static final String AVRO_SCHEMA = """
+            {
+              "type": "record",
+              "name": "message",
+              "fields": [
+                {"name": "name", "type": "string"}
+              ]
+            }
+            """;
+
+    private static final String MESSAGE_KEY = "key";
+
+    private static final int FIRST_PARTITION = 0;
+
+    private static final Duration POLL_TIMEOUT = Duration.ofSeconds(30);
+
+    private TestRunner runner;
+
+    private String topic;
+
+    @BeforeEach
+    void setRunner() throws InitializationException {
+        runner = TestRunners.newTestRunner(ConsumeKafka.class);
+        addKafkaConnectionService(runner);
+        runner.setProperty(ConsumeKafka.CONNECTION_SERVICE, 
CONNECTION_SERVICE_ID);
+        addRecordWriterService(runner);
+        useAvroReader();
+
+        topic = UUID.randomUUID().toString();
+        runner.setProperty(ConsumeKafka.GROUP_ID, topic);
+        runner.setProperty(ConsumeKafka.TOPICS, topic);
+        runner.setProperty(ConsumeKafka.PROCESSING_STRATEGY, 
ProcessingStrategy.RECORD);
+        runner.setProperty(ConsumeKafka.AUTO_OFFSET_RESET, 
AutoOffsetReset.EARLIEST.getValue());
+    }
+
+    @Test
+    void testTombstoneSkippedAndEmptyValueRoutedToParseFailure() throws 
ExecutionException, InterruptedException, IOException {
+        produceAndConsumeAvroRecords();
+
+        runner.assertTransferCount(ConsumeKafka.PARSE_FAILURE, 1);
+        runner.assertTransferCount(ConsumeKafka.SUCCESS, 1);
+        final MockFlowFile flowFile = 
runner.getFlowFilesForRelationship(ConsumeKafka.SUCCESS).getFirst();
+        final ArrayNode records = assertInstanceOf(ArrayNode.class, 
objectMapper.readTree(flowFile.getContent()));
+        assertEquals(1, records.size());
+        final ObjectNode record = assertInstanceOf(ObjectNode.class, 
records.get(0));
+        assertEquals("A", record.get("name").asText());
+    }
+
+    private void produceAndConsumeAvroRecords() throws ExecutionException, 
InterruptedException, IOException {
+        runner.run(1, false, true);
+        produceBytes(topic, FIRST_PARTITION, MESSAGE_KEY, EMPTY_BYTES);
+        produceBytes(topic, FIRST_PARTITION, MESSAGE_KEY, null);
+        produceBytes(topic, FIRST_PARTITION, MESSAGE_KEY, createAvroRecord());
+        runUntil(runner, testRunner -> 
!testRunner.getFlowFilesForRelationship(ConsumeKafka.SUCCESS).isEmpty()
+                && 
!testRunner.getFlowFilesForRelationship(ConsumeKafka.PARSE_FAILURE).isEmpty(), 
POLL_TIMEOUT);
+        runner.run(1, true, false);
+    }
+
+    private void useAvroReader() throws InitializationException {
+        final String readerId = "Avro Reader";
+        final AvroReader reader = new AvroReader();
+        runner.addControllerService(readerId, reader);
+        runner.setProperty(reader, SchemaAccessUtils.SCHEMA_ACCESS_STRATEGY, 
"embedded-avro-schema");
+        runner.enableControllerService(reader);
+        runner.setProperty(ConsumeKafka.RECORD_READER, readerId);
+    }
+
+    private byte[] createAvroRecord() throws IOException {
+        final Schema schema = new Schema.Parser().parse(AVRO_SCHEMA);
+        final GenericRecord record = new GenericData.Record(schema);
+        record.put("name", "A");
+
+        final ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
+        try (DataFileWriter<GenericRecord> writer = new DataFileWriter<>(new 
GenericDatumWriter<>(schema))) {
+            writer.create(schema, outputStream);
+            writer.append(record);
+        }
+
+        return outputStream.toByteArray();
+    }
+}
diff --git 
a/nifi-extension-bundles/nifi-kafka-bundle/nifi-kafka-processors/src/main/java/org/apache/nifi/kafka/processors/ConsumeKafka.java
 
b/nifi-extension-bundles/nifi-kafka-bundle/nifi-kafka-processors/src/main/java/org/apache/nifi/kafka/processors/ConsumeKafka.java
index f000ea8b8be..4e532eb4c21 100644
--- 
a/nifi-extension-bundles/nifi-kafka-bundle/nifi-kafka-processors/src/main/java/org/apache/nifi/kafka/processors/ConsumeKafka.java
+++ 
b/nifi-extension-bundles/nifi-kafka-bundle/nifi-kafka-processors/src/main/java/org/apache/nifi/kafka/processors/ConsumeKafka.java
@@ -515,6 +515,7 @@ public class ConsumeKafka extends AbstractProcessor 
implements VerifiableProcess
                             getLogger().debug("Rebalance detected with revoked 
partitions, breaking to commit session");
                             break;
                         }
+
                         continue;
                     }
 
@@ -570,6 +571,7 @@ public class ConsumeKafka extends AbstractProcessor 
implements VerifiableProcess
                 } catch (final Exception e) {
                     getLogger().warn("Failed to commit offsets for revoked 
partitions", e);
                 }
+
                 consumerServices.offer(consumerService);
                 return;
             }
@@ -791,8 +793,15 @@ public class ConsumeKafka extends AbstractProcessor 
implements VerifiableProcess
 
         final RecordReaderFactory readerFactory = 
context.getProperty(RECORD_READER).asControllerService(RecordReaderFactory.class);
         int recordIndex = 0;
+        int parsedRecords = 0;
+        int skippedTombstones = 0;
         for (final ByteRecord byteRecord : records) {
             recordIndex++;
+            if (byteRecord.isTombstone()) {
+                skippedTombstones++;
+                continue;
+            }
+
             final Map<String, String> recordAttributes = 
KafkaUtils.toAttributes(
                 byteRecord, keyEncoding, headerNamePattern, 
headerValueConverter, commitOffsets);
 
@@ -808,24 +817,31 @@ public class ConsumeKafka extends AbstractProcessor 
implements VerifiableProcess
                     .explanation("Failed to parse Record number " + 
recordIndex + ": " + e)
                     .build();
             }
+
+            parsedRecords++;
         }
 
-        if (recordIndex == 0) {
+        if (parsedRecords == 0) {
+            final String explanation = skippedTombstones == 0
+                    ? "No records were received to parse"
+                    : "No records were parsed (skipped " + skippedTombstones + 
" tombstones)";
             return new ConfigVerificationResult.Builder()
                 .verificationStepName("Parse Records")
                 .outcome(Outcome.SKIPPED)
-                .explanation("No records were received to parse")
+                .explanation(explanation)
                 .build();
         }
 
+        final String explanation = skippedTombstones == 0
+                ? "Successfully parsed " + parsedRecords + " records"
+                : "Successfully parsed " + parsedRecords + " records (skipped 
" + skippedTombstones + " tombstones)";
         return new ConfigVerificationResult.Builder()
             .verificationStepName("Parse Records")
             .outcome(Outcome.SUCCESSFUL)
-            .explanation("Successfully parsed " + recordIndex + " records")
+            .explanation(explanation)
             .build();
     }
 
-
     @ConnectorMethod(
         name = "sampleTopics",
         description = "Returns a list of sample data from the topics that 
would be consumed by this processor."
@@ -1032,6 +1048,7 @@ public class ConsumeKafka extends AbstractProcessor 
implements VerifiableProcess
                 topicPartitionSummaries.add(summary);
                 lastTopicPartition = summary;
             }
+
             return lastTopicPartition;
         }
 
@@ -1047,6 +1064,7 @@ public class ConsumeKafka extends AbstractProcessor 
implements VerifiableProcess
             if (key.isPresent()) {
                 recordSize += key.get().length;
             }
+
             return recordSize;
         }
     }
diff --git 
a/nifi-extension-bundles/nifi-kafka-bundle/nifi-kafka-processors/src/main/java/org/apache/nifi/kafka/processors/consumer/bundle/ByteRecordBundler.java
 
b/nifi-extension-bundles/nifi-kafka-bundle/nifi-kafka-processors/src/main/java/org/apache/nifi/kafka/processors/consumer/bundle/ByteRecordBundler.java
index c47e308cfe6..c3944ced572 100644
--- 
a/nifi-extension-bundles/nifi-kafka-bundle/nifi-kafka-processors/src/main/java/org/apache/nifi/kafka/processors/consumer/bundle/ByteRecordBundler.java
+++ 
b/nifi-extension-bundles/nifi-kafka-bundle/nifi-kafka-processors/src/main/java/org/apache/nifi/kafka/processors/consumer/bundle/ByteRecordBundler.java
@@ -66,7 +66,7 @@ public class ByteRecordBundler {
         key.headers.add(new 
RecordHeader(KafkaFlowFileAttribute.KAFKA_MAX_OFFSET, 
Long.toString(value.getLastOffset()).getBytes(StandardCharsets.UTF_8)));
         key.headers.add(new RecordHeader(KafkaFlowFileAttribute.KAFKA_COUNT, 
Long.toString(value.getCount()).getBytes(StandardCharsets.UTF_8)));
         return new ByteRecord(topicPartition.getTopic(), 
topicPartition.getPartition(),
-                value.getFirstOffset(), key.getTimestamp(), key.getHeaders(), 
key.getMessageKey(), value.getData(), value.getCount());
+                value.getFirstOffset(), key.getTimestamp(), key.getHeaders(), 
key.getMessageKey(), value.getData(), value.getCount(), false);
     }
 
     private void update(final Map<BundleKey, BundleValue> bundles, final 
ByteRecord byteRecord) {
diff --git 
a/nifi-extension-bundles/nifi-kafka-bundle/nifi-kafka-processors/src/main/java/org/apache/nifi/kafka/processors/consumer/convert/AbstractRecordStreamKafkaMessageConverter.java
 
b/nifi-extension-bundles/nifi-kafka-bundle/nifi-kafka-processors/src/main/java/org/apache/nifi/kafka/processors/consumer/convert/AbstractRecordStreamKafkaMessageConverter.java
index a707c22e34e..5f9d65c7009 100644
--- 
a/nifi-extension-bundles/nifi-kafka-bundle/nifi-kafka-processors/src/main/java/org/apache/nifi/kafka/processors/consumer/convert/AbstractRecordStreamKafkaMessageConverter.java
+++ 
b/nifi-extension-bundles/nifi-kafka-bundle/nifi-kafka-processors/src/main/java/org/apache/nifi/kafka/processors/consumer/convert/AbstractRecordStreamKafkaMessageConverter.java
@@ -87,6 +87,13 @@ public abstract class 
AbstractRecordStreamKafkaMessageConverter implements Kafka
     public void toFlowFiles(final ProcessSession session, final 
Iterator<ByteRecord> consumerRecords) {
         while (consumerRecords.hasNext()) {
             final ByteRecord consumerRecord = consumerRecords.next();
+            if (consumerRecord.isTombstone()) {
+                session.adjustCounter("Records Received from " + 
consumerRecord.getTopic(), 1, false);
+                session.adjustCounter("Tombstones Skipped for " + 
consumerRecord.getTopic(), 1, false);
+                offsetTracker.update(consumerRecord);
+                continue;
+            }
+
             final byte[] value = consumerRecord.getValue();
 
             final Map<String, String> attributes = KafkaUtils.toAttributes(
diff --git 
a/nifi-extension-bundles/nifi-kafka-bundle/nifi-kafka-processors/src/main/resources/docs/org.apache.nifi.kafka.processors.ConsumeKafka/additionalDetails.md
 
b/nifi-extension-bundles/nifi-kafka-bundle/nifi-kafka-processors/src/main/resources/docs/org.apache.nifi.kafka.processors.ConsumeKafka/additionalDetails.md
index fb5f455cd16..bd9b8176095 100644
--- 
a/nifi-extension-bundles/nifi-kafka-bundle/nifi-kafka-processors/src/main/resources/docs/org.apache.nifi.kafka.processors.ConsumeKafka/additionalDetails.md
+++ 
b/nifi-extension-bundles/nifi-kafka-bundle/nifi-kafka-processors/src/main/resources/docs/org.apache.nifi.kafka.processors.ConsumeKafka/additionalDetails.md
@@ -176,6 +176,16 @@ Here is an example of FlowFile content that is emitted by 
JsonRecordSetWriter wh
 ]
 ```
 
+### Handling tombstones
+
+A Kafka tombstone is a record whose value is null (which is not the same as 
empty byte array).
+
+Handling depends on Processing Strategy:
+
+- FLOW_FILE: The tombstone is represented as a FlowFile with `kafka.tombstone` 
attribute set to `true`.
+- DEMARCATOR: The tombstone is included in the bundle records as empty byte 
array.
+- RECORD: The tombstone is skipped for every Output Strategy. The processor 
increments `Tombstones Skipped for <topic>` counter.
+
 ### Memory Usage with Continue with Merged Schema
 
 When the Schema Conflict Resolution property is set to 'Continue with Merged 
Schema', the records from an entire poll are grouped by topic and partition and 
held in memory until they are written to FlowFiles.
diff --git 
a/nifi-extension-bundles/nifi-kafka-bundle/nifi-kafka-processors/src/test/java/org/apache/nifi/kafka/processors/ConsumeKafkaTest.java
 
b/nifi-extension-bundles/nifi-kafka-bundle/nifi-kafka-processors/src/test/java/org/apache/nifi/kafka/processors/ConsumeKafkaTest.java
index 4cd6fc81d71..07bfa326c57 100644
--- 
a/nifi-extension-bundles/nifi-kafka-bundle/nifi-kafka-processors/src/test/java/org/apache/nifi/kafka/processors/ConsumeKafkaTest.java
+++ 
b/nifi-extension-bundles/nifi-kafka-bundle/nifi-kafka-processors/src/test/java/org/apache/nifi/kafka/processors/ConsumeKafkaTest.java
@@ -28,6 +28,8 @@ import 
org.apache.nifi.kafka.service.api.consumer.KafkaConsumerService;
 import org.apache.nifi.kafka.service.api.record.ByteRecord;
 import org.apache.nifi.kafka.shared.attribute.KafkaFlowFileAttribute;
 import org.apache.nifi.reporting.InitializationException;
+import org.apache.nifi.serialization.RecordReader;
+import org.apache.nifi.serialization.RecordReaderFactory;
 import org.apache.nifi.util.TestRunner;
 import org.apache.nifi.util.TestRunners;
 import org.junit.jupiter.api.BeforeEach;
@@ -36,6 +38,7 @@ import org.junit.jupiter.api.extension.ExtendWith;
 import org.mockito.Mock;
 import org.mockito.junit.jupiter.MockitoExtension;
 
+import java.io.IOException;
 import java.lang.reflect.Field;
 import java.nio.charset.StandardCharsets;
 import java.time.Duration;
@@ -59,6 +62,8 @@ import static org.junit.jupiter.api.Assertions.assertNotNull;
 import static org.junit.jupiter.api.Assertions.assertNull;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.never;
 import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
@@ -101,6 +106,8 @@ class ConsumeKafkaTest {
 
     private static final String SERVICE_ID = 
KafkaConnectionService.class.getSimpleName();
 
+    private static final String READER_ID = "record-reader";
+
     private static final String CONSUMER_GROUP_ID = 
ConsumeKafkaTest.class.getSimpleName();
 
     @Mock
@@ -177,6 +184,55 @@ class ConsumeKafkaTest {
         assertNotNull(firstResult.getExplanation());
     }
 
+    @Test
+    public void testVerifySkipsTombstoneAndParsesFollowingRecord() throws 
Exception {
+        final RecordReaderFactory readerFactory = configureRecordReader();
+        final RecordReader recordReader = mock(RecordReader.class);
+        when(readerFactory.createRecordReader(any(), any(), anyLong(), 
any())).thenAnswer(invocation -> {
+            final long length = invocation.getArgument(2);
+            if (length == 0L) {
+                throw new IOException("empty value");
+            }
+
+            return recordReader;
+        });
+
+        final ByteRecord tombstone = new ByteRecord(TEST_TOPIC_NAME, 
FIRST_PARTITION, 0L, 0L, List.of(), null, new byte[0], 1, true);
+        final ByteRecord dataRecord = new ByteRecord(TEST_TOPIC_NAME, 
FIRST_PARTITION, 1L, 0L, List.of(), null, FIRST_RECORD_VALUE, 1, false);
+        
when(kafkaConsumerService.poll(any(Duration.class))).thenReturn(List.of(tombstone,
 dataRecord));
+
+        final ConfigVerificationResult parseResult = 
findParseResult(verifyRecords());
+        assertEquals(Outcome.SUCCESSFUL, parseResult.getOutcome());
+        assertEquals("Successfully parsed 1 records (skipped 1 tombstones)", 
parseResult.getExplanation());
+    }
+
+    @Test
+    public void testVerifySkipsWhenSampleContainsOnlyTombstones() throws 
Exception {
+        final RecordReaderFactory readerFactory = configureRecordReader();
+        final ByteRecord tombstone = new ByteRecord(TEST_TOPIC_NAME, 
FIRST_PARTITION, 0L, 0L, List.of(), null, new byte[0], 1, true);
+        
when(kafkaConsumerService.poll(any(Duration.class))).thenReturn(List.of(tombstone));
+
+        final ConfigVerificationResult parseResult = 
findParseResult(verifyRecords());
+        assertEquals(Outcome.SKIPPED, parseResult.getOutcome());
+        assertEquals("No records were parsed (skipped 1 tombstones)", 
parseResult.getExplanation());
+        verify(readerFactory, never()).createRecordReader(any(), any(), 
anyLong(), any());
+    }
+
+    @Test
+    public void testVerifyReportsDataRecordWhenTombstonePrecedesParseFailure() 
throws Exception {
+        final RecordReaderFactory readerFactory = configureRecordReader();
+        when(readerFactory.createRecordReader(any(), any(), anyLong(), 
any())).thenThrow(new IOException("empty value"));
+
+        final ByteRecord tombstone = new ByteRecord(TEST_TOPIC_NAME, 
FIRST_PARTITION, 0L, 0L, List.of(), null, new byte[0], 1, true);
+        final ByteRecord dataRecord = new ByteRecord(TEST_TOPIC_NAME, 
FIRST_PARTITION, 1L, 0L, List.of(), null, FIRST_RECORD_VALUE, 1, false);
+        
when(kafkaConsumerService.poll(any(Duration.class))).thenReturn(List.of(tombstone,
 dataRecord));
+
+        final ConfigVerificationResult parseResult = 
findParseResult(verifyRecords());
+        assertEquals(Outcome.FAILED, parseResult.getOutcome());
+        assertEquals("Failed to parse Record number 2: java.io.IOException: 
empty value", parseResult.getExplanation());
+        verify(readerFactory).createRecordReader(any(), any(), anyLong(), 
any());
+    }
+
     @Test
     public void testDynamicProperties() throws InitializationException {
         when(kafkaConnectionService.getIdentifier()).thenReturn(SERVICE_ID);
@@ -273,8 +329,8 @@ class ConsumeKafkaTest {
         when(kafkaConnectionService.getBrokerUri()).thenReturn(BROKER_URI);
         
when(kafkaConnectionService.getConsumerService(any())).thenReturn(kafkaConsumerService);
 
-        final ByteRecord firstRecord = new ByteRecord(TEST_TOPIC_NAME, 
FIRST_PARTITION, 0, 0L, List.of(), FIRST_RECORD_KEY, FIRST_RECORD_VALUE, 1);
-        final ByteRecord secondRecord = new ByteRecord(TEST_TOPIC_NAME, 
SECOND_PARTITION, 0, 0L, List.of(), null, SECOND_RECORD_VALUE, 1);
+        final ByteRecord firstRecord = new ByteRecord(TEST_TOPIC_NAME, 
FIRST_PARTITION, 0, 0L, List.of(), FIRST_RECORD_KEY, FIRST_RECORD_VALUE, 1, 
false);
+        final ByteRecord secondRecord = new ByteRecord(TEST_TOPIC_NAME, 
SECOND_PARTITION, 0, 0L, List.of(), null, SECOND_RECORD_VALUE, 1, false);
         
when(kafkaConsumerService.poll(any(Duration.class))).thenReturn(List.of(firstRecord,
 secondRecord)).thenReturn(List.of());
 
         final TopicPartitionSummary firstPartition = new 
TopicPartitionSummary(TEST_TOPIC_NAME, FIRST_PARTITION);
@@ -324,7 +380,7 @@ class ConsumeKafkaTest {
         when(kafkaConnectionService.getBrokerUri()).thenReturn(BROKER_URI);
         
when(kafkaConnectionService.getConsumerService(any())).thenReturn(kafkaConsumerService);
 
-        final ByteRecord firstRecord = new ByteRecord(TEST_TOPIC_NAME, 
FIRST_PARTITION, 0, 0L, List.of(), FIRST_RECORD_KEY, FIRST_RECORD_VALUE, 1);
+        final ByteRecord firstRecord = new ByteRecord(TEST_TOPIC_NAME, 
FIRST_PARTITION, 0, 0L, List.of(), FIRST_RECORD_KEY, FIRST_RECORD_VALUE, 1, 
false);
         
when(kafkaConsumerService.poll(any(Duration.class))).thenReturn(List.of(firstRecord)).thenThrow(new
 RuntimeException("poll failed"));
 
         runner.run();
@@ -344,8 +400,8 @@ class ConsumeKafkaTest {
         when(kafkaConnectionService.getBrokerUri()).thenReturn(BROKER_URI);
         
when(kafkaConnectionService.getConsumerService(any())).thenReturn(kafkaConsumerService);
 
-        final ByteRecord firstRecord = new ByteRecord(TEST_TOPIC_NAME, 
FIRST_PARTITION, 0, 0L, new ArrayList<>(), FIRST_RECORD_KEY, 
FIRST_RECORD_VALUE, 1);
-        final ByteRecord secondRecord = new ByteRecord(TEST_TOPIC_NAME, 
FIRST_PARTITION, 1, 0L, new ArrayList<>(), SECOND_RECORD_KEY, 
SECOND_RECORD_VALUE, 1);
+        final ByteRecord firstRecord = new ByteRecord(TEST_TOPIC_NAME, 
FIRST_PARTITION, 0, 0L, new ArrayList<>(), FIRST_RECORD_KEY, 
FIRST_RECORD_VALUE, 1, false);
+        final ByteRecord secondRecord = new ByteRecord(TEST_TOPIC_NAME, 
FIRST_PARTITION, 1, 0L, new ArrayList<>(), SECOND_RECORD_KEY, 
SECOND_RECORD_VALUE, 1, false);
         
when(kafkaConsumerService.poll(any(Duration.class))).thenReturn(List.of(firstRecord,
 secondRecord)).thenReturn(List.of());
 
         final TopicPartitionSummary firstPartition = new 
TopicPartitionSummary(TEST_TOPIC_NAME, FIRST_PARTITION);
@@ -365,6 +421,32 @@ class ConsumeKafkaTest {
         runner.assertTransferCount(ConsumeKafka.SUCCESS, 1);
     }
 
+    private RecordReaderFactory configureRecordReader() throws 
InitializationException {
+        final RecordReaderFactory readerFactory = 
mock(RecordReaderFactory.class);
+        when(readerFactory.getIdentifier()).thenReturn(READER_ID);
+        runner.addControllerService(READER_ID, readerFactory);
+        runner.enableControllerService(readerFactory);
+        runner.setProperty(ConsumeKafka.PROCESSING_STRATEGY, 
ProcessingStrategy.RECORD);
+        runner.setProperty(ConsumeKafka.RECORD_READER, READER_ID);
+        return readerFactory;
+    }
+
+    private List<ConfigVerificationResult> verifyRecords() throws 
InitializationException {
+        when(kafkaConsumerService.getPartitionStates()).thenReturn(List.of(new 
PartitionState(TEST_TOPIC_NAME, FIRST_PARTITION)));
+        setConnectionService();
+        
when(kafkaConnectionService.getConsumerService(any())).thenReturn(kafkaConsumerService);
+        runner.setProperty(TOPICS, TEST_TOPIC_NAME);
+        runner.setProperty(GROUP_ID, CONSUMER_GROUP_ID);
+        return processor.verify(runner.getProcessContext(), 
runner.getLogger(), Collections.emptyMap());
+    }
+
+    private static ConfigVerificationResult findParseResult(final 
List<ConfigVerificationResult> results) {
+        return results.stream()
+                .filter(result -> "Parse 
Records".equals(result.getVerificationStepName()))
+                .findFirst()
+                .orElseThrow();
+    }
+
     private void configureProcessor() {
         runner.setProperty(TOPICS, TEST_TOPIC_NAME);
         runner.setProperty(GROUP_ID, CONSUMER_GROUP_ID);
diff --git 
a/nifi-extension-bundles/nifi-kafka-bundle/nifi-kafka-processors/src/test/java/org/apache/nifi/kafka/processors/common/KafkaUtilsTest.java
 
b/nifi-extension-bundles/nifi-kafka-bundle/nifi-kafka-processors/src/test/java/org/apache/nifi/kafka/processors/common/KafkaUtilsTest.java
index e319fd44a4f..aea888d4636 100644
--- 
a/nifi-extension-bundles/nifi-kafka-bundle/nifi-kafka-processors/src/test/java/org/apache/nifi/kafka/processors/common/KafkaUtilsTest.java
+++ 
b/nifi-extension-bundles/nifi-kafka-bundle/nifi-kafka-processors/src/test/java/org/apache/nifi/kafka/processors/common/KafkaUtilsTest.java
@@ -189,7 +189,8 @@ class KafkaUtilsTest {
                 headers,
                 null,  // key
                 "test-value".getBytes(StandardCharsets.UTF_8),
-                1  // count
+                1,
+                false
         );
     }
 }
diff --git 
a/nifi-extension-bundles/nifi-kafka-bundle/nifi-kafka-processors/src/test/java/org/apache/nifi/kafka/processors/consumer/OffsetTrackerTest.java
 
b/nifi-extension-bundles/nifi-kafka-bundle/nifi-kafka-processors/src/test/java/org/apache/nifi/kafka/processors/consumer/OffsetTrackerTest.java
index 995baae8fa2..92cbecf8f22 100644
--- 
a/nifi-extension-bundles/nifi-kafka-bundle/nifi-kafka-processors/src/test/java/org/apache/nifi/kafka/processors/consumer/OffsetTrackerTest.java
+++ 
b/nifi-extension-bundles/nifi-kafka-bundle/nifi-kafka-processors/src/test/java/org/apache/nifi/kafka/processors/consumer/OffsetTrackerTest.java
@@ -126,6 +126,6 @@ class OffsetTrackerTest {
     }
 
     private static ByteRecord newByteRecord(final String topic, final int 
partition, final byte[] key, final byte[] value, final long bundledCount) {
-        return new ByteRecord(topic, partition, 0, 0L, List.of(), key, value, 
bundledCount);
+        return new ByteRecord(topic, partition, 0, 0L, List.of(), key, value, 
bundledCount, false);
     }
 }
diff --git 
a/nifi-extension-bundles/nifi-kafka-bundle/nifi-kafka-processors/src/test/java/org/apache/nifi/kafka/processors/consumer/convert/CreateNewFlowFileGroupingTest.java
 
b/nifi-extension-bundles/nifi-kafka-bundle/nifi-kafka-processors/src/test/java/org/apache/nifi/kafka/processors/consumer/convert/CreateNewFlowFileGroupingTest.java
index f459ae18f3d..789e08445ea 100644
--- 
a/nifi-extension-bundles/nifi-kafka-bundle/nifi-kafka-processors/src/test/java/org/apache/nifi/kafka/processors/consumer/convert/CreateNewFlowFileGroupingTest.java
+++ 
b/nifi-extension-bundles/nifi-kafka-bundle/nifi-kafka-processors/src/test/java/org/apache/nifi/kafka/processors/consumer/convert/CreateNewFlowFileGroupingTest.java
@@ -82,8 +82,8 @@ class CreateNewFlowFileGroupingTest {
 
     @Test
     void testSameTopicPartitionAndSchemaShareOneFlowFile() throws Exception {
-        final ByteRecord first = new ByteRecord(TOPIC, 0, 10, 1000L, 
List.of(), null, new byte[0], 0L);
-        final ByteRecord second = new ByteRecord(TOPIC, 0, 11, 500L, 
List.of(), null, new byte[0], 0L);
+        final ByteRecord first = new ByteRecord(TOPIC, 0, 10, 1000L, 
List.of(), null, new byte[0], 0L, false);
+        final ByteRecord second = new ByteRecord(TOPIC, 0, 11, 500L, 
List.of(), null, new byte[0], 0L, false);
 
         grouping.addRecord(session, first, RECORD_A, SCHEMA_A, Map.of(), 
Map.of());
         grouping.addRecord(session, second, RECORD_A, SCHEMA_A, Map.of(), 
Map.of());
@@ -104,8 +104,8 @@ class CreateNewFlowFileGroupingTest {
 
     @Test
     void testDifferentWriteSchemasProduceSeparateFlowFiles() throws Exception {
-        final ByteRecord first = new ByteRecord(TOPIC, 0, 1, 1000L, List.of(), 
null, new byte[0], 0L);
-        final ByteRecord second = new ByteRecord(TOPIC, 0, 2, 2000L, 
List.of(), null, new byte[0], 0L);
+        final ByteRecord first = new ByteRecord(TOPIC, 0, 1, 1000L, List.of(), 
null, new byte[0], 0L, false);
+        final ByteRecord second = new ByteRecord(TOPIC, 0, 2, 2000L, 
List.of(), null, new byte[0], 0L, false);
 
         grouping.addRecord(session, first, RECORD_A, SCHEMA_A, Map.of(), 
Map.of());
         grouping.addRecord(session, second, RECORD_B, SCHEMA_B, Map.of(), 
Map.of());
@@ -119,8 +119,8 @@ class CreateNewFlowFileGroupingTest {
 
     @Test
     void testDifferentGroupingAttributesProduceSeparateFlowFiles() throws 
Exception {
-        final ByteRecord first = new ByteRecord(TOPIC, 0, 1, 1000L, List.of(), 
null, new byte[0], 0L);
-        final ByteRecord second = new ByteRecord(TOPIC, 0, 2, 2000L, 
List.of(), null, new byte[0], 0L);
+        final ByteRecord first = new ByteRecord(TOPIC, 0, 1, 1000L, List.of(), 
null, new byte[0], 0L, false);
+        final ByteRecord second = new ByteRecord(TOPIC, 0, 2, 2000L, 
List.of(), null, new byte[0], 0L, false);
 
         grouping.addRecord(session, first, RECORD_A, SCHEMA_A, Map.of(), 
Map.of("hdr", "a"));
         grouping.addRecord(session, second, RECORD_A, SCHEMA_A, Map.of(), 
Map.of("hdr", "b"));
@@ -134,8 +134,8 @@ class CreateNewFlowFileGroupingTest {
 
     @Test
     void testDifferentPartitionsProduceSeparateFlowFiles() throws Exception {
-        final ByteRecord first = new ByteRecord(TOPIC, 0, 1, 1000L, List.of(), 
null, new byte[0], 0L);
-        final ByteRecord second = new ByteRecord(TOPIC, 1, 2, 2000L, 
List.of(), null, new byte[0], 0L);
+        final ByteRecord first = new ByteRecord(TOPIC, 0, 1, 1000L, List.of(), 
null, new byte[0], 0L, false);
+        final ByteRecord second = new ByteRecord(TOPIC, 1, 2, 2000L, 
List.of(), null, new byte[0], 0L, false);
 
         grouping.addRecord(session, first, RECORD_A, SCHEMA_A, Map.of(), 
Map.of());
         grouping.addRecord(session, second, RECORD_A, SCHEMA_A, Map.of(), 
Map.of());
diff --git 
a/nifi-extension-bundles/nifi-kafka-bundle/nifi-kafka-processors/src/test/java/org/apache/nifi/kafka/processors/consumer/convert/MergeSchemaGroupingTest.java
 
b/nifi-extension-bundles/nifi-kafka-bundle/nifi-kafka-processors/src/test/java/org/apache/nifi/kafka/processors/consumer/convert/MergeSchemaGroupingTest.java
index a98a782bc1e..543341ebd81 100644
--- 
a/nifi-extension-bundles/nifi-kafka-bundle/nifi-kafka-processors/src/test/java/org/apache/nifi/kafka/processors/consumer/convert/MergeSchemaGroupingTest.java
+++ 
b/nifi-extension-bundles/nifi-kafka-bundle/nifi-kafka-processors/src/test/java/org/apache/nifi/kafka/processors/consumer/convert/MergeSchemaGroupingTest.java
@@ -86,8 +86,8 @@ class MergeSchemaGroupingTest {
 
     @Test
     void testDifferentSchemasInSameTopicPartitionMergeIntoOneFlowFile() {
-        final ByteRecord first = new ByteRecord(TOPIC, 0, 10, 1000L, 
List.of(), null, new byte[0], 0L);
-        final ByteRecord second = new ByteRecord(TOPIC, 0, 11, 500L, 
List.of(), null, new byte[0], 0L);
+        final ByteRecord first = new ByteRecord(TOPIC, 0, 10, 1000L, 
List.of(), null, new byte[0], 0L, false);
+        final ByteRecord second = new ByteRecord(TOPIC, 0, 11, 500L, 
List.of(), null, new byte[0], 0L, false);
 
         grouping.addRecord(session, first, RECORD_A, SCHEMA_A, Map.of(), 
Map.of());
         grouping.addRecord(session, second, RECORD_B, SCHEMA_B, Map.of(), 
Map.of());
@@ -110,8 +110,8 @@ class MergeSchemaGroupingTest {
 
     @Test
     void testDifferentPartitionsProduceSeparateFlowFiles() {
-        final ByteRecord first = new ByteRecord(TOPIC, 0, 1, 1000L, List.of(), 
null, new byte[0], 0L);
-        final ByteRecord second = new ByteRecord(TOPIC, 1, 2, 2000L, 
List.of(), null, new byte[0], 0L);
+        final ByteRecord first = new ByteRecord(TOPIC, 0, 1, 1000L, List.of(), 
null, new byte[0], 0L, false);
+        final ByteRecord second = new ByteRecord(TOPIC, 1, 2, 2000L, 
List.of(), null, new byte[0], 0L, false);
 
         grouping.addRecord(session, first, RECORD_A, SCHEMA_A, Map.of(), 
Map.of());
         grouping.addRecord(session, second, RECORD_B, SCHEMA_B, Map.of(), 
Map.of());
@@ -125,8 +125,8 @@ class MergeSchemaGroupingTest {
 
     @Test
     void testDifferentGroupingAttributesProduceSeparateFlowFiles() {
-        final ByteRecord first = new ByteRecord(TOPIC, 0, 1, 1000L, List.of(), 
null, new byte[0], 0L);
-        final ByteRecord second = new ByteRecord(TOPIC, 0, 2, 2000L, 
List.of(), null, new byte[0], 0L);
+        final ByteRecord first = new ByteRecord(TOPIC, 0, 1, 1000L, List.of(), 
null, new byte[0], 0L, false);
+        final ByteRecord second = new ByteRecord(TOPIC, 0, 2, 2000L, 
List.of(), null, new byte[0], 0L, false);
 
         grouping.addRecord(session, first, RECORD_A, SCHEMA_A, Map.of(), 
Map.of("hdr", "a"));
         grouping.addRecord(session, second, RECORD_B, SCHEMA_B, Map.of(), 
Map.of("hdr", "b"));
@@ -160,8 +160,8 @@ class MergeSchemaGroupingTest {
         final MergeSchemaGrouping validatingGrouping = new MergeSchemaGrouping(
                 validatingWriter, runner.getLogger(), BROKER_URI, true);
 
-        final ByteRecord first = new ByteRecord(TOPIC, 0, 10, 1000L, 
List.of(), null, new byte[0], 0L);
-        final ByteRecord second = new ByteRecord(TOPIC, 0, 11, 2000L, 
List.of(), null, new byte[0], 0L);
+        final ByteRecord first = new ByteRecord(TOPIC, 0, 10, 1000L, 
List.of(), null, new byte[0], 0L, false);
+        final ByteRecord second = new ByteRecord(TOPIC, 0, 11, 2000L, 
List.of(), null, new byte[0], 0L, false);
 
         validatingGrouping.addRecord(validatingSession, first, recordA, 
nonNullableSchemaA, Map.of(), Map.of());
         validatingGrouping.addRecord(validatingSession, second, recordB, 
nonNullableSchemaB, Map.of(), Map.of());
diff --git 
a/nifi-extension-bundles/nifi-kafka-bundle/nifi-kafka-processors/src/test/java/org/apache/nifi/kafka/processors/consumer/convert/RecordStreamKafkaMessageConverterTest.java
 
b/nifi-extension-bundles/nifi-kafka-bundle/nifi-kafka-processors/src/test/java/org/apache/nifi/kafka/processors/consumer/convert/RecordStreamKafkaMessageConverterTest.java
index 506f55517da..91cf8ad662c 100644
--- 
a/nifi-extension-bundles/nifi-kafka-bundle/nifi-kafka-processors/src/test/java/org/apache/nifi/kafka/processors/consumer/convert/RecordStreamKafkaMessageConverterTest.java
+++ 
b/nifi-extension-bundles/nifi-kafka-bundle/nifi-kafka-processors/src/test/java/org/apache/nifi/kafka/processors/consumer/convert/RecordStreamKafkaMessageConverterTest.java
@@ -17,6 +17,7 @@
 package org.apache.nifi.kafka.processors.consumer.convert;
 
 import org.apache.nifi.flowfile.FlowFile;
+import org.apache.nifi.kafka.processors.ConsumeKafka;
 import org.apache.nifi.kafka.processors.consumer.KafkaMetricName;
 import org.apache.nifi.kafka.processors.consumer.OffsetTracker;
 import org.apache.nifi.kafka.service.api.record.ByteRecord;
@@ -24,6 +25,7 @@ import 
org.apache.nifi.kafka.shared.attribute.KafkaFlowFileAttribute;
 import org.apache.nifi.kafka.shared.property.KeyEncoding;
 import org.apache.nifi.logging.ComponentLog;
 import org.apache.nifi.processor.ProcessSession;
+import org.apache.nifi.processor.io.OutputStreamCallback;
 import org.apache.nifi.processor.metrics.CommitTiming;
 import org.apache.nifi.serialization.MalformedRecordException;
 import org.apache.nifi.serialization.RecordReaderFactory;
@@ -50,6 +52,7 @@ import static org.mockito.ArgumentMatchers.anyLong;
 import static org.mockito.ArgumentMatchers.eq;
 import static org.mockito.Mockito.atLeastOnce;
 import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.times;
 import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
 import static org.mockito.Mockito.withSettings;
@@ -94,12 +97,12 @@ class RecordStreamKafkaMessageConverterTest {
         );
 
         // Create ByteRecords
-        final ByteRecord group1Record1 = new ByteRecord(FIRST_TOPIC, 0, 0, 
1000L, List.of(), null, "value1".getBytes(), 0L);
-        final ByteRecord group1Record2 = new ByteRecord(FIRST_TOPIC, 0, 3, 
500L, List.of(), null, "value4".getBytes(), 0L);
+        final ByteRecord group1Record1 = new ByteRecord(FIRST_TOPIC, 0, 0, 
1000L, List.of(), null, "value1".getBytes(), 0L, false);
+        final ByteRecord group1Record2 = new ByteRecord(FIRST_TOPIC, 0, 3, 
500L, List.of(), null, "value4".getBytes(), 0L, false);
 
-        final ByteRecord group2 = new ByteRecord(FIRST_TOPIC, 1, 1, 2000L, 
List.of(), null, "value2".getBytes(), 0L);
+        final ByteRecord group2 = new ByteRecord(FIRST_TOPIC, 1, 1, 2000L, 
List.of(), null, "value2".getBytes(), 0L, false);
 
-        final ByteRecord group3 = new ByteRecord(SECOND_TOPIC, 0, 2, 3000L, 
List.of(), null, "value3".getBytes(), 0L);
+        final ByteRecord group3 = new ByteRecord(SECOND_TOPIC, 0, 2, 3000L, 
List.of(), null, "value3".getBytes(), 0L, false);
 
         final Iterator<ByteRecord> consumerRecords = List.of(group1Record1, 
group2, group3, group1Record2).iterator();
         // Mock the session.create() and session.write() methods
@@ -144,15 +147,15 @@ class RecordStreamKafkaMessageConverterTest {
     }
 
     @Test
-    void testParseFailureIncrementsParseErrorCounterImmediately() throws 
Exception {
+    void testTombstoneSkippedAndEmptyValueIncrementsParseErrorCounter() throws 
Exception {
         final RecordReaderFactory readerFactory = 
mock(RecordReaderFactory.class);
         when(readerFactory.createRecordReader(any(), any(), anyLong(), any()))
-                .thenThrow(new MalformedRecordException("invalid record"));
+                .thenThrow(new MalformedRecordException("empty value"));
 
         final FlowFile flowFile = new MockFlowFile(1);
         when(session.create()).thenReturn(flowFile);
         when(session.putAllAttributes(any(FlowFile.class), 
any())).thenReturn(flowFile);
-        
when(session.write(any(FlowFile.class))).thenReturn(mock(OutputStream.class));
+        when(session.write(any(FlowFile.class), 
any(OutputStreamCallback.class))).thenReturn(flowFile);
 
         final RecordStreamKafkaMessageConverter converter = new 
RecordStreamKafkaMessageConverter(
                 readerFactory,
@@ -167,13 +170,19 @@ class RecordStreamKafkaMessageConverterTest {
                 new CreateNewFlowFileGrouping(writerFactory, logger, 
BROKER_URI, true)
         );
 
-        final ByteRecord consumerRecord = new ByteRecord(FIRST_TOPIC, 
FIRST_PARTITION, 0, 1000L, List.of(), null, 
"invalid".getBytes(StandardCharsets.UTF_8), 1L);
-        converter.toFlowFiles(session, List.of(consumerRecord).iterator());
+        final ByteRecord tombstone = new ByteRecord(FIRST_TOPIC, 
FIRST_PARTITION, 4L, 1000L, List.of(), null, new byte[0], 1L, true);
+        final ByteRecord emptyValue = new ByteRecord(FIRST_TOPIC, 
FIRST_PARTITION, 5L, 1000L, List.of(), null, new byte[0], 1L, false);
+        converter.toFlowFiles(session, List.of(tombstone, 
emptyValue).iterator());
 
+        verify(readerFactory).createRecordReader(any(), any(), anyLong(), 
any());
+        verify(session).transfer(flowFile, ConsumeKafka.PARSE_FAILURE);
+        verify(session, times(2)).adjustCounter(eq("Records Received from " + 
FIRST_TOPIC), eq(1L), eq(false));
+        verify(session).adjustCounter(eq("Tombstones Skipped for " + 
FIRST_TOPIC), eq(1L), eq(false));
         
verify(session).adjustCounter(eq(KafkaMetricName.RECORDS_PARSED_ERRORS.getMetricName()),
 eq(1L),
                 eq(Map.of(
                         KafkaFlowFileAttribute.KAFKA_TOPIC, FIRST_TOPIC,
                         KafkaFlowFileAttribute.KAFKA_PARTITION, 
Integer.toString(FIRST_PARTITION))),
                 eq(CommitTiming.NOW));
+        assertEquals(2L, offsetTracker.getRecordCounts().get(FIRST_TOPIC));
     }
 }
diff --git 
a/nifi-extension-bundles/nifi-kafka-bundle/nifi-kafka-processors/src/test/java/org/apache/nifi/kafka/processors/consumer/convert/WrapperRecordStreamKafkaMessageConverterTest.java
 
b/nifi-extension-bundles/nifi-kafka-bundle/nifi-kafka-processors/src/test/java/org/apache/nifi/kafka/processors/consumer/convert/WrapperRecordStreamKafkaMessageConverterTest.java
index 4a7d83ecfa5..dd6b6520aca 100644
--- 
a/nifi-extension-bundles/nifi-kafka-bundle/nifi-kafka-processors/src/test/java/org/apache/nifi/kafka/processors/consumer/convert/WrapperRecordStreamKafkaMessageConverterTest.java
+++ 
b/nifi-extension-bundles/nifi-kafka-bundle/nifi-kafka-processors/src/test/java/org/apache/nifi/kafka/processors/consumer/convert/WrapperRecordStreamKafkaMessageConverterTest.java
@@ -88,12 +88,12 @@ class WrapperRecordStreamKafkaMessageConverterTest {
         );
 
         // Create ByteRecords
-        final ByteRecord group1Record1 = new ByteRecord("topic1", 0, 0, 0L, 
List.of(), null, "value1".getBytes(), 0L);
-        final ByteRecord group1Record2 = new ByteRecord("topic1", 0, 3, 0L, 
List.of(), null, "value4".getBytes(), 0L);
+        final ByteRecord group1Record1 = new ByteRecord("topic1", 0, 0, 0L, 
List.of(), null, "value1".getBytes(), 0L, false);
+        final ByteRecord group1Record2 = new ByteRecord("topic1", 0, 3, 0L, 
List.of(), null, "value4".getBytes(), 0L, false);
 
-        final ByteRecord group2Record1 = new ByteRecord("topic1", 1, 1, 0L, 
List.of(), null, "value2".getBytes(), 0L);
+        final ByteRecord group2Record1 = new ByteRecord("topic1", 1, 1, 0L, 
List.of(), null, "value2".getBytes(), 0L, false);
 
-        final ByteRecord group3Record1 = new ByteRecord("topic2", 0, 2, 0L, 
List.of(), null, "value3".getBytes(), 0L);
+        final ByteRecord group3Record1 = new ByteRecord("topic2", 0, 2, 0L, 
List.of(), null, "value3".getBytes(), 0L, false);
 
         final Iterator<ByteRecord> consumerRecords = List.of(group1Record1, 
group2Record1, group3Record1, group1Record2).iterator();
         // Mock the session.create() and session.write() methods
diff --git 
a/nifi-extension-bundles/nifi-kafka-bundle/nifi-kafka-service-api/src/main/java/org/apache/nifi/kafka/service/api/record/ByteRecord.java
 
b/nifi-extension-bundles/nifi-kafka-bundle/nifi-kafka-service-api/src/main/java/org/apache/nifi/kafka/service/api/record/ByteRecord.java
index 4d57d661511..bb919e1fe6c 100644
--- 
a/nifi-extension-bundles/nifi-kafka-bundle/nifi-kafka-service-api/src/main/java/org/apache/nifi/kafka/service/api/record/ByteRecord.java
+++ 
b/nifi-extension-bundles/nifi-kafka-bundle/nifi-kafka-service-api/src/main/java/org/apache/nifi/kafka/service/api/record/ByteRecord.java
@@ -23,7 +23,7 @@ import java.util.Objects;
 import java.util.Optional;
 
 /**
- * Byte Record translation of Kafka Record with byte arrays for key and value 
properties
+ * Byte Record translation of Kafka Record with byte arrays for key and value 
properties.
  */
 public class ByteRecord {
 
@@ -35,6 +35,7 @@ public class ByteRecord {
     private final byte[] key;
     private final byte[] value;
     private final long bundledCount;
+    private final boolean tombstone;
 
     public ByteRecord(
             final String topic,
@@ -44,7 +45,8 @@ public class ByteRecord {
             final List<RecordHeader> headers,
             final byte[] key,
             final byte[] value,
-            final long bundledCount
+            final long bundledCount,
+            final boolean tombstone
     ) {
         this.topic = Objects.requireNonNull(topic, "Topic required");
         this.partition = partition;
@@ -54,6 +56,7 @@ public class ByteRecord {
         this.key = key;
         this.value = Objects.requireNonNull(value, "Value required");
         this.bundledCount = bundledCount;
+        this.tombstone = tombstone;
     }
 
     public String getTopic() {
@@ -80,6 +83,10 @@ public class ByteRecord {
         return value;
     }
 
+    public boolean isTombstone() {
+        return tombstone;
+    }
+
     public List<RecordHeader> getHeaders() {
         return headers;
     }
diff --git 
a/nifi-extension-bundles/nifi-kafka-bundle/nifi-kafka-service-shared/src/main/java/org/apache/nifi/kafka/service/consumer/Kafka3ConsumerService.java
 
b/nifi-extension-bundles/nifi-kafka-bundle/nifi-kafka-service-shared/src/main/java/org/apache/nifi/kafka/service/consumer/Kafka3ConsumerService.java
index 37cb7e18254..ca388d4d868 100644
--- 
a/nifi-extension-bundles/nifi-kafka-bundle/nifi-kafka-service-shared/src/main/java/org/apache/nifi/kafka/service/consumer/Kafka3ConsumerService.java
+++ 
b/nifi-extension-bundles/nifi-kafka-bundle/nifi-kafka-service-shared/src/main/java/org/apache/nifi/kafka/service/consumer/Kafka3ConsumerService.java
@@ -375,11 +375,10 @@ public class Kafka3ConsumerService implements 
KafkaConsumerService, Closeable, C
                 recordHeaders.add(recordHeader);
             });
 
-            // Support Kafka tombstones
-            byte[] value = consumerRecord.value();
-            if (value == null) {
-                value = new byte[0];
-            }
+            // A null Kafka value is a tombstone. Keep that distinct from a 
non-null empty value.
+            final byte[] originalValue = consumerRecord.value();
+            final boolean isTombstone = originalValue == null;
+            final byte[] value = isTombstone ? new byte[0] : originalValue;
 
             return new ByteRecord(
                     consumerRecord.topic(),
@@ -389,7 +388,8 @@ public class Kafka3ConsumerService implements 
KafkaConsumerService, Closeable, C
                     recordHeaders,
                     consumerRecord.key(),
                     value,
-                    1
+                    1,
+                    isTombstone
             );
         }
 
diff --git 
a/nifi-extension-bundles/nifi-kafka-bundle/nifi-kafka-service-shared/src/main/java/org/apache/nifi/kafka/service/consumer/RecordIterable.java
 
b/nifi-extension-bundles/nifi-kafka-bundle/nifi-kafka-service-shared/src/main/java/org/apache/nifi/kafka/service/consumer/RecordIterable.java
index 6f8229eb2f8..181221a74d4 100644
--- 
a/nifi-extension-bundles/nifi-kafka-bundle/nifi-kafka-service-shared/src/main/java/org/apache/nifi/kafka/service/consumer/RecordIterable.java
+++ 
b/nifi-extension-bundles/nifi-kafka-bundle/nifi-kafka-service-shared/src/main/java/org/apache/nifi/kafka/service/consumer/RecordIterable.java
@@ -58,11 +58,10 @@ public class RecordIterable implements Iterable<ByteRecord> 
{
                 recordHeaders.add(recordHeader);
             });
 
-            // Support Kafka tombstones
-            byte[] value = consumerRecord.value();
-            if (value == null) {
-                value = new byte[0];
-            }
+            // A null Kafka value is a tombstone. Keep that distinct from a 
non-null empty value.
+            final byte[] originalValue = consumerRecord.value();
+            final boolean isTombstone = originalValue == null;
+            final byte[] value = isTombstone ? new byte[0] : originalValue;
 
             return new ByteRecord(
                 consumerRecord.topic(),
@@ -72,7 +71,8 @@ public class RecordIterable implements Iterable<ByteRecord> {
                 recordHeaders,
                 consumerRecord.key(),
                 value,
-                1
+                1,
+                isTombstone
             );
         }
     }
diff --git 
a/nifi-extension-bundles/nifi-kafka-bundle/nifi-kafka-service-shared/src/test/java/org/apache/nifi/kafka/service/consumer/Kafka3ConsumerServiceTest.java
 
b/nifi-extension-bundles/nifi-kafka-bundle/nifi-kafka-service-shared/src/test/java/org/apache/nifi/kafka/service/consumer/Kafka3ConsumerServiceTest.java
index 0f59500515b..62dcdb8af4b 100644
--- 
a/nifi-extension-bundles/nifi-kafka-bundle/nifi-kafka-service-shared/src/test/java/org/apache/nifi/kafka/service/consumer/Kafka3ConsumerServiceTest.java
+++ 
b/nifi-extension-bundles/nifi-kafka-bundle/nifi-kafka-service-shared/src/test/java/org/apache/nifi/kafka/service/consumer/Kafka3ConsumerServiceTest.java
@@ -35,6 +35,7 @@ import org.mockito.Mock;
 import org.mockito.junit.jupiter.MockitoExtension;
 
 import java.time.Duration;
+import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Collections;
 import java.util.HashMap;
@@ -249,7 +250,31 @@ class Kafka3ConsumerServiceTest {
         verify(consumer, never()).commitSync(anyMap());
     }
 
+    @Test
+    void testPollDistinguishesTombstoneFromEmptyValue() {
+        final TopicPartition partition0 = new TopicPartition(TOPIC, 
PARTITION_0);
+        final ConsumerRecord<byte[], byte[]> tombstone = createRecord(TOPIC, 
PARTITION_0, 0L, null);
+        final ConsumerRecord<byte[], byte[]> emptyValue = createRecord(TOPIC, 
PARTITION_0, 1L, new byte[0]);
+
+        final Map<TopicPartition, List<ConsumerRecord<byte[], byte[]>>> 
recordsMap = new HashMap<>();
+        recordsMap.put(partition0, List.of(tombstone, emptyValue));
+        
when(consumer.poll(any(Duration.class))).thenReturn(createConsumerRecords(recordsMap));
+
+        final List<ByteRecord> records = new ArrayList<>();
+        consumerService.poll(Duration.ofMillis(100)).forEach(records::add);
+
+        assertEquals(2, records.size());
+        assertTrue(records.get(0).isTombstone());
+        assertEquals(0, records.get(0).getValue().length);
+        assertFalse(records.get(1).isTombstone());
+        assertEquals(0, records.get(1).getValue().length);
+    }
+
     private ConsumerRecord<byte[], byte[]> createRecord(final String topic, 
final int partition, final long offset) {
+        return createRecord(topic, partition, offset, "test-value".getBytes());
+    }
+
+    private ConsumerRecord<byte[], byte[]> createRecord(final String topic, 
final int partition, final long offset, final byte[] value) {
         return new ConsumerRecord<>(
                 topic,
                 partition,
@@ -259,7 +284,7 @@ class Kafka3ConsumerServiceTest {
                 0,
                 0,
                 null,
-                "test-value".getBytes(),
+                value,
                 new RecordHeaders(),
                 Optional.empty()
         );

Reply via email to