amaechler commented on code in PR #20092:
URL: https://github.com/apache/druid/pull/20092#discussion_r4260716815
##########
extensions-core/kafka-indexing-service/src/main/java/org/apache/druid/indexing/kafka/KafkaIndexTaskRunner.java:
##########
@@ -87,75 +84,51 @@ protected
List<OrderedPartitionableRecord<KafkaTopicPartition, Long, KafkaRecord
return recordSupplier.poll(task.getIOConfig().getPollTimeout());
}
catch (OffsetOutOfRangeException e) {
+ // OffsetOutOfRangeException means one of two things:
//
- // Handles OffsetOutOfRangeException, which is thrown if the seeked-to
- // offset is not present in the topic-partition. This can happen if
we're asking a task to read from data
- // that has not been written yet (which is totally legitimate). So let's
wait for it to show up
- //
+ // 1) the seeked-to offset is below the earliest available offset of the
partition. If
+ // resetOffsetAutomatically is set, re-throw so the supervisor can
reset the offsets. Note that
+ // we must not simply fail the task here: only the supervisor can
update the metadata, and it
+ // recognizes the reset from the exception.
+ // 2) the seeked-to offset is beyond the current end of the partition.
This is legitimate - a task
+ // can be asked to read data that has not been written yet - so wait
for it to show up. Kafka
+ // tasks use auto.offset.reset=none, so polling such an offset raises
this exception instead of
+ // silently repositioning the consumer.
log.warn("OffsetOutOfRangeException with message [%s]", e.getMessage());
- possiblyResetOffsetsOrWait(e.offsetOutOfRangePartitions(),
recordSupplier, toolbox);
- return Collections.emptyList();
- }
- }
- @Override
- protected SeekableStreamEndSequenceNumbers<KafkaTopicPartition, Long>
deserializePartitionsFromMetadata(
- ObjectMapper mapper,
- Object object
- )
- {
- return mapper.convertValue(object,
mapper.getTypeFactory().constructParametrizedType(
- SeekableStreamEndSequenceNumbers.class,
- SeekableStreamEndSequenceNumbers.class,
- KafkaTopicPartition.class,
- Long.class
- ));
- }
+ boolean belowEarliestOffset = false;
+ boolean futureOffset = false;
+ final String stream =
task.getIOConfig().getStartSequenceNumbers().getStream();
+ final boolean isMultiTopic = task.getIOConfig().isMultiTopic();
- private void possiblyResetOffsetsOrWait(
- Map<TopicPartition, Long> outOfRangePartitions,
- RecordSupplier<KafkaTopicPartition, Long, KafkaRecordEntity>
recordSupplier,
- TaskToolbox taskToolbox
- ) throws InterruptedException, IOException
- {
- final String stream =
task.getIOConfig().getStartSequenceNumbers().getStream();
- final boolean isMultiTopic = task.getIOConfig().isMultiTopic();
- final Map<TopicPartition, Long> resetPartitions = new HashMap<>();
- boolean doReset = false;
- if (task.getTuningConfig().isResetOffsetAutomatically()) {
- for (Map.Entry<TopicPartition, Long> outOfRangePartition :
outOfRangePartitions.entrySet()) {
- final TopicPartition topicPartition = outOfRangePartition.getKey();
- final long nextOffset = outOfRangePartition.getValue();
- // seek to the beginning to get the least available offset
- StreamPartition<KafkaTopicPartition> streamPartition =
StreamPartition.of(
+ for (Map.Entry<TopicPartition, Long> entry :
e.offsetOutOfRangePartitions().entrySet()) {
+ final TopicPartition topicPartition = entry.getKey();
+ final StreamPartition<KafkaTopicPartition> streamPartition =
StreamPartition.of(
stream,
new KafkaTopicPartition(isMultiTopic, topicPartition.topic(),
topicPartition.partition())
);
- final Long leastAvailableOffset =
recordSupplier.getEarliestSequenceNumber(streamPartition);
- if (leastAvailableOffset == null) {
- throw new ISE(
- "got null sequence number for partition[%s] when fetching from
kafka!",
- topicPartition.partition()
- );
- }
- // reset the seek
- recordSupplier.seek(streamPartition, nextOffset);
- // Reset consumer offset if resetOffsetAutomatically is set to true
- // and the current message offset in the kafka partition is more than
the
- // next message offset that we are trying to fetch
- if (leastAvailableOffset > nextOffset) {
- doReset = true;
- resetPartitions.put(topicPartition, nextOffset);
+ final Long earliestOffset =
recordSupplier.getEarliestSequenceNumber(streamPartition);
+ final Long latestOffset =
recordSupplier.getLatestSequenceNumber(streamPartition);
+
+ if (earliestOffset != null && earliestOffset > entry.getValue()) {
+ belowEarliestOffset = true;
+ } else if (latestOffset != null && latestOffset < entry.getValue()) {
+ // Not written yet. Waiting for it to appear is the correct
behavior; resetting would skip data.
+ futureOffset = true;
}
}
- }
- if (doReset) {
- sendResetRequestAndWait(CollectionUtils.mapKeys(resetPartitions,
topicPartition -> StreamPartition.of(
- stream,
- new KafkaTopicPartition(isMultiTopic, topicPartition.topic(),
topicPartition.partition())
- )), taskToolbox);
- } else {
+ if (belowEarliestOffset &&
task.getTuningConfig().isResetOffsetAutomatically()) {
+ throw e;
Review Comment:
With `replicas >= 2`, I think this can bring back the symptom #18282 set out
to remove ("new tasks are continuously forked but all fail"). When one replica
fails here while another is still running, `checkCurrentTaskState` only removes
the failed task, and `createNewTasks` tops the group up through
`createTasksForGroup` from the group's original `startingSequences`, which are
already below the earliest offset. Each replacement then fails the same way
until the surviving replica also fails or reaches `taskDuration`.
I reproduced this in a `KafkaSupervisorTest` on this branch: with
`replicas=2`, after `deleteRecords` moved partition 0's earliest offset to 2,
four consecutive replacements all started at offset 0, no reset was issued, and
the supervisor reported `UNHEALTHY_TASKS` from the third failure. On master,
the failing replica sends a `ResetDataSourceMetadataAction` instead, which
avoids the churn but also kills the healthy replica.
Options I can see:
1. Skip replica top-up while the group's starting offsets are unavailable,
next to the bounded-mode guard in the same loop. The surviving replica keeps
running with its data. If it fails, the group empties and goes through the
existing reset path; if it finishes first, the next group starts from its end
offsets. A local sketch of this stopped the churn, and `KafkaSupervisorTest`
still passes:
```java
if (!taskGroup.tasks.isEmpty()
&& taskTuningConfig.isResetOffsetAutomatically()
&& !taskTuningConfig.isSkipSequenceNumberAvailabilityCheck()
&& !areStartingSequencesAvailable(taskGroup)) { //
checkOffsetAvailability() over taskGroup.startingSequences
log.warn("Not creating replacement tasks for taskGroup[%d]: its starting
offsets are no longer available.", groupId);
continue;
}
```
2. Send the group through the existing batch reset. Recovers sooner, but
drops the healthy replica's unpublished data, as master does.
3. Keep the current behaviour and document it.
I'd lean towards 1. Separately, the comment above says the supervisor
"recognizes the reset from the exception", but the supervisor only sees a
failed task and finds the stale offset through its own availability check when
it rebuilds the group. Could the comment say that instead?
##########
extensions-core/kafka-indexing-service/src/main/java/org/apache/druid/indexing/kafka/KafkaIndexTaskRunner.java:
##########
@@ -87,75 +84,51 @@ protected
List<OrderedPartitionableRecord<KafkaTopicPartition, Long, KafkaRecord
return recordSupplier.poll(task.getIOConfig().getPollTimeout());
}
catch (OffsetOutOfRangeException e) {
+ // OffsetOutOfRangeException means one of two things:
//
- // Handles OffsetOutOfRangeException, which is thrown if the seeked-to
- // offset is not present in the topic-partition. This can happen if
we're asking a task to read from data
- // that has not been written yet (which is totally legitimate). So let's
wait for it to show up
- //
+ // 1) the seeked-to offset is below the earliest available offset of the
partition. If
+ // resetOffsetAutomatically is set, re-throw so the supervisor can
reset the offsets. Note that
+ // we must not simply fail the task here: only the supervisor can
update the metadata, and it
+ // recognizes the reset from the exception.
+ // 2) the seeked-to offset is beyond the current end of the partition.
This is legitimate - a task
+ // can be asked to read data that has not been written yet - so wait
for it to show up. Kafka
+ // tasks use auto.offset.reset=none, so polling such an offset raises
this exception instead of
+ // silently repositioning the consumer.
log.warn("OffsetOutOfRangeException with message [%s]", e.getMessage());
- possiblyResetOffsetsOrWait(e.offsetOutOfRangePartitions(),
recordSupplier, toolbox);
- return Collections.emptyList();
- }
- }
- @Override
- protected SeekableStreamEndSequenceNumbers<KafkaTopicPartition, Long>
deserializePartitionsFromMetadata(
- ObjectMapper mapper,
- Object object
- )
- {
- return mapper.convertValue(object,
mapper.getTypeFactory().constructParametrizedType(
- SeekableStreamEndSequenceNumbers.class,
- SeekableStreamEndSequenceNumbers.class,
- KafkaTopicPartition.class,
- Long.class
- ));
- }
+ boolean belowEarliestOffset = false;
+ boolean futureOffset = false;
+ final String stream =
task.getIOConfig().getStartSequenceNumbers().getStream();
+ final boolean isMultiTopic = task.getIOConfig().isMultiTopic();
- private void possiblyResetOffsetsOrWait(
- Map<TopicPartition, Long> outOfRangePartitions,
- RecordSupplier<KafkaTopicPartition, Long, KafkaRecordEntity>
recordSupplier,
- TaskToolbox taskToolbox
- ) throws InterruptedException, IOException
- {
- final String stream =
task.getIOConfig().getStartSequenceNumbers().getStream();
- final boolean isMultiTopic = task.getIOConfig().isMultiTopic();
- final Map<TopicPartition, Long> resetPartitions = new HashMap<>();
- boolean doReset = false;
- if (task.getTuningConfig().isResetOffsetAutomatically()) {
- for (Map.Entry<TopicPartition, Long> outOfRangePartition :
outOfRangePartitions.entrySet()) {
- final TopicPartition topicPartition = outOfRangePartition.getKey();
- final long nextOffset = outOfRangePartition.getValue();
- // seek to the beginning to get the least available offset
- StreamPartition<KafkaTopicPartition> streamPartition =
StreamPartition.of(
+ for (Map.Entry<TopicPartition, Long> entry :
e.offsetOutOfRangePartitions().entrySet()) {
+ final TopicPartition topicPartition = entry.getKey();
+ final StreamPartition<KafkaTopicPartition> streamPartition =
StreamPartition.of(
stream,
new KafkaTopicPartition(isMultiTopic, topicPartition.topic(),
topicPartition.partition())
);
- final Long leastAvailableOffset =
recordSupplier.getEarliestSequenceNumber(streamPartition);
- if (leastAvailableOffset == null) {
- throw new ISE(
- "got null sequence number for partition[%s] when fetching from
kafka!",
- topicPartition.partition()
- );
- }
- // reset the seek
- recordSupplier.seek(streamPartition, nextOffset);
- // Reset consumer offset if resetOffsetAutomatically is set to true
- // and the current message offset in the kafka partition is more than
the
- // next message offset that we are trying to fetch
- if (leastAvailableOffset > nextOffset) {
- doReset = true;
- resetPartitions.put(topicPartition, nextOffset);
+ final Long earliestOffset =
recordSupplier.getEarliestSequenceNumber(streamPartition);
+ final Long latestOffset =
recordSupplier.getLatestSequenceNumber(streamPartition);
Review Comment:
Suggestion: I think the future-offset classification can go. Both branches
end in the same wait and retry, so `futureOffset` only decides whether the "not
yet available" line is logged, and it costs a seek round trip to the broker per
partition on every retry. What's left would be: if `resetOffsetAutomatically`
is set and any partition is below the earliest offset, rethrow; otherwise wait
and retry as before.
##########
indexing-service/src/main/java/org/apache/druid/indexing/seekablestream/supervisor/SeekableStreamSupervisor.java:
##########
@@ -4367,101 +4377,46 @@ private void createNewTasks() throws
JsonProcessingException
}
log.info("Creating new taskGroup[%d] for partitions[%s].", groupId,
partitionGroups.get(groupId));
- final DateTime minimumMessageTime;
- if (ioConfig.getLateMessageRejectionStartDateTime().isPresent()) {
- minimumMessageTime =
ioConfig.getLateMessageRejectionStartDateTime().get();
- } else {
- minimumMessageTime =
ioConfig.getLateMessageRejectionPeriod().isPresent()
- ?
DateTimes.nowUtc().minus(ioConfig.getLateMessageRejectionPeriod().get())
- : null;
- }
-
- final DateTime maximumMessageTime =
ioConfig.getEarlyMessageRejectionPeriod().isPresent()
- ? DateTimes.nowUtc()
-
.plus(ioConfig.getTaskDuration())
-
.plus(ioConfig.getEarlyMessageRejectionPeriod().get())
- : null;
-
- final Map<PartitionIdType, OrderedSequenceNumber<SequenceOffsetType>>
unfilteredStartingOffsets =
- generateStartingSequencesForPartitionGroup(groupId,
metadataOffsets, partitionsToReset);
-
- final Map<PartitionIdType, OrderedSequenceNumber<SequenceOffsetType>>
startingOffsets;
- if (supportsPartitionExpiration()) {
- startingOffsets =
filterExpiredPartitionsFromStartingOffsets(unfilteredStartingOffsets);
- } else {
- startingOffsets = unfilteredStartingOffsets;
- }
-
- ImmutableMap<PartitionIdType, SequenceOffsetType>
simpleStartingOffsets = startingOffsets
- .entrySet()
- .stream()
- .filter(entry -> entry.getValue().get() != null)
- .collect(
- Collectors.collectingAndThen(
- Collectors.toMap(Entry::getKey, entry ->
entry.getValue().get()),
- ImmutableMap::copyOf
- )
- );
-
- ImmutableMap<PartitionIdType, SequenceOffsetType>
simpleUnfilteredStartingOffsets;
- if (supportsPartitionExpiration()) {
- simpleUnfilteredStartingOffsets = unfilteredStartingOffsets
- .entrySet()
- .stream()
- .filter(entry -> entry.getValue().get() != null)
- .collect(
- Collectors.collectingAndThen(
- Collectors.toMap(Entry::getKey, entry ->
entry.getValue().get()),
- ImmutableMap::copyOf
- )
- );
- } else {
- simpleUnfilteredStartingOffsets = simpleStartingOffsets;
- }
-
- Set<PartitionIdType> exclusiveStartSequenceNumberPartitions;
- if (!useExclusiveStartingSequence) {
- exclusiveStartSequenceNumberPartitions = Collections.emptySet();
- } else {
- exclusiveStartSequenceNumberPartitions = startingOffsets
- .entrySet()
- .stream()
- .filter(x -> x.getValue().get() != null
- && x.getValue().isExclusive())
- .map(Entry::getKey)
- .collect(Collectors.toSet());
- }
-
- // NEW: Extract end offsets for bounded mode
- ImmutableMap<PartitionIdType, SequenceOffsetType> endOffsets = null;
- if (ioConfig.isBounded()) {
- endOffsets = ImmutableMap.copyOf(getEndOffsetsForGroup(groupId));
- }
-
- log.info(
- "Initializing taskGroup[%d] with startingOffsets[%s] and
endOffsets[%s]",
- groupId,
- simpleStartingOffsets,
- endOffsets
- );
- newTaskGroups.put(
- groupId,
- new TaskGroup(
- groupId,
- simpleStartingOffsets,
- simpleUnfilteredStartingOffsets,
- endOffsets,
- minimumMessageTime,
- maximumMessageTime,
- exclusiveStartSequenceNumberPartitions
- )
- );
+ newTaskGroups.put(groupId, buildNewTaskGroup(groupId, metadataOffsets,
partitionsToReset));
}
}
// If any partitions need a reset, issue a single batch reset.
if (!partitionsToReset.isEmpty()) {
resetInternal(createDataSourceMetaDataForReset(ioConfig.getStream(),
partitionsToReset));
+
+ // The new task groups were built before the reset, so the ones that
cover a partition which has to
+ // be reset hold no offset for it: 'getOffsetFromStorageForPartition'
left the partition out rather
+ // than taking it from metadata storage. Installing them as they are
would leave a mixed group
+ // reading the stale partition from its old position until the next
rollover, and would make a group
+ // that consists only of reset partitions run no task at all. Rebuild
the affected groups now that
+ // partitionOffsets has been reset, so that each of them starts from the
reset position.
+ // Collect the affected groupIds first: rebuilding a group mutates
'partitionsToReset', so iterating
+ // its keys directly would risk a ConcurrentModificationException, and
several reset partitions can
+ // map to the same group.
+ final Set<Integer> groupsToRebuild = new HashSet<>();
+ for (PartitionIdType partition : partitionsToReset.keySet()) {
+ groupsToRebuild.add(getTaskGroupIdForPartition(partition));
+ }
+
+ for (Integer groupId : groupsToRebuild) {
Review Comment:
I think this rebuild can go, along with the rest of the changes in this
file. `resetInternal` calls `killTaskGroupForPartitions`, which removes the
group from `partitionGroups`, and then puts it back as an empty set through
`computeIfAbsent`. By the time this loop runs, every affected group has no
partitions, so `buildNewTaskGroup` returns an empty `TaskGroup`, which the
`StreamException` below discards. The earlier concern about installing
incomplete groups was already resolved when 1e73792e restored the throw.
The rebuild also changes what
`KafkaSupervisorTest#testGetOffsetFromStorageForPartitionWithResetOffsetAutomatically`
exercises. The test's `generateSequenceName` override calls
`startPartitions.keySet().iterator().next()`, so on this branch the run ends
with a `NoSuchElementException` from the rebuild rather than the
`StreamException`. The test still passes because it only checks the alert
prefix; asserting the exception class through `getExceptionEvents()`, like the
test around line 1358, would catch that.
Master already stages the new groups, issues one batch reset and throws, so
I'd suggest reverting `SeekableStreamSupervisor.java` to master: the rebuild,
the `dataSourceMetadataFetchedInCreateNewTasks` field, the extra overloads and
the `buildNewTaskGroup` extraction.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]