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

yuxiqian pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/flink-cdc.git


The following commit(s) were added to refs/heads/master by this push:
     new 391169d05 [hotfix][runtime Preserve original failure during 
distributed schema evolution (#4556)
391169d05 is described below

commit 391169d059b54f538b1e698e51ba3c36da69d5cc
Author: yuxiqian <[email protected]>
AuthorDate: Wed Sep 23 17:56:12 2026 +0800

    [hotfix][runtime Preserve original failure during distributed schema 
evolution (#4556)
    
    Co-authored-by: lvyanquan.lyq <[email protected]>
---
 ...edSchemaEvolutionFailurePreservationITCase.java | 162 ++++++++++++++
 .../testsink/factory/FailingDataSinkFactory.java   | 224 +++++++++++++++++++
 .../org.apache.flink.cdc.common.factories.Factory  |   1 +
 .../operators/schema/common/SchemaRegistry.java    |   2 +-
 .../schema/distributed/SchemaCoordinator.java      |  71 +++++-
 .../schema/distributed/SchemaCoordinatorTest.java  | 242 +++++++++++++++++++++
 6 files changed, 697 insertions(+), 5 deletions(-)

diff --git 
a/flink-cdc-composer/src/test/java/org/apache/flink/cdc/composer/flink/DistributedSchemaEvolutionFailurePreservationITCase.java
 
b/flink-cdc-composer/src/test/java/org/apache/flink/cdc/composer/flink/DistributedSchemaEvolutionFailurePreservationITCase.java
new file mode 100644
index 000000000..2f86709d7
--- /dev/null
+++ 
b/flink-cdc-composer/src/test/java/org/apache/flink/cdc/composer/flink/DistributedSchemaEvolutionFailurePreservationITCase.java
@@ -0,0 +1,162 @@
+/*
+ * 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.cdc.composer.flink;
+
+import org.apache.flink.cdc.common.configuration.Configuration;
+import org.apache.flink.cdc.common.pipeline.PipelineOptions;
+import org.apache.flink.cdc.common.pipeline.SchemaChangeBehavior;
+import org.apache.flink.cdc.composer.PipelineExecution;
+import org.apache.flink.cdc.composer.definition.PipelineDef;
+import org.apache.flink.cdc.composer.definition.SinkDef;
+import org.apache.flink.cdc.composer.definition.SourceDef;
+import org.apache.flink.cdc.composer.testsink.factory.FailingDataSinkFactory;
+import 
org.apache.flink.cdc.composer.testsource.factory.DistributedDataSourceFactory;
+import 
org.apache.flink.cdc.composer.testsource.source.DistributedSourceOptions;
+import org.apache.flink.configuration.RestartStrategyOptions;
+import org.apache.flink.runtime.testutils.MiniClusterResourceConfiguration;
+import org.apache.flink.test.junit5.MiniClusterExtension;
+
+import org.assertj.core.api.Assertions;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.Timeout;
+import org.junit.jupiter.api.extension.RegisterExtension;
+
+import java.time.Duration;
+import java.util.Collections;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.TimeUnit;
+
+import static 
org.apache.flink.configuration.CoreOptions.ALWAYS_PARENT_FIRST_LOADER_PATTERNS_ADDITIONAL;
+
+/** Integration tests for preserving failures during distributed schema 
evolution. */
+@Timeout(value = 120, unit = TimeUnit.SECONDS)
+class DistributedSchemaEvolutionFailurePreservationITCase {
+
+    private static final Duration EVENT_TIMEOUT = Duration.ofSeconds(30);
+
+    private static final org.apache.flink.configuration.Configuration 
MINI_CLUSTER_CONFIG =
+            new org.apache.flink.configuration.Configuration();
+
+    static {
+        MINI_CLUSTER_CONFIG.set(
+                ALWAYS_PARENT_FIRST_LOADER_PATTERNS_ADDITIONAL,
+                Collections.singletonList("org.apache.flink.cdc"));
+        MINI_CLUSTER_CONFIG.set(RestartStrategyOptions.RESTART_STRATEGY, 
"fixed-delay");
+        
MINI_CLUSTER_CONFIG.set(RestartStrategyOptions.RESTART_STRATEGY_FIXED_DELAY_ATTEMPTS,
 1);
+        MINI_CLUSTER_CONFIG.set(
+                RestartStrategyOptions.RESTART_STRATEGY_FIXED_DELAY_DELAY, 
Duration.ZERO);
+    }
+
+    @RegisterExtension
+    static final MiniClusterExtension MINI_CLUSTER_RESOURCE =
+            new MiniClusterExtension(
+                    new MiniClusterResourceConfiguration.Builder()
+                            .setNumberTaskManagers(1)
+                            .setNumberSlotsPerTaskManager(2)
+                            .setConfiguration(MINI_CLUSTER_CONFIG)
+                            .build());
+
+    @BeforeEach
+    void setup() {
+        FailingDataSinkFactory.reset();
+    }
+
+    @AfterEach
+    void cleanup() {
+        FailingDataSinkFactory.releaseMetadataApplier();
+    }
+
+    @Test
+    void testPreserveSinkFailureDuringDistributedSchemaEvolution() throws 
Exception {
+        PipelineExecution execution = createPipelineExecution();
+        CompletableFuture<Throwable> executionFailure =
+                CompletableFuture.supplyAsync(
+                        () -> {
+                            try {
+                                execution.execute();
+                                throw new AssertionError("Pipeline execution 
should fail.");
+                            } catch (Throwable t) {
+                                return t;
+                            }
+                        });
+
+        try {
+            
Assertions.assertThat(FailingDataSinkFactory.awaitMetadataApplying(EVENT_TIMEOUT))
+                    .as("Metadata applier should block while the coordinator 
is EVOLVING.")
+                    .isTrue();
+            
Assertions.assertThat(FailingDataSinkFactory.awaitSinkFailure(EVENT_TIMEOUT))
+                    .as("A sink subtask should fail while schema evolution is 
in progress.")
+                    .isTrue();
+
+            Throwable failure =
+                    executionFailure.get(EVENT_TIMEOUT.toMillis(), 
TimeUnit.MILLISECONDS);
+            Assertions.assertThat(failure)
+                    .as("The original sink failure should remain the root 
cause.")
+                    
.hasRootCauseMessage(FailingDataSinkFactory.ORIGINAL_FAILURE_MESSAGE);
+            Assertions.assertThat(getRootCause(failure).getSuppressed())
+                    .as(
+                            "The schema evolution state error should only be 
attached as a suppressed exception.")
+                    .anySatisfy(
+                            suppressed ->
+                                    Assertions.assertThat(suppressed)
+                                            
.isInstanceOf(IllegalStateException.class)
+                                            .hasMessage("Unexpected evolving 
status: EVOLVING"));
+        } finally {
+            FailingDataSinkFactory.releaseMetadataApplier();
+            executionFailure.cancel(true);
+        }
+    }
+
+    private static PipelineExecution createPipelineExecution() {
+        Configuration sourceConfig = new Configuration();
+        sourceConfig.set(DistributedSourceOptions.TABLE_COUNT, 1);
+        sourceConfig.set(DistributedSourceOptions.DISTRIBUTED_TABLES, true);
+        SourceDef sourceDef =
+                new SourceDef(
+                        DistributedDataSourceFactory.IDENTIFIER,
+                        "Distributed Source",
+                        sourceConfig);
+
+        SinkDef sinkDef =
+                new SinkDef(FailingDataSinkFactory.IDENTIFIER, "Failing Sink", 
new Configuration());
+
+        Configuration pipelineConfig = new Configuration();
+        pipelineConfig.set(PipelineOptions.PIPELINE_PARALLELISM, 1);
+        pipelineConfig.set(
+                PipelineOptions.PIPELINE_SCHEMA_CHANGE_BEHAVIOR, 
SchemaChangeBehavior.LENIENT);
+        return FlinkPipelineComposer.ofMiniCluster()
+                .compose(
+                        new PipelineDef(
+                                sourceDef,
+                                sinkDef,
+                                Collections.emptyList(),
+                                Collections.emptyList(),
+                                Collections.emptyList(),
+                                pipelineConfig));
+    }
+
+    private static Throwable getRootCause(Throwable throwable) {
+        Throwable rootCause = throwable;
+        while (rootCause.getCause() != null) {
+            rootCause = rootCause.getCause();
+        }
+        return rootCause;
+    }
+}
diff --git 
a/flink-cdc-composer/src/test/java/org/apache/flink/cdc/composer/testsink/factory/FailingDataSinkFactory.java
 
b/flink-cdc-composer/src/test/java/org/apache/flink/cdc/composer/testsink/factory/FailingDataSinkFactory.java
new file mode 100644
index 000000000..c89f7ddbc
--- /dev/null
+++ 
b/flink-cdc-composer/src/test/java/org/apache/flink/cdc/composer/testsink/factory/FailingDataSinkFactory.java
@@ -0,0 +1,224 @@
+/*
+ * 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.cdc.composer.testsink.factory;
+
+import org.apache.flink.api.connector.sink2.Sink;
+import org.apache.flink.api.connector.sink2.SinkWriter;
+import org.apache.flink.cdc.common.configuration.ConfigOption;
+import org.apache.flink.cdc.common.event.Event;
+import org.apache.flink.cdc.common.event.SchemaChangeEvent;
+import org.apache.flink.cdc.common.exceptions.SchemaEvolveException;
+import org.apache.flink.cdc.common.factories.DataSinkFactory;
+import org.apache.flink.cdc.common.sink.DataSink;
+import org.apache.flink.cdc.common.sink.EventSinkProvider;
+import org.apache.flink.cdc.common.sink.FlinkSinkProvider;
+import org.apache.flink.cdc.common.sink.MetadataApplier;
+import org.apache.flink.runtime.execution.Environment;
+import org.apache.flink.streaming.api.operators.StreamingRuntimeContext;
+
+import java.lang.reflect.Field;
+import java.time.Duration;
+import java.util.Collections;
+import java.util.Set;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+/** A test-only sink that fails one sink subtask while schema evolution is in 
progress. */
+public class FailingDataSinkFactory implements DataSinkFactory {
+
+    public static final String IDENTIFIER = "schema-evolution-failing-sink";
+    public static final String ORIGINAL_FAILURE_MESSAGE = "Intentional sink 
subtask failure";
+
+    private static volatile CountDownLatch metadataApplying = new 
CountDownLatch(1);
+    private static volatile CountDownLatch releaseMetadataApplier = new 
CountDownLatch(1);
+    private static volatile CountDownLatch sinkFailureTriggered = new 
CountDownLatch(1);
+    private static final AtomicBoolean failOnce = new AtomicBoolean();
+
+    public static void reset() {
+        metadataApplying = new CountDownLatch(1);
+        releaseMetadataApplier = new CountDownLatch(1);
+        sinkFailureTriggered = new CountDownLatch(1);
+        failOnce.set(false);
+    }
+
+    public static boolean awaitMetadataApplying(Duration timeout) throws 
InterruptedException {
+        return metadataApplying.await(timeout.toMillis(), 
TimeUnit.MILLISECONDS);
+    }
+
+    public static boolean awaitSinkFailure(Duration timeout) throws 
InterruptedException {
+        return sinkFailureTriggered.await(timeout.toMillis(), 
TimeUnit.MILLISECONDS);
+    }
+
+    public static void releaseMetadataApplier() {
+        releaseMetadataApplier.countDown();
+    }
+
+    @Override
+    public DataSink createDataSink(Context context) {
+        return new FailingDataSink();
+    }
+
+    @Override
+    public String identifier() {
+        return IDENTIFIER;
+    }
+
+    @Override
+    public Set<ConfigOption<?>> requiredOptions() {
+        return Collections.emptySet();
+    }
+
+    @Override
+    public Set<ConfigOption<?>> optionalOptions() {
+        return Collections.emptySet();
+    }
+
+    private static class FailingDataSink implements DataSink {
+
+        @Override
+        public EventSinkProvider getEventSinkProvider() {
+            return FlinkSinkProvider.of(new FailingSink());
+        }
+
+        @Override
+        public MetadataApplier getMetadataApplier() {
+            return new BlockingMetadataApplier();
+        }
+    }
+
+    private static class FailingSink implements Sink<Event> {
+
+        @Override
+        public SinkWriter<Event> createWriter(InitContext context) {
+            return new FailingSinkWriter(getTaskEnvironment(context), 
context.getSubtaskId());
+        }
+
+        private static Environment getTaskEnvironment(InitContext context) {
+            // Sink.InitContext does not expose the task environment needed 
for an out-of-band
+            // failure.
+            try {
+                Class<?> initContextBase =
+                        Class.forName(
+                                
"org.apache.flink.streaming.runtime.operators.sink.InitContextBase");
+                Object initContext = unwrapInitContext(context, 
initContextBase);
+                Field runtimeContextField = 
initContextBase.getDeclaredField("runtimeContext");
+                runtimeContextField.setAccessible(true);
+                Object runtimeContext = runtimeContextField.get(initContext);
+                if (!(runtimeContext instanceof StreamingRuntimeContext)) {
+                    throw new IllegalStateException(
+                            "Unexpected runtime context: "
+                                    + (runtimeContext == null
+                                            ? "null"
+                                            : 
runtimeContext.getClass().getName()));
+                }
+
+                Field taskEnvironment =
+                        
StreamingRuntimeContext.class.getDeclaredField("taskEnvironment");
+                taskEnvironment.setAccessible(true);
+                return (Environment) taskEnvironment.get(runtimeContext);
+            } catch (ReflectiveOperationException e) {
+                throw new IllegalStateException("Cannot access the task 
environment.", e);
+            }
+        }
+
+        private static Object unwrapInitContext(Object context, Class<?> 
initContextBase)
+                throws ReflectiveOperationException {
+            Object current = context;
+            while (!initContextBase.isInstance(current)) {
+                String className = current.getClass().getName();
+                String wrappedField;
+                if (className.endsWith("Sink$InitContextWrapper")) {
+                    wrappedField = "wrapped";
+                } else if (className.equals(
+                        
"org.apache.flink.api.connector.sink2.InitContextAdapter")) {
+                    wrappedField = "context";
+                } else {
+                    throw new NoSuchFieldException("Cannot unwrap init context 
" + className);
+                }
+                Field wrapped = 
current.getClass().getDeclaredField(wrappedField);
+                wrapped.setAccessible(true);
+                current = wrapped.get(current);
+            }
+            return current;
+        }
+    }
+
+    private static class FailingSinkWriter implements SinkWriter<Event> {
+
+        private final Thread failureThread;
+
+        private FailingSinkWriter(Environment taskEnvironment, int subtaskId) {
+            CountDownLatch metadataApplyingLatch = metadataApplying;
+            failureThread =
+                    new Thread(
+                            () ->
+                                    failTaskAfterSchemaEvolutionStarts(
+                                            taskEnvironment, subtaskId, 
metadataApplyingLatch),
+                            "schema-evolution-sink-failure");
+            failureThread.setDaemon(true);
+            failureThread.start();
+        }
+
+        private static void failTaskAfterSchemaEvolutionStarts(
+                Environment taskEnvironment, int subtaskId, CountDownLatch 
metadataApplyingLatch) {
+            if (subtaskId != 0 || failOnce.get()) {
+                return;
+            }
+
+            try {
+                metadataApplyingLatch.await();
+                if (failOnce.compareAndSet(false, true)) {
+                    sinkFailureTriggered.countDown();
+                    // Fail the task externally so the sink remains chained 
with the schema
+                    // operator, independent of the sink's blocked mailbox 
thread.
+                    taskEnvironment.failExternally(new 
RuntimeException(ORIGINAL_FAILURE_MESSAGE));
+                }
+            } catch (InterruptedException e) {
+                Thread.currentThread().interrupt();
+            }
+        }
+
+        @Override
+        public void write(Event element, Context context) {}
+
+        @Override
+        public void flush(boolean endOfInput) {}
+
+        @Override
+        public void close() {
+            failureThread.interrupt();
+        }
+    }
+
+    private static class BlockingMetadataApplier implements MetadataApplier {
+
+        @Override
+        public void applySchemaChange(SchemaChangeEvent schemaChangeEvent)
+                throws SchemaEvolveException {
+            metadataApplying.countDown();
+            try {
+                releaseMetadataApplier.await();
+            } catch (InterruptedException e) {
+                Thread.currentThread().interrupt();
+                throw new SchemaEvolveException(
+                        schemaChangeEvent, "Blocking metadata applier was 
interrupted.", e);
+            }
+        }
+    }
+}
diff --git 
a/flink-cdc-composer/src/test/resources/META-INF/services/org.apache.flink.cdc.common.factories.Factory
 
b/flink-cdc-composer/src/test/resources/META-INF/services/org.apache.flink.cdc.common.factories.Factory
index f5be4c841..03c475c82 100644
--- 
a/flink-cdc-composer/src/test/resources/META-INF/services/org.apache.flink.cdc.common.factories.Factory
+++ 
b/flink-cdc-composer/src/test/resources/META-INF/services/org.apache.flink.cdc.common.factories.Factory
@@ -19,5 +19,6 @@ org.apache.flink.cdc.composer.utils.factory.DataSourceFactory2
 org.apache.flink.cdc.composer.testsource.factory.DistributedDataSourceFactory
 org.apache.flink.cdc.composer.testsource.recovery.RecoveryDataSourceFactory
 org.apache.flink.cdc.composer.testsource.recovery.RecoveryDataSinkFactory
+org.apache.flink.cdc.composer.testsink.factory.FailingDataSinkFactory
 
org.apache.flink.cdc.composer.flink.FlinkPipelineComposerTest$TestDataSinkFactory
 
org.apache.flink.cdc.composer.flink.FlinkPipelineComposerTest$TestDataSourceFactory
diff --git 
a/flink-cdc-runtime/src/main/java/org/apache/flink/cdc/runtime/operators/schema/common/SchemaRegistry.java
 
b/flink-cdc-runtime/src/main/java/org/apache/flink/cdc/runtime/operators/schema/common/SchemaRegistry.java
index 5dc1ff99e..03ebc68c9 100644
--- 
a/flink-cdc-runtime/src/main/java/org/apache/flink/cdc/runtime/operators/schema/common/SchemaRegistry.java
+++ 
b/flink-cdc-runtime/src/main/java/org/apache/flink/cdc/runtime/operators/schema/common/SchemaRegistry.java
@@ -315,7 +315,7 @@ public abstract class SchemaRegistry implements 
OperatorCoordinator, Coordinatio
     }
 
     @Override
-    public final void executionAttemptFailed(
+    public void executionAttemptFailed(
             int subTaskId, int attemptNumber, @Nullable Throwable reason) {
         if (reason != null) {
             failedReasons.put(subTaskId, reason);
diff --git 
a/flink-cdc-runtime/src/main/java/org/apache/flink/cdc/runtime/operators/schema/distributed/SchemaCoordinator.java
 
b/flink-cdc-runtime/src/main/java/org/apache/flink/cdc/runtime/operators/schema/distributed/SchemaCoordinator.java
index 286f89bfe..18ae29b11 100755
--- 
a/flink-cdc-runtime/src/main/java/org/apache/flink/cdc/runtime/operators/schema/distributed/SchemaCoordinator.java
+++ 
b/flink-cdc-runtime/src/main/java/org/apache/flink/cdc/runtime/operators/schema/distributed/SchemaCoordinator.java
@@ -39,6 +39,7 @@ import 
org.apache.flink.cdc.runtime.operators.schema.distributed.event.SchemaCha
 import org.apache.flink.runtime.operators.coordination.CoordinationRequest;
 import org.apache.flink.runtime.operators.coordination.CoordinationResponse;
 import org.apache.flink.runtime.operators.coordination.OperatorCoordinator;
+import org.apache.flink.util.ExceptionUtils;
 import org.apache.flink.util.FlinkRuntimeException;
 
 import 
org.apache.flink.shaded.guava31.com.google.common.collect.HashBasedTable;
@@ -49,6 +50,8 @@ import 
org.apache.flink.shaded.guava31.com.google.common.collect.Table;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
+import javax.annotation.Nullable;
+
 import java.io.ByteArrayInputStream;
 import java.io.ByteArrayOutputStream;
 import java.io.DataInputStream;
@@ -79,6 +82,13 @@ public class SchemaCoordinator extends SchemaRegistry {
     /** Atomic finite state machine to track global schema evolving state. */
     private transient AtomicReference<RequestStatus> evolvingStatus;
 
+    /**
+     * Preserves the first failure observed while a schema evolution is in 
progress, so that a later
+     * attempt's failure won't replace the original cause that left the 
coordinator in an invalid
+     * state.
+     */
+    private transient AtomicReference<Throwable> firstSchemaEvolutionFailure;
+
     /** Request futures from pending schema mappers. */
     private transient Map<
                     Integer, Tuple2<SchemaChangeRequest, 
CompletableFuture<CoordinationResponse>>>
@@ -139,6 +149,7 @@ public class SchemaCoordinator extends SchemaRegistry {
                                             "Schema coordinator request was 
cancelled by checkpoint reset.")));
         }
         this.evolvingStatus = new AtomicReference<>(RequestStatus.IDLE);
+        this.firstSchemaEvolutionFailure = new AtomicReference<>();
         this.pendingRequests = new ConcurrentHashMap<>();
         this.flushedSinkWriters = ConcurrentHashMap.newKeySet();
         this.upstreamSchemaTable = HashBasedTable.create();
@@ -174,6 +185,19 @@ public class SchemaCoordinator extends SchemaRegistry {
         }
     }
 
+    @Override
+    public void executionAttemptFailed(
+            int subTaskId, int attemptNumber, @Nullable Throwable reason) {
+        if (reason != null) {
+            // Preserve the first failure per subtask to avoid a later attempt 
overwriting the
+            // original cause that left the coordinator in an invalid state.
+            failedReasons.putIfAbsent(subTaskId, reason);
+            if (hasOngoingSchemaEvolution()) {
+                firstSchemaEvolutionFailure.compareAndSet(null, reason);
+            }
+        }
+    }
+
     // --------------------------
     // Checkpoint related methods
     // --------------------------
@@ -283,10 +307,12 @@ public class SchemaCoordinator extends SchemaRegistry {
         pendingRequests.put(request.getSinkSubTaskId(), Tuple2.of(request, 
responseFuture));
 
         if (pendingRequests.size() == 1) {
-            Preconditions.checkState(
+            safeCheckState(
                     evolvingStatus.compareAndSet(
                             RequestStatus.IDLE, 
RequestStatus.WAITING_FOR_FLUSH),
-                    "Unexpected evolving status: " + evolvingStatus.get());
+                    request.getSinkSubTaskId());
+            failedReasons.clear();
+            firstSchemaEvolutionFailure.set(null);
             LOG.info(
                     "Received the very-first schema change request {}. 
Switching from IDLE to WAITING_FOR_FLUSH.",
                     request);
@@ -294,10 +320,10 @@ public class SchemaCoordinator extends SchemaRegistry {
 
         // No else if, since currentParallelism might be == 1
         if (pendingRequests.size() == currentParallelism) {
-            Preconditions.checkState(
+            safeCheckState(
                     evolvingStatus.compareAndSet(
                             RequestStatus.WAITING_FOR_FLUSH, 
RequestStatus.EVOLVING),
-                    "Unexpected evolving status: " + evolvingStatus.get());
+                    request.getSinkSubTaskId());
             LOG.info(
                     "Received the last required schema change request {}. 
Switching from WAITING_FOR_FLUSH to EVOLVING.",
                     request);
@@ -318,6 +344,31 @@ public class SchemaCoordinator extends SchemaRegistry {
         }
     }
 
+    private void safeCheckState(boolean condition, int sinkSubTaskId) throws 
Exception {
+        if (condition) {
+            return;
+        }
+
+        IllegalStateException stateError =
+                new IllegalStateException("Unexpected evolving status: " + 
evolvingStatus.get());
+        Throwable originalFailure = firstSchemaEvolutionFailure.get();
+        if (originalFailure == null) {
+            originalFailure = failedReasons.get(sinkSubTaskId);
+        }
+        if (originalFailure == null && !failedReasons.isEmpty()) {
+            originalFailure = failedReasons.values().iterator().next();
+        }
+        if (originalFailure == null) {
+            throw stateError;
+        }
+
+        originalFailure.addSuppressed(stateError);
+        LOG.warn(
+                "Preserving the original subtask failure instead of reporting 
a secondary schema evolution state error.",
+                originalFailure);
+        ExceptionUtils.rethrowException(originalFailure);
+    }
+
     /**
      * Tries to apply schema change event {@code schemaChangeEvent} to the 
combination of {@code
      * tableId} and {@code sourcePartition}. Returns {@code true} if schema 
got changed, or {@code
@@ -513,4 +564,16 @@ public class SchemaCoordinator extends SchemaRegistry {
     public void emplaceOriginalSchema(TableId tableId, Integer subTaskId, 
Schema schema) {
         upstreamSchemaTable.put(tableId, subTaskId, schema);
     }
+
+    private boolean hasOngoingSchemaEvolution() {
+        return evolvingStatus != null
+                && (evolvingStatus.get() != RequestStatus.IDLE
+                        || (pendingRequests != null && 
!pendingRequests.isEmpty())
+                        || (flushedSinkWriters != null && 
!flushedSinkWriters.isEmpty()));
+    }
+
+    @VisibleForTesting
+    boolean isSchemaEvolutionInProgress() {
+        return evolvingStatus != null && evolvingStatus.get() == 
RequestStatus.EVOLVING;
+    }
 }
diff --git 
a/flink-cdc-runtime/src/test/java/org/apache/flink/cdc/runtime/operators/schema/distributed/SchemaCoordinatorTest.java
 
b/flink-cdc-runtime/src/test/java/org/apache/flink/cdc/runtime/operators/schema/distributed/SchemaCoordinatorTest.java
index 35d92e176..63ecd3f90 100644
--- 
a/flink-cdc-runtime/src/test/java/org/apache/flink/cdc/runtime/operators/schema/distributed/SchemaCoordinatorTest.java
+++ 
b/flink-cdc-runtime/src/test/java/org/apache/flink/cdc/runtime/operators/schema/distributed/SchemaCoordinatorTest.java
@@ -35,10 +35,13 @@ import 
org.apache.flink.cdc.runtime.operators.schema.common.event.GetEvolvedSche
 import 
org.apache.flink.cdc.runtime.operators.schema.distributed.event.SchemaChangeRequest;
 import 
org.apache.flink.cdc.runtime.operators.schema.distributed.event.SchemaChangeResponse;
 import 
org.apache.flink.cdc.runtime.testutils.operators.MockedOperatorCoordinatorContext;
+import org.apache.flink.cdc.runtime.testutils.schema.CollectingMetadataApplier;
 import org.apache.flink.runtime.jobgraph.OperatorID;
 import org.apache.flink.runtime.operators.coordination.CoordinationResponse;
+import 
org.apache.flink.runtime.operators.coordination.MockOperatorCoordinatorContext;
 import org.apache.flink.runtime.operators.coordination.OperatorCoordinator;
 
+import org.assertj.core.api.Assertions;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.params.ParameterizedTest;
 import org.junit.jupiter.params.provider.ValueSource;
@@ -61,6 +64,7 @@ import java.util.concurrent.TimeoutException;
 import java.util.concurrent.atomic.AtomicBoolean;
 import java.util.concurrent.atomic.AtomicInteger;
 import java.util.concurrent.atomic.AtomicReference;
+import java.util.function.BooleanSupplier;
 
 import static 
org.apache.flink.cdc.runtime.operators.schema.common.CoordinationResponseUtils.unwrap;
 import static org.assertj.core.api.Assertions.assertThat;
@@ -284,6 +288,196 @@ class SchemaCoordinatorTest {
         }
     }
 
+    @Test
+    void testIgnoreSubtaskResetWithoutOngoingSchemaEvolution() throws 
Exception {
+        MockedOperatorCoordinatorContext context = createContext();
+        SchemaCoordinator coordinator = createCoordinator(context);
+        coordinator.start();
+
+        try {
+            coordinator.executionAttemptFailed(
+                    0, 0, new RuntimeException("failure outside schema 
evolution"));
+            coordinator.subtaskReset(0, 123L);
+
+            Assertions.assertThat(context.isJobFailed()).isFalse();
+        } finally {
+            coordinator.close();
+        }
+    }
+
+    @Test
+    void testDoNotEscalateSubtaskResetWhenFailureReasonIsMissing() throws 
Exception {
+        MockedOperatorCoordinatorContext context = createContext();
+        SchemaCoordinator coordinator = createCoordinator(context);
+        coordinator.start();
+
+        try {
+            CompletableFuture<CoordinationResponse> requestFuture =
+                    
coordinator.handleCoordinationRequest(createSchemaChangeRequest());
+            waitUntil(coordinator::isSchemaEvolutionInProgress);
+
+            coordinator.subtaskReset(0, 123L);
+            coordinator.subtaskReset(0, 124L);
+
+            Assertions.assertThat(context.isJobFailed()).isFalse();
+            Assertions.assertThat(requestFuture).isNotDone();
+        } finally {
+            coordinator.close();
+        }
+    }
+
+    @Test
+    void testPreserveFailureFromAnotherSubtaskWithoutEscalatingReset() throws 
Exception {
+        MockOperatorCoordinatorContext context =
+                new MockOperatorCoordinatorContext(
+                        new OperatorID(), 2, 
Thread.currentThread().getContextClassLoader());
+        SchemaCoordinator coordinator = createCoordinator(context);
+        coordinator.start();
+
+        try {
+            CompletableFuture<CoordinationResponse> requestFromSubtaskZero =
+                    
coordinator.handleCoordinationRequest(createSchemaChangeRequest(0));
+            
coordinator.handleCoordinationRequest(createSchemaChangeRequest(1));
+            waitUntil(coordinator::isSchemaEvolutionInProgress);
+
+            RuntimeException originalFailure = new RuntimeException("other 
subtask failure");
+            coordinator.executionAttemptFailed(1, 0, originalFailure);
+            coordinator.subtaskReset(1, 123L);
+
+            Assertions.assertThat(context.isJobFailed()).isFalse();
+            coordinator.executionAttemptReady(1, 1, null);
+            CompletableFuture<CoordinationResponse> retriedRequest =
+                    
coordinator.handleCoordinationRequest(createSchemaChangeRequest(0));
+
+            waitUntil(context::isJobFailed);
+            
Assertions.assertThat(context.getJobFailureReason()).isSameAs(originalFailure);
+            Assertions.assertThatThrownBy(() -> retriedRequest.get(5, 
TimeUnit.SECONDS))
+                    .hasCause(originalFailure);
+            assertUnexpectedEvolvingStatusSuppressed(originalFailure);
+            Assertions.assertThat(requestFromSubtaskZero).isNotDone();
+        } finally {
+            coordinator.close();
+        }
+    }
+
+    @Test
+    void testPreserveOriginalFailureDuringPartialFailover() throws Exception {
+        MockedOperatorCoordinatorContext context = createContext();
+        SchemaCoordinator coordinator = createCoordinator(context);
+        coordinator.start();
+
+        try {
+            SchemaChangeRequest request = createSchemaChangeRequest();
+
+            CompletableFuture<CoordinationResponse> firstRequest =
+                    coordinator.handleCoordinationRequest(request);
+            waitUntil(coordinator::isSchemaEvolutionInProgress);
+
+            RuntimeException originalFailure = new RuntimeException("original 
task failure");
+            coordinator.executionAttemptFailed(0, 0, originalFailure);
+            coordinator.subtaskReset(0, 123L);
+
+            Assertions.assertThat(context.isJobFailed()).isFalse();
+            coordinator.executionAttemptReady(0, 1, null);
+            CompletableFuture<CoordinationResponse> retriedRequest =
+                    coordinator.handleCoordinationRequest(request);
+
+            waitUntil(context::isJobFailed);
+            
Assertions.assertThat(context.getFailureCause()).isSameAs(originalFailure);
+            Assertions.assertThatThrownBy(() -> retriedRequest.get(5, 
TimeUnit.SECONDS))
+                    .hasCause(originalFailure);
+            assertUnexpectedEvolvingStatusSuppressed(originalFailure);
+            Assertions.assertThat(firstRequest).isNotDone();
+        } finally {
+            coordinator.close();
+        }
+    }
+
+    @Test
+    void testPreserveFirstFailureAcrossMultipleAttempts() throws Exception {
+        MockedOperatorCoordinatorContext context = createContext();
+        SchemaCoordinator coordinator = createCoordinator(context);
+        coordinator.start();
+
+        try {
+            SchemaChangeRequest request = createSchemaChangeRequest();
+
+            CompletableFuture<CoordinationResponse> firstRequest =
+                    coordinator.handleCoordinationRequest(request);
+            waitUntil(coordinator::isSchemaEvolutionInProgress);
+
+            RuntimeException firstFailure = new RuntimeException("first 
attempt failure");
+            coordinator.executionAttemptFailed(0, 0, firstFailure);
+            coordinator.subtaskReset(0, 123L);
+
+            RuntimeException secondFailure = new RuntimeException("second 
attempt failure");
+            coordinator.executionAttemptFailed(0, 1, secondFailure);
+            coordinator.subtaskReset(0, 124L);
+
+            coordinator.executionAttemptReady(0, 2, null);
+            CompletableFuture<CoordinationResponse> retriedRequest =
+                    coordinator.handleCoordinationRequest(request);
+
+            waitUntil(context::isJobFailed);
+            
Assertions.assertThat(context.getFailureCause()).isSameAs(firstFailure);
+            Assertions.assertThatThrownBy(() -> retriedRequest.get(5, 
TimeUnit.SECONDS))
+                    .hasCause(firstFailure);
+            assertUnexpectedEvolvingStatusSuppressed(firstFailure);
+            Assertions.assertThat(firstRequest).isNotDone();
+        } finally {
+            coordinator.close();
+        }
+    }
+
+    @Test
+    void testRetainStateErrorWhenOriginalFailureIsMissing() throws Exception {
+        MockedOperatorCoordinatorContext context = createContext();
+        SchemaCoordinator coordinator = createCoordinator(context);
+        coordinator.start();
+
+        try {
+            SchemaChangeRequest request = createSchemaChangeRequest();
+            coordinator.handleCoordinationRequest(request);
+            waitUntil(coordinator::isSchemaEvolutionInProgress);
+
+            coordinator.handleCoordinationRequest(request);
+
+            waitUntil(context::isJobFailed);
+            Assertions.assertThat(context.getFailureCause())
+                    .isInstanceOf(IllegalStateException.class)
+                    .hasMessage("Unexpected evolving status: EVOLVING");
+        } finally {
+            coordinator.close();
+        }
+    }
+
+    @Test
+    void testClearStaleFailureWhenStartingNewSchemaEvolution() throws 
Exception {
+        MockedOperatorCoordinatorContext context = createContext();
+        SchemaCoordinator coordinator = createCoordinator(context);
+        coordinator.start();
+
+        try {
+            RuntimeException staleFailure = new RuntimeException("stale task 
failure");
+            coordinator.executionAttemptFailed(0, 0, staleFailure);
+
+            SchemaChangeRequest request = createSchemaChangeRequest();
+            coordinator.handleCoordinationRequest(request);
+            waitUntil(coordinator::isSchemaEvolutionInProgress);
+
+            coordinator.handleCoordinationRequest(request);
+
+            waitUntil(context::isJobFailed);
+            Assertions.assertThat(context.getFailureCause())
+                    .isInstanceOf(IllegalStateException.class)
+                    .hasMessage("Unexpected evolving status: EVOLVING")
+                    .isNotSameAs(staleFailure);
+            Assertions.assertThat(staleFailure.getSuppressed()).isEmpty();
+        } finally {
+            coordinator.close();
+        }
+    }
+
     private static SchemaCoordinator createCoordinator(
             FailOnceMetadataApplier metadataApplier, Duration rpcTimeout) {
         return createCoordinator(
@@ -327,6 +521,54 @@ class SchemaCoordinatorTest {
         return response;
     }
 
+    private static MockedOperatorCoordinatorContext createContext() {
+        return new MockedOperatorCoordinatorContext(
+                new OperatorID(), 
Thread.currentThread().getContextClassLoader());
+    }
+
+    private static SchemaCoordinator 
createCoordinator(OperatorCoordinator.Context context) {
+        return new SchemaCoordinator(
+                "SchemaCoordinator",
+                context,
+                Executors.newSingleThreadExecutor(),
+                new CollectingMetadataApplier(Duration.ZERO),
+                Collections.emptyList(),
+                RouteMode.ALL_MATCH,
+                SchemaChangeBehavior.LENIENT,
+                Duration.ofMinutes(1));
+    }
+
+    private static SchemaChangeRequest createSchemaChangeRequest() {
+        return createSchemaChangeRequest(0);
+    }
+
+    private static SchemaChangeRequest createSchemaChangeRequest(int 
sinkSubTaskId) {
+        TableId tableId = TableId.tableId("inventory", "products");
+        Schema schema =
+                Schema.newBuilder()
+                        .physicalColumn("id", DataTypes.INT().notNull())
+                        .primaryKey("id")
+                        .build();
+        return new SchemaChangeRequest(0, sinkSubTaskId, new 
CreateTableEvent(tableId, schema));
+    }
+
+    private static void assertUnexpectedEvolvingStatusSuppressed(Throwable 
originalFailure) {
+        Assertions.assertThat(originalFailure.getSuppressed()).hasSize(1);
+        Assertions.assertThat(originalFailure.getSuppressed()[0])
+                .isInstanceOf(IllegalStateException.class)
+                .hasMessage("Unexpected evolving status: EVOLVING");
+    }
+
+    private static void waitUntil(BooleanSupplier condition) throws Exception {
+        long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5);
+        while (!condition.getAsBoolean()) {
+            if (System.nanoTime() > deadline) {
+                throw new AssertionError("Condition was not met within the 
timeout.");
+            }
+            Thread.sleep(10);
+        }
+    }
+
     private static final class FailOnceMetadataApplier implements 
MetadataApplier {
         private final AtomicBoolean failNextAdd;
         private final CountDownLatch failedAdd = new CountDownLatch(1);

Reply via email to