This is an automated email from the ASF dual-hosted git repository.
xiangfu0 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 0007e8b9e4a Use the empty-batch stream position only for consumption
lag on transactional Kafka topics (#20024)
0007e8b9e4a is described below
commit 0007e8b9e4a144eb8f102ca1c52f0af3af848963
Author: Xiang Fu <[email protected]>
AuthorDate: Sun Nov 8 10:07:48 2026 -0800
Use the empty-batch stream position only for consumption lag on
transactional Kafka topics (#20024)
A Kafka transaction's commit marker occupies an offset the client never
delivers, so on an idle transactional partition the consumer sits at the
high watermark while RealtimeSegmentDataManager's current offset stays one
behind it. The records lag in ConsumerPartitionState therefore never
reached 0, and the HIGHEST_STREAM_OFFSET_CONSUMED gauge stayed one behind.
RealtimeSegmentDataManager now records the next offset of a batch without
messages as the stream position when it is ahead of the current offset.
Only the partition lag state and the HIGHEST_STREAM_OFFSET_CONSUMED gauge
read it. The current offset, and with it every fetch, the catch-up, the
KEEP and ONLINE transitions and the committed end offset, stays one past
the last delivered record.
The kafka-4.0 KafkaPartitionLevelConsumer now reports the client position
on an empty poll under read_uncommitted too, not only read_committed. The
move is not validated, which is safe because no caller resumes from it:
the next poll with records still runs the data-loss check against the
caller's offset.
StatelessRealtimeSegmentWriter (re-ingestion) no longer moves to an empty
batch's next offset. It polls again from its own offset, so it cannot
adopt an unreported reset, and the loss check on the next batch with
records covers the gap.
The MessageBatch.getOffsetOfNextBatch Javadoc now says that an empty
batch's value can be ahead of the requested offset and is only used for
lag. No config, SPI signature, wire or segment metadata change.
---
.../realtime/RealtimeSegmentDataManager.java | 61 ++++++-
.../realtime/RealtimeSegmentDataManagerTest.java | 196 +++++++++++++++++++++
.../kafka40/KafkaPartitionLevelConsumer.java | 38 ++--
...fkaPartitionLevelConsumerDataLossCheckTest.java | 12 +-
...titionLevelConsumerDataLossIntegrationTest.java | 30 +++-
.../KafkaPartitionLevelConsumerSeekTest.java | 27 +++
.../writer/StatelessRealtimeSegmentWriter.java | 10 ++
.../writer/StatelessRealtimeSegmentWriterTest.java | 34 ++--
.../org/apache/pinot/spi/stream/MessageBatch.java | 5 +
9 files changed, 360 insertions(+), 53 deletions(-)
diff --git
a/pinot-core/src/main/java/org/apache/pinot/core/data/manager/realtime/RealtimeSegmentDataManager.java
b/pinot-core/src/main/java/org/apache/pinot/core/data/manager/realtime/RealtimeSegmentDataManager.java
index 29e4efff320..8641227c9cd 100644
---
a/pinot-core/src/main/java/org/apache/pinot/core/data/manager/realtime/RealtimeSegmentDataManager.java
+++
b/pinot-core/src/main/java/org/apache/pinot/core/data/manager/realtime/RealtimeSegmentDataManager.java
@@ -274,6 +274,10 @@ public class RealtimeSegmentDataManager extends
SegmentDataManager {
private ServerIngestionOomProtectionManager
_serverIngestionOomProtectionManager;
private final MutableSegmentImpl _realtimeSegment;
private volatile StreamPartitionMsgOffset _currentOffset; // Next offset to
be consumed
+ // Furthest next offset reported by a batch without messages, e.g. one past
a Kafka transaction marker. Only feeds
+ // consumption lag and the highest-offset gauge (see getStreamPosition()).
Written by the consumer thread only.
+ @Nullable
+ private volatile StreamPartitionMsgOffset _emptyBatchStreamPosition;
private volatile State _state;
private volatile int _numRowsConsumed = 0;
private volatile int _numRowsIndexed = 0; // Can be different from
_numRowsConsumed when metrics update is enabled.
@@ -575,12 +579,7 @@ public class RealtimeSegmentDataManager extends
SegmentDataManager {
if (_currentOffset.compareTo(lastUpdatedOffset) != 0) {
_idleTimer.markEventConsumed();
// We consumed something. Update the highest stream offset as well as
partition-consuming metric.
- // TODO Issue 5359 Need to find a way to bump metrics without getting
actual offset value.
- if (_currentOffset instanceof LongMsgOffset) {
- // TODO: only LongMsgOffset supplies long offset value.
- _serverMetrics.setValueOfTableGauge(_clientId,
ServerGauge.HIGHEST_STREAM_OFFSET_CONSUMED,
- ((LongMsgOffset) _currentOffset).getOffset());
- }
+ updateHighestStreamOffsetConsumedGauge();
lastUpdatedOffset =
_streamPartitionMsgOffsetFactory.create(_currentOffset);
} else if (endCriteriaReached) {
// At this point current offset has not moved because
processStreamEvents() has exited before processing a
@@ -602,6 +601,9 @@ public class RealtimeSegmentDataManager extends
SegmentDataManager {
lastUpdatedOffset =
_streamPartitionMsgOffsetFactory.create(nextOffset);
} else {
// We did not consume any rows.
+ if (recordStreamPositionOfEmptyBatch(messageBatch)) {
+ updateHighestStreamOffsetConsumedGauge();
+ }
long timeSinceStreamLastCreatedOrConsumedMs =
_idleTimer.getTimeSinceStreamLastCreatedOrConsumedMs();
if (idleTimeoutMillis >= 0 && (timeSinceStreamLastCreatedOrConsumedMs
> idleTimeoutMillis)) {
@@ -628,6 +630,48 @@ public class RealtimeSegmentDataManager extends
SegmentDataManager {
return true;
}
+ // TODO Issue 5359 Need to find a way to bump metrics without getting actual
offset value.
+ private void updateHighestStreamOffsetConsumedGauge() {
+ StreamPartitionMsgOffset streamPosition = getStreamPosition();
+ if (streamPosition instanceof LongMsgOffset) {
+ // TODO: only LongMsgOffset supplies long offset value.
+ _serverMetrics.setValueOfTableGauge(_clientId,
ServerGauge.HIGHEST_STREAM_OFFSET_CONSUMED,
+ ((LongMsgOffset) streamPosition).getOffset());
+ }
+ }
+
+ /// Records the next offset of a batch without messages when it is ahead of
[#getStreamPosition()], which happens
+ /// when the stream moved past offsets it never delivers, such as Kafka
transaction markers.
+ ///
+ /// @return `true` if the stream position moved
+ private boolean recordStreamPositionOfEmptyBatch(MessageBatch messageBatch) {
+ StreamPartitionMsgOffset nextBatchOffset =
messageBatch.getOffsetOfNextBatch();
+ if (nextBatchOffset == null ||
nextBatchOffset.compareTo(getStreamPosition()) <= 0) {
+ return false;
+ }
+ if (_segmentLogger.isDebugEnabled()) {
+ _segmentLogger.debug("Empty batch moved the stream position to {}
(current offset {})", nextBatchOffset,
+ _currentOffset);
+ }
+ _emptyBatchStreamPosition = nextBatchOffset;
+ return true;
+ }
+
+ /// Returns how far the stream has been read: `_currentOffset`, or the
furthest next offset reported by a batch
+ /// without messages when that is ahead of it. A Kafka consumer moves past
transaction markers without delivering
+ /// anything, so on a transactional topic a caught-up consumer sits one past
the last commit marker while
+ /// `_currentOffset` stays one past the last record. This position only
feeds consumption lag
+ /// ([#getConsumerPartitionState]) and
[ServerGauge#HIGHEST_STREAM_OFFSET_CONSUMED]. Fetching, catch-up and the end
+ /// offset a segment commits keep using `_currentOffset`, so an empty
batch's next offset never decides which records
+ /// a segment holds.
+ @VisibleForTesting
+ StreamPartitionMsgOffset getStreamPosition() {
+ StreamPartitionMsgOffset currentOffset = _currentOffset;
+ StreamPartitionMsgOffset emptyBatchStreamPosition =
_emptyBatchStreamPosition;
+ return emptyBatchStreamPosition != null &&
emptyBatchStreamPosition.compareTo(currentOffset) > 0
+ ? emptyBatchStreamPosition : currentOffset;
+ }
+
/// @param messageBatch batch of messages to process
/// @param idlePipeSleepTimeMillis wait time in case no messages were read
/// @return returns `true` if the process loop ended before processing the
batch, `false`
@@ -1176,12 +1220,13 @@ public class RealtimeSegmentDataManager extends
SegmentDataManager {
return _lastConsumedTimestampMs;
}
- /// Returns the [ConsumerPartitionState] for the partition group.
+ /// Returns the [ConsumerPartitionState] for the partition group. Its
current offset is [#getStreamPosition()], so
+ /// the lag computed from it counts offsets the stream skipped without
delivering anything as consumed.
public Map<String, ConsumerPartitionState> getConsumerPartitionState(
@Nullable StreamPartitionMsgOffset latestMsgOffset) {
String partitionGroupId = String.valueOf(_partitionGroupId);
return Map.of(partitionGroupId,
- new ConsumerPartitionState(partitionGroupId, getCurrentOffset(),
getLastConsumedTimestamp(), latestMsgOffset,
+ new ConsumerPartitionState(partitionGroupId, getStreamPosition(),
getLastConsumedTimestamp(), latestMsgOffset,
_lastRowMetadata));
}
diff --git
a/pinot-core/src/test/java/org/apache/pinot/core/data/manager/realtime/RealtimeSegmentDataManagerTest.java
b/pinot-core/src/test/java/org/apache/pinot/core/data/manager/realtime/RealtimeSegmentDataManagerTest.java
index ab360012d95..49daefd1ff2 100644
---
a/pinot-core/src/test/java/org/apache/pinot/core/data/manager/realtime/RealtimeSegmentDataManagerTest.java
+++
b/pinot-core/src/test/java/org/apache/pinot/core/data/manager/realtime/RealtimeSegmentDataManagerTest.java
@@ -25,6 +25,7 @@ import java.lang.reflect.Field;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.time.Instant;
+import java.util.ArrayList;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
@@ -41,6 +42,7 @@ import
org.apache.commons.configuration2.ex.ConfigurationException;
import org.apache.commons.io.FileUtils;
import org.apache.helix.HelixManager;
import org.apache.pinot.common.metadata.segment.SegmentZKMetadata;
+import org.apache.pinot.common.metrics.ServerGauge;
import org.apache.pinot.common.metrics.ServerMeter;
import org.apache.pinot.common.metrics.ServerMetrics;
import org.apache.pinot.common.protocols.SegmentCompletionProtocol;
@@ -74,11 +76,15 @@ import org.apache.pinot.spi.data.Schema;
import org.apache.pinot.spi.data.readers.GenericRow;
import org.apache.pinot.spi.env.PinotConfiguration;
import org.apache.pinot.spi.metrics.PinotMetricUtils;
+import org.apache.pinot.spi.stream.BytesStreamMessage;
import org.apache.pinot.spi.stream.LongMsgOffset;
import org.apache.pinot.spi.stream.LongMsgOffsetFactory;
import org.apache.pinot.spi.stream.MessageBatch;
+import org.apache.pinot.spi.stream.PartitionGroupConsumer;
import org.apache.pinot.spi.stream.PermanentConsumerException;
import org.apache.pinot.spi.stream.StreamConfigProperties;
+import org.apache.pinot.spi.stream.StreamMessage;
+import org.apache.pinot.spi.stream.StreamMessageMetadata;
import org.apache.pinot.spi.stream.StreamPartitionMsgOffset;
import org.apache.pinot.spi.utils.CommonConstants;
import org.apache.pinot.spi.utils.builder.TableNameBuilder;
@@ -442,6 +448,185 @@ public class RealtimeSegmentDataManagerTest {
}
}
+ /// A batch without messages whose next offset is ahead of the current
offset means the stream moved past offsets it
+ /// never delivers (a Kafka consumer skipping a transaction commit marker,
for example). That position feeds lag and
+ /// the highest-offset gauge, while the current offset, which every fetch
starts from, stays where the last record
+ /// left it. A later batch that reports the requested offset, no offset, or
an offset behind does not move the
+ /// position back.
+ @Test
+ public void testEmptyBatchAheadOfCurrentOffsetMovesOnlyTheStreamPosition()
+ throws Exception {
+ ServerMetrics serverMetrics = mock(ServerMetrics.class);
+ try (FakeRealtimeSegmentDataManager segmentDataManager =
+ createFakeSegmentManager(true, new TimeSupplier(), null, null, null,
serverMetrics)) {
+ segmentDataManager._stubConsumeLoop = false;
+ segmentDataManager._state.set(segmentDataManager,
RealtimeSegmentDataManager.State.INITIAL_CONSUMING);
+ // consumeLoop() is called directly, so set the time limit run() would
have set
+ segmentDataManager.setConsumeEndTime(System.currentTimeMillis() +
TimeUnit.HOURS.toMillis(1));
+ ScriptedPartitionGroupConsumer consumer = new
ScriptedPartitionGroupConsumer(segmentDataManager,
+ // The stream skipped START_OFFSET_VALUE and START_OFFSET_VALUE + 1
without delivering anything
+ emptyBatch(START_OFFSET_VALUE + 2),
+ // Nothing moved
+ emptyBatch(START_OFFSET_VALUE + 2),
+ // A stream that does not report where the next batch starts
+ emptyBatch(null),
+ // Behind the stream position
+ emptyBatch(START_OFFSET_VALUE + 1));
+ segmentDataManager.setPartitionGroupConsumer(consumer);
+
+ segmentDataManager.consumeLoop();
+
+ assertEquals(consumer._requestedOffsets,
+ List.of(START_OFFSET_VALUE, START_OFFSET_VALUE, START_OFFSET_VALUE,
START_OFFSET_VALUE));
+ assertEquals(((LongMsgOffset)
segmentDataManager.getCurrentOffset()).getOffset(), START_OFFSET_VALUE);
+ assertEquals(((LongMsgOffset)
segmentDataManager.getStreamPosition()).getOffset(), START_OFFSET_VALUE + 2);
+ assertEquals(((LongMsgOffset)
segmentDataManager.getConsumerPartitionState(null)
+
.get(String.valueOf(PARTITION_GROUP_ID)).getCurrentOffset()).getOffset(),
START_OFFSET_VALUE + 2);
+ verify(serverMetrics).setValueOfTableGauge(anyString(),
eq(ServerGauge.HIGHEST_STREAM_OFFSET_CONSUMED),
+ anyLong());
+ verify(serverMetrics).setValueOfTableGauge(anyString(),
eq(ServerGauge.HIGHEST_STREAM_OFFSET_CONSUMED),
+ eq(START_OFFSET_VALUE + 2));
+ assertEquals(segmentDataManager.getSegment().getNumDocsIndexed(), 0);
+ }
+ }
+
+ /// Records still move the current offset after the stream position moved
ahead of it, and the stream position
+ /// follows: a single-record transaction after an idle period on a
transactional topic, then its commit marker.
+ @Test
+ public void testRecordAfterEmptyBatchMovesTheCurrentOffset()
+ throws Exception {
+ ServerMetrics serverMetrics = mock(ServerMetrics.class);
+ try (FakeRealtimeSegmentDataManager segmentDataManager =
+ createFakeSegmentManager(true, new TimeSupplier(), null, null, null,
serverMetrics)) {
+ segmentDataManager._stubConsumeLoop = false;
+ segmentDataManager._state.set(segmentDataManager,
RealtimeSegmentDataManager.State.INITIAL_CONSUMING);
+ segmentDataManager.setConsumeEndTime(System.currentTimeMillis() +
TimeUnit.HOURS.toMillis(1));
+ ScriptedPartitionGroupConsumer consumer = new
ScriptedPartitionGroupConsumer(segmentDataManager,
+ // Commit marker at START_OFFSET_VALUE
+ emptyBatch(START_OFFSET_VALUE + 1),
+ // The next transaction's record, then its marker at
START_OFFSET_VALUE + 2
+ new ScriptedMessageBatch(START_OFFSET_VALUE + 2, START_OFFSET_VALUE
+ 1),
+ emptyBatch(START_OFFSET_VALUE + 3));
+ segmentDataManager.setPartitionGroupConsumer(consumer);
+
+ segmentDataManager.consumeLoop();
+
+ assertEquals(consumer._requestedOffsets, List.of(START_OFFSET_VALUE,
START_OFFSET_VALUE, START_OFFSET_VALUE + 2));
+ assertEquals(((LongMsgOffset)
segmentDataManager.getCurrentOffset()).getOffset(), START_OFFSET_VALUE + 2);
+ assertEquals(((LongMsgOffset)
segmentDataManager.getStreamPosition()).getOffset(), START_OFFSET_VALUE + 3);
+ for (long offset = START_OFFSET_VALUE + 1; offset <= START_OFFSET_VALUE
+ 3; offset++) {
+ verify(serverMetrics).setValueOfTableGauge(anyString(),
eq(ServerGauge.HIGHEST_STREAM_OFFSET_CONSUMED),
+ eq(offset));
+ }
+ }
+ }
+
+ /// A replica whose stream position moved past a commit marker still posts,
and commits, the offset one past its
+ /// last record: the end offset of a segment never depends on an empty batch.
+ @Test
+ public void testSegmentEndsOnePastLastRecordAfterEmptyBatch()
+ throws Exception {
+ TimeSupplier timeSupplier = new TimeSupplier();
+ try (FakeRealtimeSegmentDataManager segmentDataManager =
+ createFakeSegmentManager(true, timeSupplier, null, null, null)) {
+ segmentDataManager._stubConsumeLoop = false;
+ segmentDataManager._state.set(segmentDataManager,
RealtimeSegmentDataManager.State.INITIAL_CONSUMING);
+ segmentDataManager.setConsumeEndTime(timeSupplier.get() +
TimeUnit.HOURS.toMillis(1));
+ // A record, then an empty poll that skipped its commit marker, then the
time limit
+ ScriptedPartitionGroupConsumer consumer =
+ new ScriptedPartitionGroupConsumer(() ->
timeSupplier.add(TimeUnit.HOURS.toMillis(2)),
+ new ScriptedMessageBatch(START_OFFSET_VALUE + 1,
START_OFFSET_VALUE),
+ emptyBatch(START_OFFSET_VALUE + 2));
+ segmentDataManager.setPartitionGroupConsumer(consumer);
+ segmentDataManager._responses.add(new SegmentCompletionProtocol.Response(
+ new SegmentCompletionProtocol.Response.Params()
+ .withStreamPartitionMsgOffset(Long.toString(START_OFFSET_VALUE +
1))
+
.withStatus(SegmentCompletionProtocol.ControllerResponseStatus.COMMIT)));
+
+ segmentDataManager.createPartitionConsumer().run();
+
+ assertEquals(segmentDataManager._postedOffsets,
List.of(START_OFFSET_VALUE + 1));
+ assertTrue(segmentDataManager._commitSegmentCalled);
+ assertEquals(segmentDataManager._state.get(segmentDataManager),
RealtimeSegmentDataManager.State.COMMITTED);
+ assertEquals(((LongMsgOffset)
segmentDataManager.getCurrentOffset()).getOffset(), START_OFFSET_VALUE + 1);
+ assertEquals(((LongMsgOffset)
segmentDataManager.getStreamPosition()).getOffset(), START_OFFSET_VALUE + 2);
+ }
+ }
+
+ /// A batch without messages; `nextBatchOffset` is where the stream consumer
stands after the poll.
+ private static MessageBatch<byte[]> emptyBatch(@Nullable Long
nextBatchOffset) {
+ return new ScriptedMessageBatch(nextBatchOffset);
+ }
+
+ /// A batch of empty messages at the given offsets (each ending at the
following offset), reporting the given
+ /// next-batch offset (`null` for a stream that does not report one). A
consumed message fails to decode and is
+ /// dropped as an erroneous row, which still moves the current offset past
it.
+ private static class ScriptedMessageBatch implements MessageBatch<byte[]> {
+ private final Long _nextBatchOffset;
+ private final long[] _messageOffsets;
+
+ ScriptedMessageBatch(@Nullable Long nextBatchOffset, long...
messageOffsets) {
+ _nextBatchOffset = nextBatchOffset;
+ _messageOffsets = messageOffsets;
+ }
+
+ @Override
+ public int getMessageCount() {
+ return _messageOffsets.length;
+ }
+
+ @Override
+ public StreamMessage<byte[]> getStreamMessage(int index) {
+ long offset = _messageOffsets[index];
+ return new BytesStreamMessage(new byte[0], new
StreamMessageMetadata.Builder()
+ .setOffset(new LongMsgOffset(offset), new LongMsgOffset(offset +
1)).build());
+ }
+
+ @Override
+ public StreamPartitionMsgOffset getOffsetOfNextBatch() {
+ return _nextBatchOffset != null ? new LongMsgOffset(_nextBatchOffset) :
null;
+ }
+
+ @Override
+ public long getSizeInBytes() {
+ return 0;
+ }
+ }
+
+ /// Hands out the given batches in order, recording the offset requested for
each, and runs `afterLastBatch` once the
+ /// last one has been handed out (by default it stops the data manager, so
consumeLoop() returns).
+ private static class ScriptedPartitionGroupConsumer implements
PartitionGroupConsumer {
+ final List<Long> _requestedOffsets = new ArrayList<>();
+ private final Runnable _afterLastBatch;
+ private final LinkedList<MessageBatch<byte[]>> _batches;
+
+ @SafeVarargs
+ ScriptedPartitionGroupConsumer(FakeRealtimeSegmentDataManager
segmentDataManager,
+ MessageBatch<byte[]>... batches) {
+ this(segmentDataManager::stop, batches);
+ }
+
+ @SafeVarargs
+ ScriptedPartitionGroupConsumer(Runnable afterLastBatch,
MessageBatch<byte[]>... batches) {
+ _afterLastBatch = afterLastBatch;
+ _batches = new LinkedList<>(List.of(batches));
+ }
+
+ @Override
+ public MessageBatch<byte[]> fetchMessages(StreamPartitionMsgOffset
startOffset, int timeoutMs) {
+ _requestedOffsets.add(((LongMsgOffset) startOffset).getOffset());
+ MessageBatch<byte[]> batch = _batches.remove();
+ if (_batches.isEmpty()) {
+ _afterLastBatch.run();
+ }
+ return batch;
+ }
+
+ @Override
+ public void close() {
+ }
+ }
+
private FakeRealtimeSegmentDataManager createFakeSegmentManager(boolean
noUpsert, TimeSupplier timeSupplier,
@Nullable String maxRows, @Nullable String maxDuration, @Nullable
TableConfig tableConfig)
throws Exception {
@@ -2002,6 +2187,8 @@ public class RealtimeSegmentDataManagerTest {
private Field _streamMsgOffsetFactory;
public LinkedList<LongMsgOffset> _consumeOffsets = new LinkedList<>();
public LinkedList<SegmentCompletionProtocol.Response> _responses = new
LinkedList<>();
+ // Offsets posted with segmentConsumed(), which the real
postSegmentConsumedMsg() reads from getCurrentOffset()
+ public List<Long> _postedOffsets = new ArrayList<>();
public boolean _commitSegmentCalled = false;
public boolean _buildSegmentCalled = false;
public boolean _failSegmentBuild = false;
@@ -2069,6 +2256,14 @@ public class RealtimeSegmentDataManagerTest {
realtimeSegmentField.set(this, realtimeSegment);
}
+ /// Replaces the stream consumer so tests can script the batches
consumeLoop() sees.
+ public void setPartitionGroupConsumer(PartitionGroupConsumer
partitionGroupConsumer)
+ throws Exception {
+ Field partitionGroupConsumerField =
RealtimeSegmentDataManager.class.getDeclaredField("_partitionGroupConsumer");
+ partitionGroupConsumerField.setAccessible(true);
+ partitionGroupConsumerField.set(this, partitionGroupConsumer);
+ }
+
/// Replaces the upsert metadata manager, which the constructor always
leaves null.
public void
setPartitionUpsertMetadataManager(PartitionUpsertMetadataManager
partitionUpsertMetadataManager)
throws Exception {
@@ -2146,6 +2341,7 @@ public class RealtimeSegmentDataManagerTest {
@Override
protected SegmentCompletionProtocol.Response postSegmentConsumedMsg() {
+ _postedOffsets.add(((LongMsgOffset) getCurrentOffset()).getOffset());
return _responses.remove();
}
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 a0fec35ebe4..c69ef090857 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
@@ -57,10 +57,10 @@ public class KafkaPartitionLevelConsumer extends
KafkaPartitionLevelConnectionHa
// Offset the consumer is positioned to read NEXT. -1 means the consumer has
not been
// positioned for any caller-requested startOffset yet (we have not issued a
seek).
- // After a successful fetch, this is lastRecord.offset + 1. After an empty
fetch under
- // read_committed (where the batch may have been filtered as aborted), this
is the
+ // After a successful fetch, this is lastRecord.offset + 1. After an empty
fetch, this is the
// consumer's actual KafkaConsumer.position(), which may have advanced past
the caller's
- // startOffset even though zero records were returned.
+ // startOffset even though zero records were returned (transaction control
records under
+ // either isolation level, aborted records under read_committed).
private long _nextReadOffset = -1;
// The caller's startOffset on the previous fetchMessages() call. Used to
distinguish
// "caller is passing the same startOffset back because
RealtimeSegmentDataManager's
@@ -107,10 +107,10 @@ public class KafkaPartitionLevelConsumer extends
KafkaPartitionLevelConnectionHa
// the repeated-startOffset-after-empty-fetch case described below.
//
// We only suppress a re-seek when the caller passed the same startOffset
after the
- // previous successful fetch returned zero records. That is the
read_committed
- // empty-poll case: RealtimeSegmentDataManager._currentOffset did not
advance, but
- // KafkaConsumer.position() may have advanced through aborted records.
Seeking back to
- // startOffset would undo that progress and wedge consumption forever.
+ // previous successful fetch returned zero records. That is the empty-poll
case:
+ // RealtimeSegmentDataManager._currentOffset did not advance, but
KafkaConsumer.position()
+ // may have advanced through control or aborted records. Seeking back to
startOffset would
+ // undo that progress (and wedge consumption forever in an aborted region).
//
// If the previous poll returned records, a repeated startOffset means the
caller did
// not process them (for example, the segment time limit fired before
index 0). We must
@@ -151,18 +151,18 @@ public class KafkaPartitionLevelConsumer extends
KafkaPartitionLevelConnectionHa
}
lastMessageMetadata = messageMetadata;
}
- } else if (_isReadCommitted) {
- // No records returned. With read_committed isolation, the underlying
KafkaConsumer's
- // internal position may still have advanced past offsets filtered out
as aborted
- // transactional records, so snap _nextReadOffset to the consumer's
actual position.
- // RealtimeSegmentDataManager doesn't advance _currentOffset on empty
batches and will
- // call us again with the same startOffset; without this update the
seek-check would
- // re-seek to startOffset and undo the consumer's progress through the
aborted region.
- //
- // For read_uncommitted (the default), empty polls can never advance the
internal
- // position past records that didn't exist, so we skip the position()
call in the
- // hot path. The call is bounded by the same timeout as poll() so a sick
broker
- // can't stall this loop unbounded.
+ } else {
+ // No records returned. The underlying KafkaConsumer's internal position
may still have
+ // advanced past offsets it never delivers: transaction control records
(commit/abort
+ // markers) under either isolation level, and records filtered out as
aborted under
+ // read_committed. Snap _nextReadOffset to the consumer's actual
position and report it as
+ // the next batch's offset. RealtimeSegmentDataManager only uses it for
consumption lag
+ // and calls us again with the same startOffset, which the seek-check
above does not undo.
+ // The move is not validated (it may also be an out-of-range reset),
which is fine because
+ // no caller resumes from it: the next poll that returns records checks
its first offset
+ // against the caller's startOffset below. The call is bounded by the
same timeout as
+ // poll() so a sick broker can't stall this loop unbounded; after a poll
it is normally
+ // answered locally.
long currentPosition;
try {
currentPosition = _consumer.position(_topicPartition,
Duration.ofMillis(timeoutMs));
diff --git
a/pinot-plugins/pinot-stream-ingestion/pinot-kafka-4.0/src/test/java/org/apache/pinot/plugin/stream/kafka40/KafkaPartitionLevelConsumerDataLossCheckTest.java
b/pinot-plugins/pinot-stream-ingestion/pinot-kafka-4.0/src/test/java/org/apache/pinot/plugin/stream/kafka40/KafkaPartitionLevelConsumerDataLossCheckTest.java
index 166a2c9838f..7b8129f1995 100644
---
a/pinot-plugins/pinot-stream-ingestion/pinot-kafka-4.0/src/test/java/org/apache/pinot/plugin/stream/kafka40/KafkaPartitionLevelConsumerDataLossCheckTest.java
+++
b/pinot-plugins/pinot-stream-ingestion/pinot-kafka-4.0/src/test/java/org/apache/pinot/plugin/stream/kafka40/KafkaPartitionLevelConsumerDataLossCheckTest.java
@@ -42,6 +42,7 @@ import org.testng.annotations.Test;
import static org.apache.kafka.common.record.RecordBatch.NO_TIMESTAMP;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyCollection;
+import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
@@ -140,22 +141,23 @@ public class KafkaPartitionLevelConsumerDataLossCheckTest
{
}
/// An out-of-range reset can surface on an empty poll: the client jumps to
the new log start without returning a
- /// record, so that batch cannot be told from one that only skipped
transaction markers and reports no loss. Under
- /// read_uncommitted the empty batch reports the requested offset as the
next one, so the caller asks for it again
- /// and the loss is reported by the next poll that returns records: its
first offset is past the requested one and
- /// the log now starts above it.
+ /// record, so that batch cannot be told from one that only skipped
transaction markers and reports no loss. The
+ /// empty batch reports the client position as the next one, which callers
only use for consumption lag, so the
+ /// caller asks for its own offset again and the loss is reported by the
next poll that returns records: its first
+ /// offset is past the requested one and the log now starts above it.
@Test
public void testResetSeenOnEmptyPollIsReportedOnNextDataPoll()
throws Exception {
Consumer<Bytes, Bytes> mockConsumer = mock(Consumer.class);
when(mockConsumer.poll(any(Duration.class))).thenReturn(records(),
records(record(150L)));
+ when(mockConsumer.position(eq(TOPIC_PARTITION),
any(Duration.class))).thenReturn(150L);
when(mockConsumer.beginningOffsets(anyCollection(), any(Duration.class)))
.thenReturn(Map.of(TOPIC_PARTITION, 150L));
try (KafkaPartitionLevelConsumer consumer =
createConsumerWithMock(getStreamConfig(null), mockConsumer)) {
KafkaMessageBatch emptyBatch = consumer.fetchMessages(new
LongMsgOffset(100L), TIMEOUT_MS);
assertEquals(emptyBatch.getMessageCount(), 0);
- assertEquals(emptyBatch.getOffsetOfNextBatch().toString(), "100");
+ assertEquals(emptyBatch.getOffsetOfNextBatch().toString(), "150");
assertFalse(emptyBatch.hasDataLoss());
KafkaMessageBatch batch = consumer.fetchMessages(new
LongMsgOffset(100L), TIMEOUT_MS);
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
index ae1d703fece..525b7dec329 100644
---
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
@@ -52,7 +52,8 @@ import static org.testng.Assert.assertTrue;
/// 2. Delete offsets (advance the log start via
[EmbeddedKafkaCluster#deleteRecordsBeforeOffset]) and
/// confirm data loss IS reported.
/// 3. Replay the production pattern: a caught-up consumer on a low-traffic
transactional topic with one
-/// record per transaction, which sees every new record one offset past
where it was parked.
+/// record per transaction, which sees every new record one offset past
where it was parked, and whose
+/// empty polls report the position past the commit marker (the high
watermark) for the consumption lag.
///
/// Speed/stability: setup uses only synchronous broker calls
(createTopics().all().get(),
/// commitTransaction()/flush(), deleteRecords().all().get()), so there are no
fixed sleeps. Reads
@@ -200,9 +201,9 @@ public class
KafkaPartitionLevelConsumerDataLossIntegrationTest {
assertEquals(firstOffset(batch), 0);
assertFalse(batch.hasDataLoss());
- // Caught up: the caller asks for offset 1, the marker. The client skips
it and returns nothing.
- KafkaMessageBatch emptyBatch = consumer.fetchMessages(new
LongMsgOffset(1), FETCH_TIMEOUT_MS);
- assertEquals(emptyBatch.getMessageCount(), 0);
+ // Caught up: the caller asks for offset 1, the marker. The client skips
it and returns nothing, and the empty
+ // batch reports the client position, 2, which is the high watermark:
the lag computed from it is 0.
+ KafkaMessageBatch emptyBatch = fetchUntilNextBatchOffset(consumer, 1, 2);
assertFalse(emptyBatch.hasDataLoss());
// Transaction 2: record at offset 2, marker at 3. The caller still asks
for offset 1, as
@@ -237,10 +238,31 @@ public class
KafkaPartitionLevelConsumerDataLossIntegrationTest {
return batch;
}
+ /// Polls repeatedly at the same startOffset until the (empty) batch reports
`expectedNextBatchOffset` as the
+ /// start of the next batch, i.e. until the consumer has observed and
skipped the control record(s) at/after
+ /// startOffset, or the wait budget elapses. Fails if a record shows up
instead.
+ private KafkaMessageBatch
fetchUntilNextBatchOffset(KafkaPartitionLevelConsumer consumer, long
startOffset,
+ long expectedNextBatchOffset) {
+ long deadlineMs = System.currentTimeMillis() + FETCH_MAX_WAIT_MS;
+ KafkaMessageBatch batch = consumer.fetchMessages(new
LongMsgOffset(startOffset), FETCH_TIMEOUT_MS);
+ while (batch.getMessageCount() == 0 && nextBatchOffset(batch) <
expectedNextBatchOffset
+ && System.currentTimeMillis() < deadlineMs) {
+ batch = consumer.fetchMessages(new LongMsgOffset(startOffset),
FETCH_TIMEOUT_MS);
+ }
+ assertEquals(batch.getMessageCount(), 0, "Expected no record at/after
offset " + startOffset + " yet");
+ assertEquals(nextBatchOffset(batch), expectedNextBatchOffset,
+ "Expected the empty batch to report the consumer position past the
skipped control record");
+ return batch;
+ }
+
private static long firstOffset(KafkaMessageBatch batch) {
return Long.parseLong(batch.getFirstMessageOffset().toString());
}
+ private static long nextBatchOffset(KafkaMessageBatch batch) {
+ return Long.parseLong(batch.getOffsetOfNextBatch().toString());
+ }
+
private StreamConfig streamConfig(String topic, String isolationLevel,
String autoOffsetReset) {
Map<String, String> streamConfigMap = new HashMap<>();
streamConfigMap.put("streamType", "kafka");
diff --git
a/pinot-plugins/pinot-stream-ingestion/pinot-kafka-4.0/src/test/java/org/apache/pinot/plugin/stream/kafka40/KafkaPartitionLevelConsumerSeekTest.java
b/pinot-plugins/pinot-stream-ingestion/pinot-kafka-4.0/src/test/java/org/apache/pinot/plugin/stream/kafka40/KafkaPartitionLevelConsumerSeekTest.java
index 26d946b3ea8..86f8bedd664 100644
---
a/pinot-plugins/pinot-stream-ingestion/pinot-kafka-4.0/src/test/java/org/apache/pinot/plugin/stream/kafka40/KafkaPartitionLevelConsumerSeekTest.java
+++
b/pinot-plugins/pinot-stream-ingestion/pinot-kafka-4.0/src/test/java/org/apache/pinot/plugin/stream/kafka40/KafkaPartitionLevelConsumerSeekTest.java
@@ -39,12 +39,14 @@ import org.testng.annotations.Test;
import static org.apache.kafka.common.record.RecordBatch.NO_TIMESTAMP;
import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyCollection;
import static org.mockito.ArgumentMatchers.eq;
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.testng.Assert.assertEquals;
+import static org.testng.Assert.assertFalse;
/// Regression tests for [KafkaPartitionLevelConsumer] seek decisions.
@@ -87,6 +89,31 @@ public class KafkaPartitionLevelConsumerSeekTest {
verify(mockConsumer, times(1)).seek(TOPIC_PARTITION, 0L);
}
+ /// Under read_uncommitted an empty poll can still move the consumer past a
transaction control record. The empty
+ /// batch must report that position as the start of the next batch, and the
repeated startOffset must not undo
+ /// the progress with a re-seek.
+ @Test
+ public void
testRepeatedStartOffsetAfterReadUncommittedEmptyFetchDoesNotSeekAgain() {
+ Consumer<Bytes, Bytes> mockConsumer = mock(Consumer.class);
+ ConsumerRecords<Bytes, Bytes> emptyRecords =
+ new ConsumerRecords<>(Map.of(TOPIC_PARTITION,
List.<ConsumerRecord<Bytes, Bytes>>of()), Map.of());
+ // The client skipped the commit marker at offset 1 without returning
anything, so its position is now 2
+ when(mockConsumer.poll(any(Duration.class))).thenReturn(emptyRecords,
records(record(2L)));
+ when(mockConsumer.position(eq(TOPIC_PARTITION),
any(Duration.class))).thenReturn(2L);
+ when(mockConsumer.beginningOffsets(anyCollection(), any(Duration.class)))
+ .thenReturn(Map.of(TOPIC_PARTITION, 0L));
+
+ KafkaPartitionLevelConsumer consumer =
createConsumerWithMock(getStreamConfig(null), mockConsumer);
+ KafkaMessageBatch emptyBatch = consumer.fetchMessages(new
LongMsgOffset(1L), 10000);
+ KafkaMessageBatch nextBatch = consumer.fetchMessages(new
LongMsgOffset(1L), 10000);
+
+ assertEquals(emptyBatch.getMessageCount(), 0);
+ assertEquals(emptyBatch.getOffsetOfNextBatch().toString(), "2");
+ assertEquals(nextBatch.getFirstMessageOffset().toString(), "2");
+ assertFalse(nextBatch.hasDataLoss());
+ verify(mockConsumer, times(1)).seek(TOPIC_PARTITION, 1L);
+ }
+
private static KafkaPartitionLevelConsumer
createConsumerWithMock(StreamConfig streamConfig,
Consumer<Bytes, Bytes> mockConsumer) {
class FakeKafkaPartitionLevelConsumer extends KafkaPartitionLevelConsumer {
diff --git
a/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/realtime/writer/StatelessRealtimeSegmentWriter.java
b/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/realtime/writer/StatelessRealtimeSegmentWriter.java
index 9355236c6b2..67a098c515c 100644
---
a/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/realtime/writer/StatelessRealtimeSegmentWriter.java
+++
b/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/realtime/writer/StatelessRealtimeSegmentWriter.java
@@ -84,6 +84,12 @@ import org.slf4j.LoggerFactory;
/// otherwise silently miss part of its offset range. The segment then stays
in ERROR until the operator restores the
/// range from another source or deletes the segment to accept the loss.
///
+/// A batch without unfiltered messages never moves the writer, whatever next
offset it reports: the writer polls again
+/// from its own offset. A Kafka consumer reports its client position there,
which can lie past transaction markers,
+/// but also past deleted records after an out-of-range reset it has not
reported as loss yet. The loss check of the
+/// next batch with records then covers the whole gap. Reaching the end offset
never needs that position, since the
+/// data manager only moves its own offset on fetched records.
+///
/// TODO:
/// 1. Clean up this class and only keep the necessary parts.
/// 2. Use a different segment impl for better performance because it
doesn't need to serve queries.
@@ -281,6 +287,10 @@ public class StatelessRealtimeSegmentWriter implements
Closeable {
+ "the stream no longer holds this range (e.g. it aged out of
retention), so the segment cannot be "
+ "rebuilt from it. Restore the range from another source, or
delete the segment to accept the loss",
_currentOffset, _endOffset);
+ if (messageBatch.getUnfilteredMessageCount() == 0) {
+ // Poll again from the same offset (see the class Javadoc)
+ continue;
+ }
int messageCount = messageBatch.getMessageCount();
diff --git
a/pinot-segment-local/src/test/java/org/apache/pinot/segment/local/realtime/writer/StatelessRealtimeSegmentWriterTest.java
b/pinot-segment-local/src/test/java/org/apache/pinot/segment/local/realtime/writer/StatelessRealtimeSegmentWriterTest.java
index 5ae086e4d19..50fc8c136e0 100644
---
a/pinot-segment-local/src/test/java/org/apache/pinot/segment/local/realtime/writer/StatelessRealtimeSegmentWriterTest.java
+++
b/pinot-segment-local/src/test/java/org/apache/pinot/segment/local/realtime/writer/StatelessRealtimeSegmentWriterTest.java
@@ -115,28 +115,27 @@ public class StatelessRealtimeSegmentWriterTest {
ServerMetrics.register(mock(ServerMetrics.class));
}
- /// A batch without unfiltered messages moves the writer to the next offset
it reports: a consumer can move past
- /// offsets it never delivers (Kafka transaction markers, aborted records
under read_committed), which may land
- /// below, at or past the end offset. A batch that reports the requested
offset leaves it alone. Every such replay
- /// completes with exactly the records below the end, and one whose tail
holds only skipped offsets completes as soon
- /// as the reported position reaches the end (the scripted streams below end
there).
+ /// A batch without unfiltered messages never moves the writer, whatever
next offset it reports: a Kafka consumer
+ /// reports its position there, past skipped transaction markers or aborted
records, and the writer polls again from
+ /// its own offset. Fetched records, filtered ones included, move it as
usual. Every such replay completes with
+ /// exactly the records below the end, including one whose empty batches
report a position at or past the end: the
+ /// next batch with records completes it.
@DataProvider
public Object[][] validReplays() {
return new Object[][]{
{"records", List.of(records(0, 120)), 120, List.of(0L)},
{"nonmoving", List.of(records(0, 100), empty(100, 0, false),
records(100, 120)), 120, List.of(0L, 100L, 100L)},
- // Commit marker at 100: the empty poll moves the consumer to 101, the
next one delivers 101..119
- {"marker", List.of(records(0, 100), empty(101, 0, false), records(101,
120)), 119, List.of(0L, 100L, 101L)},
+ // Commit marker at 100: the empty poll moves the consumer to 101, the
writer asks for 100 again and gets
+ // 101..119
+ {"marker", List.of(records(0, 100), empty(101, 0, false), records(101,
120)), 119, List.of(0L, 100L, 100L)},
{"markerThenFiltered", List.of(records(0, 100), empty(101, 0, false),
empty(106, 5, false), records(106, 120)),
- 114, List.of(0L, 100L, 101L, 106L)},
+ 114, List.of(0L, 100L, 100L, 106L)},
// Five fetched tombstones/filtered records legitimately advance the
position without decoded rows
{"filtered", List.of(records(0, 100), empty(105, 5, false),
records(105, 120)), 115, List.of(0L, 100L, 105L)},
- // Markers fill the tail of the range: the empty advance stays below
the end (the next record lands at the
- // end), or reaches or crosses the end, which completes the replay
with no further record
- {"markerTailBelowEnd", List.of(records(0, 100), empty(110, 0, false),
records(120, 121)), 100,
- List.of(0L, 100L, 110L)},
- {"markerTailAtEnd", List.of(records(0, 100), empty(120, 0, false)),
100, List.of(0L, 100L)},
- {"markerTailPastEnd", List.of(records(0, 100), empty(150, 0, false)),
100, List.of(0L, 100L)},
+ // Markers fill the tail of the range: the empty batches report
positions below, at and past the end, and the
+ // record at the end completes the replay
+ {"markerTail", List.of(records(0, 100), empty(110, 0, false),
empty(120, 0, false), empty(150, 0, false),
+ records(120, 121)), 100, List.of(0L, 100L, 100L, 100L, 100L)},
{"alreadyAtEnd", List.of(), 0, List.of()}
};
}
@@ -185,7 +184,8 @@ public class StatelessRealtimeSegmentWriterTest {
}
/// A non-empty batch that reports data loss is not indexed, including the
case where an empty poll moved the
- /// consumer and the writer first (the loss check of the data poll covers
the gap from the adopted offset).
+ /// consumer first (the writer stays at its own offset, so the loss check of
the data poll covers the gap from
+ /// there).
@DataProvider
public Object[][] lossAfterMarker() {
return new Object[][]{{false}, {true}};
@@ -206,8 +206,8 @@ public class StatelessRealtimeSegmentWriterTest {
assertFalse(replay.writer().isSuccess());
assertEquals(indexedRows(replay.writer()), 100, "The loss batch must not
be indexed");
assertTrue(replay.writer().getConsumptionException().getMessage().contains("data
loss"));
- assertEquals(((LongMsgOffset) field(replay.writer(),
"_currentOffset")).getOffset(), afterMarker ? 101L : 100L);
- assertEquals(replay.requestedOffsets(), afterMarker ? List.of(0L, 100L,
101L) : List.of(0L, 100L));
+ assertEquals(((LongMsgOffset) field(replay.writer(),
"_currentOffset")).getOffset(), 100L);
+ assertEquals(replay.requestedOffsets(), afterMarker ? List.of(0L, 100L,
100L) : List.of(0L, 100L));
verify(replay.consumer(), times(1)).close();
}
}
diff --git
a/pinot-spi/src/main/java/org/apache/pinot/spi/stream/MessageBatch.java
b/pinot-spi/src/main/java/org/apache/pinot/spi/stream/MessageBatch.java
index 55c485fbfec..920b07010be 100644
--- a/pinot-spi/src/main/java/org/apache/pinot/spi/stream/MessageBatch.java
+++ b/pinot-spi/src/main/java/org/apache/pinot/spi/stream/MessageBatch.java
@@ -42,6 +42,11 @@ public interface MessageBatch<T> {
StreamMessage<T> getStreamMessage(int index);
/// Returns the start offset of the next batch.
+ ///
+ /// For a batch without messages this can be ahead of the requested start
offset when the stream moved past offsets
+ /// it never delivers (e.g. Kafka transaction control records). Pinot only
uses that value to report how far the
+ /// stream has been read (consumption lag): the next fetch still starts from
the requested offset, so a value past
+ /// undelivered messages cannot skip them.
StreamPartitionMsgOffset getOffsetOfNextBatch();
/// Returns the offset of the first message (including tombstone) in the
batch.
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]