diff --git a/docs/layouts/shortcodes/generated/pipeline_configuration.html b/docs/layouts/shortcodes/generated/pipeline_configuration.html index e4050aee746852..4338f8fe8274c2 100644 --- a/docs/layouts/shortcodes/generated/pipeline_configuration.html +++ b/docs/layouts/shortcodes/generated/pipeline_configuration.html @@ -56,6 +56,12 @@ Boolean Forces Flink to register avro classes in kryo serializer.

Important: Make sure to include the flink-avro module. Otherwise, nothing will be registered. For backward compatibility, the default value is empty to conform to the behavior of the older version. That is, always register avro with kryo, and if flink-avro is not in the class path, register a dummy serializer. In Flink-2.0, we will set the default value to true. + +
pipeline.forward-edge.parallelism-mismatch-mode
+ REBALANCE +

Enum

+ Determines how the runtime handles a FORWARD (pointwise) edge whose producer and consumer parallelism no longer match, which can happen when the parallelism of connected operators is changed independently (for example via the AdaptiveScheduler or pipeline.jobvertex-parallelism-overrides). A FORWARD edge is only valid at equal parallelism; on a mismatch one of the following strategies is applied:

Possible values: +
pipeline.generic-types
true diff --git a/flink-core/src/main/java/org/apache/flink/configuration/PipelineOptions.java b/flink-core/src/main/java/org/apache/flink/configuration/PipelineOptions.java index 4742b6dbca8596..d366b63ce0ed80 100644 --- a/flink-core/src/main/java/org/apache/flink/configuration/PipelineOptions.java +++ b/flink-core/src/main/java/org/apache/flink/configuration/PipelineOptions.java @@ -305,6 +305,68 @@ public enum VertexDescriptionMode { CASCADING } + public static final ConfigOption + FORWARD_EDGE_PARALLELISM_MISMATCH_MODE = + key("pipeline.forward-edge.parallelism-mismatch-mode") + .enumType(ForwardEdgeParallelismMismatchMode.class) + .defaultValue(ForwardEdgeParallelismMismatchMode.REBALANCE) + .withDescription( + Description.builder() + .text( + "Determines how the runtime handles a FORWARD (pointwise) edge whose" + + " producer and consumer parallelism no longer match, which can happen" + + " when the parallelism of connected operators is changed" + + " independently (for example via the AdaptiveScheduler or %s). A" + + " FORWARD edge is only valid at equal parallelism; on a mismatch one" + + " of the following strategies is applied:", + code(PARALLELISM_OVERRIDES.key())) + .list( + text( + "%s: replace the forward partitioner with a rebalance (round-robin)" + + " partitioner. This preserves throughput but reorders records," + + " which is safe for append-only streams but corrupts" + + " order-sensitive (changelog) streams.", + code( + ForwardEdgeParallelismMismatchMode + .REBALANCE + .name())), + text( + "%s: fail the job with an exception instead of silently changing" + + " the data distribution.", + code( + ForwardEdgeParallelismMismatchMode + .FAIL + .name())), + text( + "%s: keep the forward partitioner. Record order is preserved but" + + " the records are funneled to a single consumer subtask, so the" + + " remaining consumer subtasks stay idle.", + code( + ForwardEdgeParallelismMismatchMode + .KEEP_FORWARD + .name()))) + .build()); + + /** + * The strategy applied when a FORWARD (pointwise) edge connects a producer and consumer with + * mismatched parallelism. + */ + @PublicEvolving + public enum ForwardEdgeParallelismMismatchMode { + /** + * Replace the forward partitioner with a rebalance partitioner. Reorders records; safe only + * for append-only streams. + */ + REBALANCE, + /** Fail the job with an exception. */ + FAIL, + /** + * Keep the forward partitioner. Preserves record order but funnels records to a single + * consumer subtask. + */ + KEEP_FORWARD + } + public static final ConfigOption VERTEX_NAME_INCLUDE_INDEX_PREFIX = key("pipeline.vertex-name-include-index-prefix") .booleanType() diff --git a/flink-runtime/src/main/java/org/apache/flink/streaming/runtime/tasks/StreamTask.java b/flink-runtime/src/main/java/org/apache/flink/streaming/runtime/tasks/StreamTask.java index 8a08c73a887d68..df3a58bdd71091 100644 --- a/flink-runtime/src/main/java/org/apache/flink/streaming/runtime/tasks/StreamTask.java +++ b/flink-runtime/src/main/java/org/apache/flink/streaming/runtime/tasks/StreamTask.java @@ -25,6 +25,8 @@ import org.apache.flink.configuration.CheckpointingOptions; import org.apache.flink.configuration.Configuration; import org.apache.flink.configuration.NettyShuffleEnvironmentOptions; +import org.apache.flink.configuration.PipelineOptions; +import org.apache.flink.configuration.PipelineOptions.ForwardEdgeParallelismMismatchMode; import org.apache.flink.configuration.TaskManagerOptions; import org.apache.flink.core.execution.RecoveryClaimMode; import org.apache.flink.core.fs.AutoCloseableRegistry; @@ -2051,18 +2053,60 @@ private static void replaceForwardPartitionerIfConsumerParallelismDoesNotMatch( // The number of subpartitions isn't the consumer parallelism on a POINTWISE edge, so the // actual consumer parallelism is passed through the deployment descriptor. final int consumerParallelism = environment.getWriterConsumerParallelism(outputIndex); - if (streamOutput.getPartitioner() instanceof ForwardPartitioner + if (!(streamOutput.getPartitioner() instanceof ForwardPartitioner) // An undecided consumer parallelism is not a mismatch. - && consumerParallelism - != ResultPartitionDeploymentDescriptor.UNKNOWN_CONSUMER_PARALLELISM - && consumerParallelism != producerParallelism) { - LOG.debug( - "Replacing forward partitioner with rebalance for {} " - + "(producer parallelism {} != consumer parallelism {}).", - environment.getTaskInfo().getTaskNameWithSubtasks(), - producerParallelism, - consumerParallelism); - streamOutput.setPartitioner(new RebalancePartitioner<>()); + || consumerParallelism + == ResultPartitionDeploymentDescriptor.UNKNOWN_CONSUMER_PARALLELISM + || consumerParallelism == producerParallelism) { + return; + } + + final ForwardEdgeParallelismMismatchMode mode = + environment + .getJobConfiguration() + .get(PipelineOptions.FORWARD_EDGE_PARALLELISM_MISMATCH_MODE); + final String taskNameWithSubtasks = environment.getTaskInfo().getTaskNameWithSubtasks(); + switch (mode) { + case REBALANCE: + LOG.warn( + "Replacing forward partitioner with rebalance for {} " + + "(producer parallelism {} != consumer parallelism {}). This may " + + "reorder records and produce incorrect results for order-sensitive " + + "(changelog) streams. Set '{}' to {} or {} to avoid it.", + taskNameWithSubtasks, + producerParallelism, + consumerParallelism, + PipelineOptions.FORWARD_EDGE_PARALLELISM_MISMATCH_MODE.key(), + ForwardEdgeParallelismMismatchMode.FAIL, + ForwardEdgeParallelismMismatchMode.KEEP_FORWARD); + streamOutput.setPartitioner(new RebalancePartitioner<>()); + break; + case KEEP_FORWARD: + LOG.warn( + "Keeping forward partitioner for {} despite a parallelism mismatch " + + "(producer parallelism {} != consumer parallelism {}). Record order " + + "is preserved but records are funneled to a single consumer subtask, " + + "leaving the remaining consumer subtasks idle.", + taskNameWithSubtasks, + producerParallelism, + consumerParallelism); + break; + case FAIL: + throw new FlinkRuntimeException( + String.format( + "Forward partitioning cannot be preserved across a parallelism change " + + "for %s (producer parallelism %d != consumer parallelism %d). " + + "Silently downgrading a FORWARD edge to a redistributing " + + "exchange can reorder records and corrupt order-sensitive " + + "(changelog) results. Set %s to %s or %s to allow it.", + taskNameWithSubtasks, + producerParallelism, + consumerParallelism, + PipelineOptions.FORWARD_EDGE_PARALLELISM_MISMATCH_MODE.key(), + ForwardEdgeParallelismMismatchMode.REBALANCE, + ForwardEdgeParallelismMismatchMode.KEEP_FORWARD)); + default: + throw new IllegalStateException("Unhandled mode: " + mode); } } diff --git a/flink-streaming-java/src/test/java/org/apache/flink/streaming/runtime/tasks/StreamTaskTest.java b/flink-streaming-java/src/test/java/org/apache/flink/streaming/runtime/tasks/StreamTaskTest.java index 93d000b8ec58ea..8ca889c033ab58 100644 --- a/flink-streaming-java/src/test/java/org/apache/flink/streaming/runtime/tasks/StreamTaskTest.java +++ b/flink-streaming-java/src/test/java/org/apache/flink/streaming/runtime/tasks/StreamTaskTest.java @@ -30,6 +30,8 @@ import org.apache.flink.configuration.Configuration; import org.apache.flink.configuration.IllegalConfigurationException; import org.apache.flink.configuration.NettyShuffleEnvironmentOptions; +import org.apache.flink.configuration.PipelineOptions; +import org.apache.flink.configuration.PipelineOptions.ForwardEdgeParallelismMismatchMode; import org.apache.flink.configuration.ReadableConfig; import org.apache.flink.core.execution.SavepointFormatType; import org.apache.flink.core.fs.FSDataInputStream; @@ -1893,6 +1895,82 @@ public int getNumberOfSubpartitions() { } } + @Test + void testForwardPartitionerFailsOnParallelismChangeWhenModeIsFail() throws Exception { + StreamTaskMailboxTestHarnessBuilder builder = + new StreamTaskMailboxTestHarnessBuilder<>( + OneInputStreamTask::new, BasicTypeInfo.INT_TYPE_INFO) + .addInput(BasicTypeInfo.INT_TYPE_INFO) + .addJobConfig( + PipelineOptions.FORWARD_EDGE_PARALLELISM_MISMATCH_MODE, + ForwardEdgeParallelismMismatchMode.FAIL) + .setOutputPartitioner(new ForwardPartitioner<>()) + .setupOutputForSingletonOperatorChain( + new TestBoundedOneInputStreamOperator()); + + try (StreamTaskMailboxTestHarness harness = builder.build()) { + // Simulate changed downstream task parallelism (1->2) + List newOutputs = new ArrayList<>(); + newOutputs.add( + new MockResultPartitionWriter() { + @Override + public int getNumberOfSubpartitions() { + return 2; + } + }); + harness.streamMockEnvironment.setOutputs(newOutputs); + harness.streamMockEnvironment.setWriterConsumerParallelism(2); + + // With FAIL mode the job is rejected instead of silently downgrading the edge. + assertThatThrownBy( + () -> + harness.streamTask.createRecordWriterDelegate( + harness.streamTask.configuration, + harness.streamMockEnvironment)) + .isInstanceOf(FlinkRuntimeException.class) + .hasMessageContaining("Forward partitioning cannot be preserved"); + } + } + + @Test + void testForwardPartitionerIsKeptOnParallelismChangeWhenModeIsKeepForward() throws Exception { + StreamTaskMailboxTestHarnessBuilder builder = + new StreamTaskMailboxTestHarnessBuilder<>( + OneInputStreamTask::new, BasicTypeInfo.INT_TYPE_INFO) + .addInput(BasicTypeInfo.INT_TYPE_INFO) + .addJobConfig( + PipelineOptions.FORWARD_EDGE_PARALLELISM_MISMATCH_MODE, + ForwardEdgeParallelismMismatchMode.KEEP_FORWARD) + .setOutputPartitioner(new ForwardPartitioner<>()) + .setupOutputForSingletonOperatorChain( + new TestBoundedOneInputStreamOperator()); + + try (StreamTaskMailboxTestHarness harness = builder.build()) { + // Simulate changed downstream task parallelism (1->2) + List newOutputs = new ArrayList<>(); + newOutputs.add( + new MockResultPartitionWriter() { + @Override + public int getNumberOfSubpartitions() { + return 2; + } + }); + harness.streamMockEnvironment.setOutputs(newOutputs); + harness.streamMockEnvironment.setWriterConsumerParallelism(2); + + RecordWriterDelegate>> recordWriterDelegate = + harness.streamTask.createRecordWriterDelegate( + harness.streamTask.configuration, harness.streamMockEnvironment); + // With KEEP_FORWARD mode the forward partitioner is preserved despite the mismatch. + assertThat( + ((ChannelSelectorRecordWriter) + ((SingleRecordWriter) recordWriterDelegate) + .getRecordWriter(0)) + .getChannelSelector()) + .isInstanceOf(ForwardPartitioner.class); + } + } + @Test void testGetAndCheckMaxTraverseSize() { Configuration config = new Configuration(); diff --git a/flink-tests/src/test/java/org/apache/flink/test/scheduling/ForwardEdgeParallelismMismatchITCaseBase.java b/flink-tests/src/test/java/org/apache/flink/test/scheduling/ForwardEdgeParallelismMismatchITCaseBase.java new file mode 100644 index 00000000000000..fd62d6954cbc0d --- /dev/null +++ b/flink-tests/src/test/java/org/apache/flink/test/scheduling/ForwardEdgeParallelismMismatchITCaseBase.java @@ -0,0 +1,279 @@ +/* + * 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.flink.test.scheduling; + +import org.apache.flink.api.common.RuntimeExecutionMode; +import org.apache.flink.api.common.eventtime.WatermarkStrategy; +import org.apache.flink.api.connector.source.Source; +import org.apache.flink.configuration.CheckpointingOptions; +import org.apache.flink.configuration.Configuration; +import org.apache.flink.configuration.JobManagerOptions; +import org.apache.flink.configuration.JobManagerOptions.SchedulerType; +import org.apache.flink.configuration.PipelineOptions; +import org.apache.flink.configuration.PipelineOptions.ForwardEdgeParallelismMismatchMode; +import org.apache.flink.configuration.RestOptions; +import org.apache.flink.configuration.RestartStrategyOptions; +import org.apache.flink.runtime.jobgraph.JobGraph; +import org.apache.flink.runtime.jobgraph.JobVertexID; +import org.apache.flink.runtime.minicluster.MiniCluster; +import org.apache.flink.runtime.minicluster.MiniClusterConfiguration; +import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment; +import org.apache.flink.streaming.api.functions.sink.v2.DiscardingSink; +import org.apache.flink.streaming.runtime.tasks.StreamTask; +import org.apache.flink.testutils.junit.extensions.parameterized.Parameter; +import org.apache.flink.testutils.junit.extensions.parameterized.ParameterizedTestExtension; +import org.apache.flink.testutils.logging.LoggerAuditingExtension; +import org.apache.flink.util.ExceptionUtils; + +import org.junit.jupiter.api.TestTemplate; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.extension.RegisterExtension; +import org.slf4j.event.Level; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; + +import static org.apache.flink.configuration.RestartStrategyOptions.RestartStrategyType.NO_RESTART_STRATEGY; +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Verifies which branch of {@link PipelineOptions#FORWARD_EDGE_PARALLELISM_MISMATCH_MODE} a {@code + * source -> forward -> sink} job takes after its parallelism is changed. + * + *

The branch is decided per producer job vertex from the real producer and consumer parallelism: + * if they are equal no branch is taken; otherwise every producer subtask either logs the configured + * branch (REBALANCE, KEEP_FORWARD) or fails the job (FAIL). Subclasses run the jobs and use the + * helpers of this class to build them and to assert the branch. + */ +@ExtendWith(ParameterizedTestExtension.class) +abstract class ForwardEdgeParallelismMismatchITCaseBase { + + private static final int NUM_SLOTS = 8; + + private static final String REBALANCE_LOG = "Replacing forward partitioner"; + private static final String KEEP_FORWARD_LOG = "Keeping forward partitioner"; + private static final String FAIL_MESSAGE = "Forward partitioning cannot be preserved"; + + static final List PARALLELISM_CHANGES = + Arrays.asList( + new ParallelismChange(4, 4, 4), + new ParallelismChange(4, 5, 5), + new ParallelismChange(4, 3, 3), + new ParallelismChange(1, 1, 2), + new ParallelismChange(2, 2, 4), + new ParallelismChange(2, 2, 3), + new ParallelismChange(4, 4, 2), + new ParallelismChange(3, 3, 2), + new ParallelismChange(2, 2, 1), + new ParallelismChange(4, 2, 3), + new ParallelismChange(2, 4, 2)); + + @Parameter(0) + public SchedulerType scheduler; + + @Parameter(1) + public ParallelismChange parallelismChange; + + @Parameter(2) + public ForwardEdgeParallelismMismatchMode mode; + + @RegisterExtension + private final LoggerAuditingExtension streamTaskLogs = + new LoggerAuditingExtension(StreamTask.class, Level.DEBUG); + + /** + * Runs a job with the initial parallelism and changes it to the new parallelism. + * + * @return the failure of the job with the new parallelism, if any. + */ + abstract Optional runAndRescale(MiniCluster miniCluster) throws Exception; + + @TestTemplate + void testForwardEdgeParallelismMismatch() throws Exception { + final Optional failure; + try (MiniCluster miniCluster = startMiniCluster()) { + failure = runAndRescale(miniCluster); + } + assertExpectedBranch(failure); + } + + /** Combines the given schedulers with every parallelism change and mode. */ + static List parameters( + List schedulers, boolean excludeUnchangedParallelism) { + final List parameters = new ArrayList<>(); + for (SchedulerType scheduler : schedulers) { + for (ParallelismChange parallelismChange : PARALLELISM_CHANGES) { + if (excludeUnchangedParallelism && parallelismChange.isUnchanged()) { + continue; + } + for (ForwardEdgeParallelismMismatchMode mode : + ForwardEdgeParallelismMismatchMode.values()) { + parameters.add(new Object[] {scheduler, parallelismChange, mode}); + } + } + } + return parameters; + } + + /** + * Asserts the branch taken after the parallelism change, from the given failure of the job + * running with the new parallelism and the partitioner logs of all jobs of the test. + */ + private void assertExpectedBranch(Optional failure) { + final List partitionerLogs = + streamTaskLogs.getMessages().stream() + .filter(m -> m.contains(REBALANCE_LOG) || m.contains(KEEP_FORWARD_LOG)) + .collect(Collectors.toList()); + + final String failureDescription = + failure.map(ExceptionUtils::stringifyException).orElse(""); + if (!isMismatch()) { + assertThat(failure).as("job failure: %s", failureDescription).isEmpty(); + assertThat(partitionerLogs).as("partitioner logs").isEmpty(); + } else if (scheduler == SchedulerType.AdaptiveBatch) { + // Forward groups require equal parallelism, so the job is rejected before deployment. + assertThat(failure).as("job failure").isPresent(); + assertThat(ExceptionUtils.findThrowable(failure.get(), IllegalStateException.class)) + .as("forward group rejection, actual failure: %s", failureDescription) + .isPresent(); + assertThat(partitionerLogs).as("partitioner logs").isEmpty(); + } else if (mode == ForwardEdgeParallelismMismatchMode.FAIL) { + assertThat(failure).as("job failure").isPresent(); + assertThat(ExceptionUtils.findThrowableWithMessage(failure.get(), FAIL_MESSAGE)) + .as("FAIL mode exception, actual failure: %s", failureDescription) + .isPresent(); + assertThat( + ExceptionUtils.findThrowableWithMessage( + failure.get(), expectedParallelismMessage())) + .as("real parallelism in exception, actual failure: %s", failureDescription) + .isPresent(); + assertThat(partitionerLogs).as("partitioner logs").isEmpty(); + } else { + assertThat(failure).as("job failure: %s", failureDescription).isEmpty(); + final String expectedLog = + mode == ForwardEdgeParallelismMismatchMode.REBALANCE + ? REBALANCE_LOG + : KEEP_FORWARD_LOG; + assertThat(partitionerLogs) + .as("partitioner logs, one per producer subtask") + .hasSize(parallelismChange.newSource) + .allSatisfy( + m -> + assertThat(m) + .contains(expectedLog) + .contains(expectedParallelismMessage())); + } + } + + private boolean isMismatch() { + return parallelismChange.newSource != parallelismChange.newSink; + } + + private String expectedParallelismMessage() { + return String.format( + "producer parallelism %d != consumer parallelism %d", + parallelismChange.newSource, parallelismChange.newSink); + } + + /** + * Used for both the cluster and the job. The AdaptiveScheduler options are ignored by the other + * schedulers, and checkpointing is ignored in BATCH mode. + */ + private Configuration createConfiguration() { + final Configuration configuration = new Configuration(); + configuration.set(JobManagerOptions.SCHEDULER, scheduler); + configuration.set(RestOptions.BIND_PORT, "0"); + configuration.set( + JobManagerOptions.SCHEDULER_EXECUTING_COOLDOWN_AFTER_RESCALING, Duration.ZERO); + configuration.set( + JobManagerOptions.SCHEDULER_EXECUTING_RESOURCE_STABILIZATION_TIMEOUT, + Duration.ZERO); + configuration.set(CheckpointingOptions.CHECKPOINTING_INTERVAL, Duration.ofSeconds(1)); + // Otherwise checkpointing enables restarts and FAIL would restart the job forever. + configuration.set( + RestartStrategyOptions.RESTART_STRATEGY, NO_RESTART_STRATEGY.getMainValue()); + configuration.set(PipelineOptions.FORWARD_EDGE_PARALLELISM_MISMATCH_MODE, mode); + return configuration; + } + + /** Creates a {@code source -> forward -> sink} job with the initial parallelism. */ + JobGraph createJobGraph(Source source) { + final StreamExecutionEnvironment env = + StreamExecutionEnvironment.getExecutionEnvironment(createConfiguration()); + env.setRuntimeMode( + scheduler == SchedulerType.AdaptiveBatch + ? RuntimeExecutionMode.BATCH + : RuntimeExecutionMode.STREAMING); + env.setParallelism(parallelismChange.initial); + env.disableOperatorChaining(); + + env.fromSource(source, WatermarkStrategy.noWatermarks(), "source") + .forward() + .sinkTo(new DiscardingSink<>()) + .name("sink"); + + return env.getStreamGraph().getJobGraph(); + } + + MiniCluster startMiniCluster() throws Exception { + final MiniCluster miniCluster = + new MiniCluster( + new MiniClusterConfiguration.Builder() + .setConfiguration(createConfiguration()) + .setNumTaskManagers(1) + .setNumSlotsPerTaskManager(NUM_SLOTS) + .build()); + miniCluster.start(); + return miniCluster; + } + + static JobVertexID sourceId(JobGraph jobGraph) { + return jobGraph.getVerticesSortedTopologicallyFromSources().get(0).getID(); + } + + static JobVertexID sinkId(JobGraph jobGraph) { + return jobGraph.getVerticesSortedTopologicallyFromSources().get(1).getID(); + } + + /** Initial parallelism of both vertices and their parallelism after the change. */ + static final class ParallelismChange { + final int initial; + final int newSource; + final int newSink; + + ParallelismChange(int initial, int newSource, int newSink) { + this.initial = initial; + this.newSource = newSource; + this.newSink = newSink; + } + + boolean isUnchanged() { + return initial == newSource && initial == newSink; + } + + @Override + public String toString() { + return String.format("(%d,%d)->(%d,%d)", initial, initial, newSource, newSink); + } + } +} diff --git a/flink-tests/src/test/java/org/apache/flink/test/scheduling/ForwardEdgeParallelismMismatchOverridesITCase.java b/flink-tests/src/test/java/org/apache/flink/test/scheduling/ForwardEdgeParallelismMismatchOverridesITCase.java new file mode 100644 index 00000000000000..c72a3552488f14 --- /dev/null +++ b/flink-tests/src/test/java/org/apache/flink/test/scheduling/ForwardEdgeParallelismMismatchOverridesITCase.java @@ -0,0 +1,83 @@ +/* + * 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.flink.test.scheduling; + +import org.apache.flink.api.common.typeinfo.Types; +import org.apache.flink.configuration.JobManagerOptions.SchedulerType; +import org.apache.flink.configuration.PipelineOptions; +import org.apache.flink.connector.datagen.source.DataGeneratorSource; +import org.apache.flink.runtime.jobgraph.JobGraph; +import org.apache.flink.runtime.jobmaster.JobResult; +import org.apache.flink.runtime.minicluster.MiniCluster; +import org.apache.flink.testutils.junit.extensions.parameterized.Parameters; + +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Changes the parallelism at submission time via {@link PipelineOptions#PARALLELISM_OVERRIDES}, + * which applies to all schedulers. + */ +class ForwardEdgeParallelismMismatchOverridesITCase + extends ForwardEdgeParallelismMismatchITCaseBase { + + @Parameters(name = "scheduler={0}, parallelism={1}, mode={2}") + private static List parameters() { + return parameters( + Arrays.asList( + SchedulerType.Default, SchedulerType.Adaptive, SchedulerType.AdaptiveBatch), + false); + } + + /** + * Runs a bounded job with the initial parallelism to the end, then submits it again with the + * new parallelism applied via the overrides. + */ + @Override + Optional runAndRescale(MiniCluster miniCluster) throws Exception { + assertThat(runToEnd(miniCluster, createJobGraph(createSource()))) + .as("failure of the job with the initial parallelism") + .isEmpty(); + + final JobGraph jobGraph = createJobGraph(createSource()); + final Map overrides = new HashMap<>(); + overrides.put( + sourceId(jobGraph).toHexString(), String.valueOf(parallelismChange.newSource)); + overrides.put(sinkId(jobGraph).toHexString(), String.valueOf(parallelismChange.newSink)); + jobGraph.getJobConfiguration().set(PipelineOptions.PARALLELISM_OVERRIDES, overrides); + return runToEnd(miniCluster, jobGraph); + } + + private static DataGeneratorSource createSource() { + return new DataGeneratorSource<>(index -> index, 100, Types.LONG); + } + + private Optional runToEnd(MiniCluster miniCluster, JobGraph jobGraph) + throws Exception { + miniCluster.submitJob(jobGraph).get(); + final JobResult result = miniCluster.requestJobResult(jobGraph.getJobID()).get(); + return result.getSerializedThrowable() + .map(t -> t.deserializeError(getClass().getClassLoader())); + } +} diff --git a/flink-tests/src/test/java/org/apache/flink/test/scheduling/ForwardEdgeParallelismMismatchRestRescaleITCase.java b/flink-tests/src/test/java/org/apache/flink/test/scheduling/ForwardEdgeParallelismMismatchRestRescaleITCase.java new file mode 100644 index 00000000000000..fdce806e62eb44 --- /dev/null +++ b/flink-tests/src/test/java/org/apache/flink/test/scheduling/ForwardEdgeParallelismMismatchRestRescaleITCase.java @@ -0,0 +1,175 @@ +/* + * 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.flink.test.scheduling; + +import org.apache.flink.api.common.JobID; +import org.apache.flink.api.common.typeinfo.Types; +import org.apache.flink.api.connector.source.util.ratelimit.RateLimiterStrategy; +import org.apache.flink.configuration.JobManagerOptions.SchedulerType; +import org.apache.flink.connector.datagen.source.DataGeneratorSource; +import org.apache.flink.runtime.checkpoint.CompletedCheckpointStats; +import org.apache.flink.runtime.executiongraph.AccessExecutionGraph; +import org.apache.flink.runtime.jobgraph.JobGraph; +import org.apache.flink.runtime.jobgraph.JobResourceRequirements; +import org.apache.flink.runtime.jobgraph.JobVertexID; +import org.apache.flink.runtime.minicluster.MiniCluster; +import org.apache.flink.testutils.junit.extensions.parameterized.Parameters; + +import java.util.Collections; +import java.util.List; +import java.util.Optional; + +import static org.apache.flink.runtime.testutils.CommonTestUtils.waitUntilCondition; + +/** + * Changes the parallelism of a running job via the resource requirements API, which only the {@link + * SchedulerType#Adaptive} scheduler supports. + */ +class ForwardEdgeParallelismMismatchRestRescaleITCase + extends ForwardEdgeParallelismMismatchITCaseBase { + + /** Throttled so that the job keeps running until the test cancels it. */ + private static final double RECORDS_PER_SECOND = 100; + + @Parameters(name = "scheduler={0}, parallelism={1}, mode={2}") + private static List parameters() { + // An unchanged parallelism doesn't trigger a rescale. + return parameters(Collections.singletonList(SchedulerType.Adaptive), true); + } + + private static DataGeneratorSource createSource() { + return new DataGeneratorSource<>( + index -> index, + Long.MAX_VALUE, + RateLimiterStrategy.perSecond(RECORDS_PER_SECOND), + Types.LONG); + } + + /** Rescales the job once it has completed a checkpoint. */ + @Override + Optional runAndRescale(MiniCluster miniCluster) throws Exception { + final JobGraph jobGraph = createJobGraph(createSource()); + final JobID jobId = jobGraph.getJobID(); + final JobVertexID sourceId = sourceId(jobGraph); + final JobVertexID sinkId = sinkId(jobGraph); + miniCluster.submitJob(jobGraph).get(); + awaitCheckpointWithInitialParallelism(miniCluster, jobId, sourceId, sinkId); + + miniCluster + .updateJobResourceRequirements( + jobId, + JobResourceRequirements.newBuilder() + .setParallelismForJobVertex( + sourceId, + parallelismChange.newSource, + parallelismChange.newSource) + .setParallelismForJobVertex( + sinkId, + parallelismChange.newSink, + parallelismChange.newSink) + .build()) + .get(); + + if (awaitCheckpointOrTermination( + miniCluster, + jobId, + sourceId, + sinkId, + parallelismChange.newSource, + parallelismChange.newSink)) { + return getFailure(miniCluster, jobId); + } + miniCluster.cancelJob(jobId).get(); + return Optional.empty(); + } + + /** + * Waits until the job completes a checkpoint with the initial parallelism, which means all its + * tasks are running. The job is not expected to terminate. + */ + private void awaitCheckpointWithInitialParallelism( + MiniCluster miniCluster, JobID jobId, JobVertexID sourceId, JobVertexID sinkId) + throws Exception { + waitUntilCondition( + () -> { + final AccessExecutionGraph graph = miniCluster.getExecutionGraph(jobId).get(); + if (graph.getState().isGloballyTerminalState()) { + throw new AssertionError( + "Job terminated unexpectedly in state " + graph.getState()); + } + return isCheckpointedWithExpectedParallelism( + graph, + sourceId, + sinkId, + parallelismChange.initial, + parallelismChange.initial); + }); + } + + /** + * Like {@link #awaitCheckpointWithInitialParallelism}, but the job may also terminate, e.g. + * when FAIL rejects the new parallelism. + * + * @return whether the job terminated. + */ + private static boolean awaitCheckpointOrTermination( + MiniCluster miniCluster, + JobID jobId, + JobVertexID sourceId, + JobVertexID sinkId, + int expectedSourceParallelism, + int expectedSinkParallelism) + throws Exception { + waitUntilCondition( + () -> { + final AccessExecutionGraph graph = miniCluster.getExecutionGraph(jobId).get(); + return graph.getState().isGloballyTerminalState() + || isCheckpointedWithExpectedParallelism( + graph, + sourceId, + sinkId, + expectedSourceParallelism, + expectedSinkParallelism); + }); + return miniCluster.getJobStatus(jobId).get().isGloballyTerminalState(); + } + + private static boolean isCheckpointedWithExpectedParallelism( + AccessExecutionGraph graph, + JobVertexID sourceId, + JobVertexID sinkId, + int expectedSourceParallelism, + int expectedSinkParallelism) { + final CompletedCheckpointStats checkpoint = + graph.getCheckpointStatsSnapshot().getHistory().getLatestCompletedCheckpoint(); + return checkpoint != null + && checkpoint.getTaskStateStats(sourceId).getNumberOfSubtasks() + == expectedSourceParallelism + && checkpoint.getTaskStateStats(sinkId).getNumberOfSubtasks() + == expectedSinkParallelism; + } + + private Optional getFailure(MiniCluster miniCluster, JobID jobId) throws Exception { + return miniCluster + .requestJobResult(jobId) + .get() + .getSerializedThrowable() + .map(t -> t.deserializeError(getClass().getClassLoader())); + } +}