-
Notifications
You must be signed in to change notification settings - Fork 15.4k
KAFKA-9481: Graceful handling TaskMigrated and TaskCorrupted #8058
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 10 commits
3bfd87c
7493782
33af581
4419900
23cd292
112d9c6
a4af5a5
64dad10
47f3e09
134dda3
2954571
e037be0
a8d81c0
bbd19bc
ae9457b
6b6c391
2fd2eae
f5ce3db
d444d84
40731e9
8738cf5
9831638
946a944
c7719c2
9210ceb
5f36288
6bbc2f6
f4049d0
03f4778
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,44 @@ | ||
| /* | ||
| * 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.kafka.streams.errors; | ||
|
|
||
| import org.apache.kafka.streams.processor.TaskId; | ||
|
|
||
| import java.util.Set; | ||
|
|
||
| /** | ||
| * Indicates a specific task is corrupted and need to be re-initialized. It can be thrown when | ||
| * | ||
| * 1) Under EOS, if the checkpoint file does not contain offsets for corresponding store's changelogs, meaning | ||
| * previously it was not close cleanly; | ||
| * 2) Out-of-range exception thrown during restoration, meaning that the changelog has been modified and we re-bootstrap | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Just now having this thought... Supposing this happens, is it guaranteed to apply to all the stores in the task? I.e., do we really need to re-bootstrap all the stores, or just the one(s) for which our offset is out of range?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yes, we can re-bootstrap the state stores only -- this is what we did in the past but that was a lot messier (remember we have to use the optional in fixed-order map? :P). My thoughts are that for non-EOS, the checkpoint file would likely exist so even re-bootstrap the whole task would be okay, for EOS, it is safer to re-bootstrap the whole task.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It's certainly safer, but the performance hit seems concerning... restoration i/o is already one of the things people complain about most, and this choice could amplify it multiple times over. Maybe we can handle it more cleanly by closing all the stores nicely, writing a checkpoint file with the out-of-range stores' checkpoints at 0, and then re-bootstrapping the task, so it only has to restore the broken stores?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. That sounds a good idea. Let me try it out. |
||
| * the store. | ||
| */ | ||
| public class TaskCorruptedException extends StreamsException { | ||
|
|
||
| private final Set<TaskId> taskIds; | ||
|
|
||
| public TaskCorruptedException(final Set<TaskId> taskIds) { | ||
| super("Tasks " + taskIds + " are corrupted and hence needs to be re-initialized"); | ||
|
|
||
| this.taskIds = taskIds; | ||
| } | ||
|
|
||
| public Set<TaskId> corruptedTaskIds() { | ||
| return taskIds; | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -415,6 +415,8 @@ public void close() throws ProcessorStateException { | |
| log.error("Failed to close state store {}: ", store.name(), exception); | ||
| } | ||
| } | ||
|
|
||
| stores.clear(); | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We need to clear the stores map now since we may re-initialize the state stores upon reviving a task.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Do we also need to clear
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| } | ||
|
|
||
| if (firstException != null) { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -19,6 +19,7 @@ | |
| import org.apache.kafka.clients.consumer.Consumer; | ||
| import org.apache.kafka.clients.consumer.ConsumerRecord; | ||
| import org.apache.kafka.clients.consumer.ConsumerRecords; | ||
| import org.apache.kafka.clients.consumer.InvalidOffsetException; | ||
| import org.apache.kafka.common.KafkaException; | ||
| import org.apache.kafka.common.errors.FencedInstanceIdException; | ||
| import org.apache.kafka.common.errors.TimeoutException; | ||
|
|
@@ -27,7 +28,9 @@ | |
| import org.apache.kafka.common.utils.Time; | ||
| import org.apache.kafka.streams.StreamsConfig; | ||
| import org.apache.kafka.streams.errors.StreamsException; | ||
| import org.apache.kafka.streams.errors.TaskCorruptedException; | ||
| import org.apache.kafka.streams.errors.TaskMigratedException; | ||
| import org.apache.kafka.streams.processor.TaskId; | ||
| import org.apache.kafka.streams.processor.internals.ProcessorStateManager.StateStoreMetadata; | ||
| import org.apache.kafka.streams.processor.StateRestoreListener; | ||
| import org.slf4j.Logger; | ||
|
|
@@ -52,7 +55,6 @@ | |
| * The reader also maintains the source of truth for restoration state: only active tasks restoring changelog could | ||
| * be completed, while standby tasks updating changelog would always be in restoring state after being initialized. | ||
| */ | ||
| // TODO K9113: we need to consider how to handle InvalidOffsetException for consumer#poll / position | ||
| public class StoreChangelogReader implements ChangelogReader { | ||
|
|
||
| enum ChangelogState { | ||
|
|
@@ -258,6 +260,8 @@ private boolean hasRestoredToEnd(final ChangelogMetadata metadata) { | |
| // if we cannot get the position of the consumer within timeout, just return false | ||
| return false; | ||
| } catch (final KafkaException e) { | ||
| // this also includes InvalidOffsetException, which should not happen under normal | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Just wondering, why is it ok to get
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| // execution, hence it is also okay to wrap it as fatal StreamsException | ||
| throw new StreamsException("Restore consumer get unexpected error trying to get the position " + | ||
| " of " + partition, e); | ||
| } | ||
|
|
@@ -409,6 +413,15 @@ public void restore() { | |
| } catch (final FencedInstanceIdException e) { | ||
| // when the consumer gets fenced, all its tasks should be migrated | ||
| throw new TaskMigratedException("Restore consumer get fenced by instance-id polling records.", e); | ||
| } catch (final InvalidOffsetException e) { | ||
| log.warn("Encountered {} fetching records from restore consumer for partitions {}, " + | ||
| "marking the corresponding tasks as corrupted.", e.getClass().getName(), e.partitions()); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I guess it wouldn't hurt to explain what the exception means (our position is too old and has been deleted or compacted by the broker) and what we hope to accomplish by marking the task as corrupted (to re-bootstrap the stores from the changelog and return to normal processing).
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Ack. |
||
|
|
||
| final Set<TaskId> taskIds = new HashSet<>(); | ||
| for (final TopicPartition partition : e.partitions()) { | ||
| taskIds.add(changelogs.get(partition).stateManager.taskId()); | ||
| } | ||
| throw new TaskCorruptedException(taskIds); | ||
| } catch (final KafkaException e) { | ||
| throw new StreamsException("Restore consumer get unexpected error polling records.", e); | ||
| } | ||
|
|
@@ -426,7 +439,6 @@ public void restore() { | |
| restoreChangelog(changelogs.get(partition)); | ||
| } | ||
|
|
||
|
|
||
| maybeUpdateLimitOffsetsForStandbyChangelogs(); | ||
| } | ||
| } | ||
|
|
@@ -679,6 +691,8 @@ private void addChangelogsToRestoreConsumer(final Set<TopicPartition> partitions | |
| } | ||
| assignment.addAll(partitions); | ||
| restoreConsumer.assign(assignment); | ||
|
|
||
| log.debug("Added partitions {} to the restore consumer, current assignment is {}", partitions, assignment); | ||
| } | ||
|
|
||
| private void pauseChangelogsFromRestoreConsumer(final Collection<TopicPartition> partitions) { | ||
|
|
@@ -690,18 +704,18 @@ private void pauseChangelogsFromRestoreConsumer(final Collection<TopicPartition> | |
| "does not contain some of the partitions " + partitions + " for pausing."); | ||
| } | ||
| restoreConsumer.pause(partitions); | ||
|
|
||
| log.debug("Paused partitions {} from the restore consumer", partitions); | ||
| } | ||
|
|
||
| private void removeChangelogsFromRestoreConsumer(final Collection<TopicPartition> partitions) { | ||
| final Set<TopicPartition> assignment = new HashSet<>(restoreConsumer.assignment()); | ||
|
|
||
| // the current assignment should contain the all partitions to remove | ||
| if (!assignment.containsAll(partitions)) { | ||
| throw new IllegalStateException("The current assignment " + assignment + " " + | ||
| "does not contain some of the partitions " + partitions + " for removing."); | ||
| if (assignment.removeAll(partitions)) { | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Here I made the |
||
| restoreConsumer.assign(assignment); | ||
|
|
||
| log.debug("Removed partitions {} from the restore consumer, current assignment is {}", partitions, assignment); | ||
| } | ||
| assignment.removeAll(partitions); | ||
| restoreConsumer.assign(assignment); | ||
| } | ||
|
|
||
| private void resumeChangelogsFromRestoreConsumer(final Collection<TopicPartition> partitions) { | ||
|
|
@@ -713,6 +727,8 @@ private void resumeChangelogsFromRestoreConsumer(final Collection<TopicPartition | |
| "does not contain some of the partitions " + partitions + " for resuming."); | ||
| } | ||
| restoreConsumer.resume(partitions); | ||
|
|
||
| log.debug("Resumed partitions {} from the restore consumer", partitions); | ||
| } | ||
|
|
||
| private void prepareChangelogs(final Set<ChangelogMetadata> newPartitionsToRestore) { | ||
|
|
@@ -759,6 +775,8 @@ private void prepareChangelogs(final Set<ChangelogMetadata> newPartitionsToResto | |
| } catch (final TimeoutException e) { | ||
| // if we cannot find the starting position at the beginning, just use the default 0L | ||
| } catch (final KafkaException e) { | ||
| // this also includes InvalidOffsetException, which should not happen under normal | ||
| // execution, hence it is also okay to wrap it as fatal StreamsException | ||
| throw new StreamsException("Restore consumer get unexpected error trying to get the position " + | ||
| " of " + partition, e); | ||
| } | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -69,10 +69,9 @@ public class StreamTask extends AbstractTask implements ProcessorNodePunctuator, | |
| // visible for testing | ||
| static final byte LATEST_MAGIC_BYTE = 1; | ||
|
|
||
| private final Time time; | ||
| private final Logger log; | ||
| private final String logPrefix; | ||
| private final Time time; | ||
| private final String threadId; | ||
| private final Consumer<byte[], byte[]> consumer; | ||
|
|
||
| // we want to abstract eos logic out of StreamTask, however | ||
|
|
@@ -82,7 +81,6 @@ public class StreamTask extends AbstractTask implements ProcessorNodePunctuator, | |
|
|
||
| private final long maxTaskIdleMs; | ||
| private final int maxBufferedSize; | ||
| private final StreamsMetricsImpl streamsMetrics; | ||
| private final PartitionGroup partitionGroup; | ||
| private final RecordCollector recordCollector; | ||
| private final PartitionGroup.RecordInfo recordInfo; | ||
|
|
@@ -121,11 +119,10 @@ public StreamTask(final TaskId id, | |
| log = logContext.logger(getClass()); | ||
|
|
||
| this.time = time; | ||
| this.streamsMetrics = streamsMetrics; | ||
| this.recordCollector = recordCollector; | ||
| eosDisabled = !StreamsConfig.EXACTLY_ONCE.equals(config.getString(StreamsConfig.PROCESSING_GUARANTEE_CONFIG)); | ||
|
|
||
| threadId = Thread.currentThread().getName(); | ||
| final String threadId = Thread.currentThread().getName(); | ||
| closeTaskSensor = ThreadMetrics.closeTaskSensor(threadId, streamsMetrics); | ||
| final String taskId = id.toString(); | ||
| if (streamsMetrics.version() == Version.FROM_0100_TO_24) { | ||
|
|
@@ -435,7 +432,6 @@ private void close(final boolean clean) { | |
|
|
||
| partitionGroup.close(); | ||
| closeTaskSensor.record(); | ||
| streamsMetrics.removeAllTaskLevelSensors(threadId, id.toString()); | ||
|
|
||
| transitionTo(State.CLOSED); | ||
| } | ||
|
|
@@ -692,9 +688,10 @@ private void closeRecordCollector(final boolean clean) { | |
| @Override | ||
| public void addRecords(final TopicPartition partition, final Iterable<ConsumerRecord<byte[], byte[]>> records) { | ||
| if (state() == State.CLOSED || state() == State.CLOSING) { | ||
| log.info("Stream task {} is already closed, probably because it got unexpectedly migrated to another thread already. " + | ||
| "Notifying the thread to trigger a new rebalance immediately.", id()); | ||
| throw new TaskMigratedException(id()); | ||
| // a task is only closing / closed when 1) task manager is closing, 2) a rebalance is undergoing; | ||
| // in either case we can just log it and move on without notifying the thread since the consumer | ||
| // would soon be updated to not return any records for this task anymore. | ||
| log.info("Stream task {} is already in {} state, skip adding records to it.", id(), state()); | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Hm. I think we should actually be concerned if we ever get to here -- I'm not sure the Unless, the consumer may still return already-fetched records from partitions no longer in its assignment during poll? I thought we would trim those records out and only return from the actual assignment
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Here's my rationale: If the task is closed due to rebalance (i.e. we If the task is closed due to closing the thread, then there's no need to throw an exception either. |
||
| } | ||
|
|
||
| final int newQueueSize = partitionGroup.addRawRecords(partition, records); | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This case 1) would be done in another PR, I just added the java-doc here to complete the scope.