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 e9e77b226b [FLINK-40656][runtime] Reset schema coordinator state on 
checkpoint restore (#4541)
e9e77b226b is described below

commit e9e77b226bf2db6d75c9f0dc2302fa3e80845955
Author: Xiaobing Fang <[email protected]>
AuthorDate: Wed Sep 16 15:55:40 2026 +0800

    [FLINK-40656][runtime] Reset schema coordinator state on checkpoint restore 
(#4541)
---
 .../FlinkSchemaCoordinatorRecoveryITCase.java      | 152 ++++++
 .../testsource/recovery/RecoveryDataSink.java      | 107 +++++
 .../recovery/RecoveryDataSinkFactory.java          |  52 ++
 .../testsource/recovery/RecoveryDataSource.java    |  48 ++
 .../recovery/RecoveryDataSourceFactory.java        |  57 +++
 .../recovery/RecoverySourceFunction.java           | 242 ++++++++++
 .../org.apache.flink.cdc.common.factories.Factory  |   4 +-
 .../flink-cdc-pipeline-e2e-tests/pom.xml           |  15 +-
 .../tests/SchemaCoordinatorRecoveryE2eITCase.java  | 260 ++++++++++
 .../tests/recovery/RecoveryE2eFixtureFactory.java  | 522 +++++++++++++++++++++
 .../tests/utils/PipelineTestEnvironment.java       |   8 +-
 .../operators/schema/common/SchemaRegistry.java    | 110 ++++-
 .../schema/distributed/SchemaCoordinator.java      |  54 ++-
 .../schema/regular/SchemaCoordinator.java          |  32 +-
 .../schema/distributed/SchemaCoordinatorTest.java  | 426 +++++++++++++++++
 .../schema/regular/SchemaCoordinatorTest.java      | 137 ++++++
 16 files changed, 2182 insertions(+), 44 deletions(-)

diff --git 
a/flink-cdc-composer/src/test/java/org/apache/flink/cdc/composer/flink/FlinkSchemaCoordinatorRecoveryITCase.java
 
b/flink-cdc-composer/src/test/java/org/apache/flink/cdc/composer/flink/FlinkSchemaCoordinatorRecoveryITCase.java
new file mode 100644
index 0000000000..5c5cb7754f
--- /dev/null
+++ 
b/flink-cdc-composer/src/test/java/org/apache/flink/cdc/composer/flink/FlinkSchemaCoordinatorRecoveryITCase.java
@@ -0,0 +1,152 @@
+/*
+ * 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.api.common.JobExecutionResult;
+import org.apache.flink.cdc.common.configuration.Configuration;
+import org.apache.flink.cdc.common.event.TableId;
+import org.apache.flink.cdc.common.pipeline.PipelineOptions;
+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.testsource.recovery.RecoveryDataSink;
+import 
org.apache.flink.cdc.composer.testsource.recovery.RecoveryDataSinkFactory;
+import 
org.apache.flink.cdc.composer.testsource.recovery.RecoveryDataSourceFactory;
+import 
org.apache.flink.cdc.composer.testsource.recovery.RecoverySourceFunction;
+import org.apache.flink.cdc.connectors.values.ValuesDatabase;
+import org.apache.flink.configuration.RestartStrategyOptions;
+import org.apache.flink.core.execution.JobClient;
+import org.apache.flink.runtime.testutils.MiniClusterResourceConfiguration;
+import org.apache.flink.test.junit5.MiniClusterExtension;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Timeout;
+import org.junit.jupiter.api.extension.RegisterExtension;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import java.time.Duration;
+import java.util.Collections;
+import java.util.concurrent.CancellationException;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.TimeUnit;
+
+import static 
org.apache.flink.configuration.CoreOptions.ALWAYS_PARENT_FIRST_LOADER_PATTERNS_ADDITIONAL;
+import static org.assertj.core.api.Assertions.assertThat;
+
+/** Tests regular and distributed schema coordinator recovery through a real 
MiniCluster. */
+@Timeout(value = 60, unit = TimeUnit.SECONDS)
+class FlinkSchemaCoordinatorRecoveryITCase {
+
+    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"));
+    }
+
+    @RegisterExtension
+    static final MiniClusterExtension MINI_CLUSTER_RESOURCE =
+            new MiniClusterExtension(
+                    new MiniClusterResourceConfiguration.Builder()
+                            .setNumberTaskManagers(1)
+                            .setNumberSlotsPerTaskManager(4)
+                            .setConfiguration(MINI_CLUSTER_CONFIG)
+                            .build());
+
+    @AfterEach
+    void cleanup() {
+        ValuesDatabase.clear();
+        RecoveryDataSink.reset();
+        RecoverySourceFunction.reset();
+    }
+
+    @ParameterizedTest
+    @ValueSource(booleans = {false, true})
+    void replaysFailedSchemaChangeAfterCheckpointAndContinues(boolean 
distributed)
+            throws Exception {
+        RecoveryDataSink.reset();
+        RecoverySourceFunction.reset();
+        ValuesDatabase.clear();
+
+        Configuration sourceConfig = new Configuration();
+        sourceConfig.set(RecoveryDataSourceFactory.PARALLEL_METADATA_SOURCE, 
distributed);
+        Configuration pipelineConfig = new Configuration();
+        pipelineConfig.set(PipelineOptions.PIPELINE_PARALLELISM, distributed ? 
2 : 1);
+        pipelineConfig.set(PipelineOptions.PIPELINE_NAME, 
"schema-coordinator-recovery");
+
+        FlinkPipelineComposer composer = FlinkPipelineComposer.ofMiniCluster();
+        composer.getEnv().enableCheckpointing(50L);
+        
composer.getEnv().getCheckpointConfig().setMinPauseBetweenCheckpoints(2_000L);
+        org.apache.flink.configuration.Configuration restartConfig =
+                new org.apache.flink.configuration.Configuration();
+        restartConfig.set(RestartStrategyOptions.RESTART_STRATEGY, 
"fixed-delay");
+        
restartConfig.set(RestartStrategyOptions.RESTART_STRATEGY_FIXED_DELAY_ATTEMPTS, 
1);
+        
restartConfig.set(RestartStrategyOptions.RESTART_STRATEGY_FIXED_DELAY_DELAY, 
Duration.ZERO);
+        composer.getEnv().configure(restartConfig);
+        composer.compose(
+                new PipelineDef(
+                        new SourceDef(
+                                RecoveryDataSourceFactory.IDENTIFIER,
+                                "Recovery test source",
+                                sourceConfig),
+                        new SinkDef(
+                                RecoveryDataSinkFactory.IDENTIFIER,
+                                "Recovery test sink",
+                                new Configuration()),
+                        Collections.emptyList(),
+                        Collections.emptyList(),
+                        Collections.emptyList(),
+                        pipelineConfig));
+
+        JobClient jobClient = 
composer.getEnv().executeAsync("schema-coordinator-recovery");
+        CompletableFuture<JobExecutionResult> executionResult = 
jobClient.getJobExecutionResult();
+        try {
+            executionResult.get();
+
+            TableId tableId = TableId.tableId("recovery", "schema", "orders");
+            assertThat(RecoveryDataSink.getFailures()).isEqualTo(1);
+            
assertThat(RecoverySourceFunction.getRunAttempts()).isGreaterThanOrEqualTo(2);
+            
assertThat(RecoverySourceFunction.getFirstColumnEmissions()).isEqualTo(2);
+            
assertThat(RecoveryDataSink.getFirstColumnApplications()).isEqualTo(2);
+            assertThat(ValuesDatabase.getTableSchema(tableId).getColumnNames())
+                    .containsExactly("id", "value", "extra_v1", "extra_v2");
+            assertThat(ValuesDatabase.getResults(tableId))
+                    .containsExactly(
+                            
"recovery.schema.orders:id=1;value=after;extra_v1=v1;extra_v2=v2");
+        } finally {
+            cancelAndWait(jobClient, executionResult);
+        }
+    }
+
+    private static void cancelAndWait(
+            JobClient jobClient, CompletableFuture<JobExecutionResult> 
executionResult)
+            throws Exception {
+        if (!executionResult.isDone()) {
+            jobClient.cancel().get(30, TimeUnit.SECONDS);
+            try {
+                executionResult.get(30, TimeUnit.SECONDS);
+            } catch (CancellationException | ExecutionException ignored) {
+                // A cancelled job completes the result future exceptionally.
+            }
+        }
+    }
+}
diff --git 
a/flink-cdc-composer/src/test/java/org/apache/flink/cdc/composer/testsource/recovery/RecoveryDataSink.java
 
b/flink-cdc-composer/src/test/java/org/apache/flink/cdc/composer/testsource/recovery/RecoveryDataSink.java
new file mode 100644
index 0000000000..e587e53c47
--- /dev/null
+++ 
b/flink-cdc-composer/src/test/java/org/apache/flink/cdc/composer/testsource/recovery/RecoveryDataSink.java
@@ -0,0 +1,107 @@
+/*
+ * 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.testsource.recovery;
+
+import org.apache.flink.cdc.common.event.AddColumnEvent;
+import org.apache.flink.cdc.common.event.SchemaChangeEvent;
+import org.apache.flink.cdc.common.event.SchemaChangeEventType;
+import org.apache.flink.cdc.common.sink.DataSink;
+import org.apache.flink.cdc.common.sink.EventSinkProvider;
+import org.apache.flink.cdc.common.sink.MetadataApplier;
+import org.apache.flink.cdc.connectors.values.ValuesDatabase;
+import org.apache.flink.cdc.connectors.values.sink.ValuesDataSink;
+
+import java.util.Set;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicInteger;
+
+/** A values sink whose first metadata application fails after applying the 
first added column. */
+public class RecoveryDataSink implements DataSink {
+
+    private static final AtomicBoolean FAILED = new AtomicBoolean();
+    private static final AtomicInteger FAILURES = new AtomicInteger();
+    private static final AtomicInteger FIRST_COLUMN_APPLICATIONS = new 
AtomicInteger();
+
+    public static void reset() {
+        FAILED.set(false);
+        FAILURES.set(0);
+        FIRST_COLUMN_APPLICATIONS.set(0);
+    }
+
+    public static int getFailures() {
+        return FAILURES.get();
+    }
+
+    public static int getFirstColumnApplications() {
+        return FIRST_COLUMN_APPLICATIONS.get();
+    }
+
+    @Override
+    public EventSinkProvider getEventSinkProvider() {
+        return new ValuesDataSink(true, false, 
ValuesDataSink.SinkApi.SINK_FUNCTION, false)
+                .getEventSinkProvider();
+    }
+
+    @Override
+    public MetadataApplier getMetadataApplier() {
+        return new FailingOnceMetadataApplier();
+    }
+
+    private static class FailingOnceMetadataApplier implements MetadataApplier 
{
+
+        private final ValuesDatabase.ValuesMetadataApplier delegate =
+                new ValuesDatabase.ValuesMetadataApplier(true);
+
+        @Override
+        public MetadataApplier setAcceptedSchemaEvolutionTypes(
+                Set<SchemaChangeEventType> schemaEvolutionTypes) {
+            delegate.setAcceptedSchemaEvolutionTypes(schemaEvolutionTypes);
+            return this;
+        }
+
+        @Override
+        public boolean acceptsSchemaEvolutionType(SchemaChangeEventType 
schemaChangeEventType) {
+            return delegate.acceptsSchemaEvolutionType(schemaChangeEventType);
+        }
+
+        @Override
+        public Set<SchemaChangeEventType> getSupportedSchemaEvolutionTypes() {
+            return delegate.getSupportedSchemaEvolutionTypes();
+        }
+
+        @Override
+        public void applySchemaChange(SchemaChangeEvent schemaChangeEvent) {
+            if (schemaChangeEvent instanceof AddColumnEvent
+                    && ((AddColumnEvent) schemaChangeEvent)
+                            .getAddedColumns()
+                            .get(0)
+                            .getAddColumn()
+                            .getName()
+                            .equals("extra_v1")) {
+                FIRST_COLUMN_APPLICATIONS.incrementAndGet();
+                if (FAILED.compareAndSet(false, true)) {
+                    delegate.applySchemaChange(schemaChangeEvent);
+                    FAILURES.incrementAndGet();
+                    throw new RuntimeException("Fail after applying extra_v1 
without rollback.");
+                }
+                return;
+            }
+            delegate.applySchemaChange(schemaChangeEvent);
+        }
+    }
+}
diff --git 
a/flink-cdc-composer/src/test/java/org/apache/flink/cdc/composer/testsource/recovery/RecoveryDataSinkFactory.java
 
b/flink-cdc-composer/src/test/java/org/apache/flink/cdc/composer/testsource/recovery/RecoveryDataSinkFactory.java
new file mode 100644
index 0000000000..633eae6ca4
--- /dev/null
+++ 
b/flink-cdc-composer/src/test/java/org/apache/flink/cdc/composer/testsource/recovery/RecoveryDataSinkFactory.java
@@ -0,0 +1,52 @@
+/*
+ * 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.testsource.recovery;
+
+import org.apache.flink.cdc.common.configuration.ConfigOption;
+import org.apache.flink.cdc.common.factories.DataSinkFactory;
+import org.apache.flink.cdc.common.sink.DataSink;
+
+import org.apache.flink.shaded.guava31.com.google.common.collect.ImmutableSet;
+
+import java.util.Set;
+
+/** Factory for {@link RecoveryDataSink}. */
+public class RecoveryDataSinkFactory implements DataSinkFactory {
+
+    public static final String IDENTIFIER = "recovery-test-sink";
+
+    @Override
+    public DataSink createDataSink(Context context) {
+        return new RecoveryDataSink();
+    }
+
+    @Override
+    public String identifier() {
+        return IDENTIFIER;
+    }
+
+    @Override
+    public Set<ConfigOption<?>> requiredOptions() {
+        return ImmutableSet.of();
+    }
+
+    @Override
+    public Set<ConfigOption<?>> optionalOptions() {
+        return ImmutableSet.of();
+    }
+}
diff --git 
a/flink-cdc-composer/src/test/java/org/apache/flink/cdc/composer/testsource/recovery/RecoveryDataSource.java
 
b/flink-cdc-composer/src/test/java/org/apache/flink/cdc/composer/testsource/recovery/RecoveryDataSource.java
new file mode 100644
index 0000000000..a2bedb5917
--- /dev/null
+++ 
b/flink-cdc-composer/src/test/java/org/apache/flink/cdc/composer/testsource/recovery/RecoveryDataSource.java
@@ -0,0 +1,48 @@
+/*
+ * 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.testsource.recovery;
+
+import org.apache.flink.cdc.common.source.DataSource;
+import org.apache.flink.cdc.common.source.EventSourceProvider;
+import org.apache.flink.cdc.common.source.FlinkSourceFunctionProvider;
+import org.apache.flink.cdc.common.source.MetadataAccessor;
+
+/** A checkpoint-aware source used by schema coordinator recovery integration 
tests. */
+public class RecoveryDataSource implements DataSource {
+
+    private final boolean parallelMetadataSource;
+
+    public RecoveryDataSource(boolean parallelMetadataSource) {
+        this.parallelMetadataSource = parallelMetadataSource;
+    }
+
+    @Override
+    public EventSourceProvider getEventSourceProvider() {
+        return FlinkSourceFunctionProvider.of(new RecoverySourceFunction());
+    }
+
+    @Override
+    public MetadataAccessor getMetadataAccessor() {
+        throw new UnsupportedOperationException("Recovery test source has no 
metadata accessor.");
+    }
+
+    @Override
+    public boolean isParallelMetadataSource() {
+        return parallelMetadataSource;
+    }
+}
diff --git 
a/flink-cdc-composer/src/test/java/org/apache/flink/cdc/composer/testsource/recovery/RecoveryDataSourceFactory.java
 
b/flink-cdc-composer/src/test/java/org/apache/flink/cdc/composer/testsource/recovery/RecoveryDataSourceFactory.java
new file mode 100644
index 0000000000..120abdd425
--- /dev/null
+++ 
b/flink-cdc-composer/src/test/java/org/apache/flink/cdc/composer/testsource/recovery/RecoveryDataSourceFactory.java
@@ -0,0 +1,57 @@
+/*
+ * 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.testsource.recovery;
+
+import org.apache.flink.cdc.common.configuration.ConfigOption;
+import org.apache.flink.cdc.common.configuration.ConfigOptions;
+import org.apache.flink.cdc.common.factories.DataSourceFactory;
+import org.apache.flink.cdc.common.source.DataSource;
+
+import org.apache.flink.shaded.guava31.com.google.common.collect.ImmutableSet;
+
+import java.util.Set;
+
+/** Factory for {@link RecoveryDataSource}. */
+public class RecoveryDataSourceFactory implements DataSourceFactory {
+
+    public static final String IDENTIFIER = "recovery-test-source";
+
+    public static final ConfigOption<Boolean> PARALLEL_METADATA_SOURCE =
+            
ConfigOptions.key("parallel-metadata-source").booleanType().defaultValue(false);
+
+    @Override
+    public DataSource createDataSource(Context context) {
+        return new RecoveryDataSource(
+                
context.getFactoryConfiguration().get(PARALLEL_METADATA_SOURCE));
+    }
+
+    @Override
+    public String identifier() {
+        return IDENTIFIER;
+    }
+
+    @Override
+    public Set<ConfigOption<?>> requiredOptions() {
+        return ImmutableSet.of();
+    }
+
+    @Override
+    public Set<ConfigOption<?>> optionalOptions() {
+        return ImmutableSet.of(PARALLEL_METADATA_SOURCE);
+    }
+}
diff --git 
a/flink-cdc-composer/src/test/java/org/apache/flink/cdc/composer/testsource/recovery/RecoverySourceFunction.java
 
b/flink-cdc-composer/src/test/java/org/apache/flink/cdc/composer/testsource/recovery/RecoverySourceFunction.java
new file mode 100644
index 0000000000..8877a9e1ef
--- /dev/null
+++ 
b/flink-cdc-composer/src/test/java/org/apache/flink/cdc/composer/testsource/recovery/RecoverySourceFunction.java
@@ -0,0 +1,242 @@
+/*
+ * 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.testsource.recovery;
+
+import org.apache.flink.api.common.state.CheckpointListener;
+import org.apache.flink.api.common.state.ListState;
+import org.apache.flink.api.common.state.ListStateDescriptor;
+import org.apache.flink.api.common.typeinfo.Types;
+import org.apache.flink.cdc.common.data.binary.BinaryStringData;
+import org.apache.flink.cdc.common.event.AddColumnEvent;
+import org.apache.flink.cdc.common.event.CreateTableEvent;
+import org.apache.flink.cdc.common.event.DataChangeEvent;
+import org.apache.flink.cdc.common.event.Event;
+import org.apache.flink.cdc.common.event.TableId;
+import org.apache.flink.cdc.common.schema.Column;
+import org.apache.flink.cdc.common.schema.Schema;
+import org.apache.flink.cdc.common.types.DataType;
+import org.apache.flink.cdc.common.types.DataTypes;
+import org.apache.flink.cdc.runtime.typeutils.BinaryRecordDataGenerator;
+import org.apache.flink.cdc.source.RuntimeContextAdapter;
+import org.apache.flink.runtime.state.FunctionInitializationContext;
+import org.apache.flink.runtime.state.FunctionSnapshotContext;
+import org.apache.flink.streaming.api.checkpoint.CheckpointedFunction;
+import 
org.apache.flink.streaming.api.functions.source.RichParallelSourceFunction;
+import org.apache.flink.streaming.api.functions.source.SourceFunction;
+import org.apache.flink.streaming.api.operators.StreamingRuntimeContext;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.atomic.AtomicInteger;
+
+/** Emits an add-column event only after a completed checkpoint and restores 
its event cursor. */
+public class RecoverySourceFunction extends RichParallelSourceFunction<Event>
+        implements CheckpointedFunction, CheckpointListener {
+
+    private static final int ADD_FIRST_COLUMN_POSITION = 2;
+
+    private static final AtomicInteger RUN_ATTEMPTS = new AtomicInteger();
+    private static final AtomicInteger FIRST_COLUMN_EMISSIONS = new 
AtomicInteger();
+
+    private transient ListState<Integer> cursorState;
+    private transient Map<Long, Integer> checkpointCursors;
+    private volatile boolean running = true;
+    private volatile boolean checkpointCompleted;
+    private int cursor;
+    private boolean restored;
+
+    public static void reset() {
+        RUN_ATTEMPTS.set(0);
+        FIRST_COLUMN_EMISSIONS.set(0);
+    }
+
+    public static int getRunAttempts() {
+        return RUN_ATTEMPTS.get();
+    }
+
+    public static int getFirstColumnEmissions() {
+        return FIRST_COLUMN_EMISSIONS.get();
+    }
+
+    @Override
+    public void run(SourceFunction.SourceContext<Event> context) throws 
Exception {
+        if (RuntimeContextAdapter.getIndexOfThisSubtask(
+                        (StreamingRuntimeContext) getRuntimeContext())
+                != 0) {
+            return;
+        }
+        RUN_ATTEMPTS.incrementAndGet();
+        List<Event> events = events();
+        if (restored) {
+            synchronized (context.getCheckpointLock()) {
+                context.collect(events.get(0));
+            }
+        }
+        while (running && cursor < events.size()) {
+            if (cursor == ADD_FIRST_COLUMN_POSITION && !checkpointCompleted) {
+                Thread.sleep(10L);
+                continue;
+            }
+            if (cursor == ADD_FIRST_COLUMN_POSITION) {
+                synchronized (context.getCheckpointLock()) {
+                    FIRST_COLUMN_EMISSIONS.incrementAndGet();
+                    context.collect(events.get(cursor));
+                    if (restored) {
+                        cursor++;
+                    }
+                }
+                // Preserve cursor 2 until the first attempt is cancelled, so 
it must replay C.
+                while (running && !restored) {
+                    Thread.sleep(10L);
+                }
+                continue;
+            }
+            synchronized (context.getCheckpointLock()) {
+                context.collect(events.get(cursor));
+                cursor++;
+            }
+        }
+    }
+
+    @Override
+    public void cancel() {
+        running = false;
+    }
+
+    @Override
+    public void snapshotState(FunctionSnapshotContext context) throws 
Exception {
+        cursorState.update(Collections.singletonList(cursor));
+        checkpointCursors.put(context.getCheckpointId(), cursor);
+    }
+
+    @Override
+    public void initializeState(FunctionInitializationContext context) throws 
Exception {
+        cursorState =
+                context.getOperatorStateStore()
+                        .getListState(new 
ListStateDescriptor<>("event-cursor", Types.INT));
+        checkpointCursors = new HashMap<>();
+        for (Integer restoredCursor : cursorState.get()) {
+            cursor = restoredCursor;
+        }
+        restored = context.isRestored();
+    }
+
+    @Override
+    public void notifyCheckpointComplete(long checkpointId) {
+        Integer checkpointCursor = checkpointCursors.remove(checkpointId);
+        if (checkpointCursor != null && checkpointCursor == 
ADD_FIRST_COLUMN_POSITION) {
+            checkpointCompleted = true;
+        }
+    }
+
+    private static List<Event> events() {
+        TableId tableId = TableId.tableId("recovery", "schema", "orders");
+        Schema initialSchema =
+                Schema.newBuilder()
+                        .physicalColumn("id", DataTypes.STRING())
+                        .physicalColumn("value", DataTypes.STRING())
+                        .primaryKey("id")
+                        .build();
+        BinaryRecordDataGenerator twoColumns =
+                new BinaryRecordDataGenerator(
+                        new DataType[] {DataTypes.STRING(), 
DataTypes.STRING()});
+        BinaryRecordDataGenerator threeColumns =
+                new BinaryRecordDataGenerator(
+                        new DataType[] {
+                            DataTypes.STRING(), DataTypes.STRING(), 
DataTypes.STRING()
+                        });
+        BinaryRecordDataGenerator fourColumns =
+                new BinaryRecordDataGenerator(
+                        new DataType[] {
+                            DataTypes.STRING(),
+                            DataTypes.STRING(),
+                            DataTypes.STRING(),
+                            DataTypes.STRING()
+                        });
+        return Arrays.asList(
+                new CreateTableEvent(tableId, initialSchema),
+                DataChangeEvent.insertEvent(
+                        tableId,
+                        twoColumns.generate(
+                                new Object[] {
+                                    BinaryStringData.fromString("1"),
+                                    BinaryStringData.fromString("before")
+                                })),
+                new AddColumnEvent(
+                        tableId,
+                        Collections.singletonList(
+                                new AddColumnEvent.ColumnWithPosition(
+                                        Column.physicalColumn("extra_v1", 
DataTypes.STRING())))),
+                DataChangeEvent.updateEvent(
+                        tableId,
+                        threeColumns.generate(
+                                new Object[] {
+                                    BinaryStringData.fromString("1"),
+                                    BinaryStringData.fromString("before"),
+                                    null
+                                }),
+                        threeColumns.generate(
+                                new Object[] {
+                                    BinaryStringData.fromString("1"),
+                                    BinaryStringData.fromString("after"),
+                                    BinaryStringData.fromString("v1")
+                                })),
+                new AddColumnEvent(
+                        tableId,
+                        Collections.singletonList(
+                                new AddColumnEvent.ColumnWithPosition(
+                                        Column.physicalColumn("extra_v2", 
DataTypes.STRING())))),
+                DataChangeEvent.updateEvent(
+                        tableId,
+                        fourColumns.generate(
+                                new Object[] {
+                                    BinaryStringData.fromString("1"),
+                                    BinaryStringData.fromString("after"),
+                                    BinaryStringData.fromString("v1"),
+                                    null
+                                }),
+                        fourColumns.generate(
+                                new Object[] {
+                                    BinaryStringData.fromString("1"),
+                                    BinaryStringData.fromString("after"),
+                                    BinaryStringData.fromString("v1"),
+                                    BinaryStringData.fromString("v2")
+                                })),
+                DataChangeEvent.insertEvent(
+                        tableId,
+                        fourColumns.generate(
+                                new Object[] {
+                                    BinaryStringData.fromString("2"),
+                                    BinaryStringData.fromString("deleted"),
+                                    BinaryStringData.fromString("v1"),
+                                    BinaryStringData.fromString("v2")
+                                })),
+                DataChangeEvent.deleteEvent(
+                        tableId,
+                        fourColumns.generate(
+                                new Object[] {
+                                    BinaryStringData.fromString("2"),
+                                    BinaryStringData.fromString("deleted"),
+                                    BinaryStringData.fromString("v1"),
+                                    BinaryStringData.fromString("v2")
+                                })));
+    }
+}
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 274faa0078..f5be4c8416 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
@@ -17,5 +17,7 @@ org.apache.flink.cdc.composer.utils.factory.DataSinkFactory2
 org.apache.flink.cdc.composer.utils.factory.DataSourceFactory1
 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.flink.FlinkPipelineComposerTest$TestDataSinkFactory
-org.apache.flink.cdc.composer.flink.FlinkPipelineComposerTest$TestDataSourceFactory
\ No newline at end of file
+org.apache.flink.cdc.composer.flink.FlinkPipelineComposerTest$TestDataSourceFactory
diff --git a/flink-cdc-e2e-tests/flink-cdc-pipeline-e2e-tests/pom.xml 
b/flink-cdc-e2e-tests/flink-cdc-pipeline-e2e-tests/pom.xml
index 37aec13dcf..7e33a006d3 100644
--- a/flink-cdc-e2e-tests/flink-cdc-pipeline-e2e-tests/pom.xml
+++ b/flink-cdc-e2e-tests/flink-cdc-pipeline-e2e-tests/pom.xml
@@ -36,6 +36,7 @@ limitations under the License.
         <flink.release.download.skip>false</flink.release.download.skip>
         
<flink.release.name>flink-${flink.version}-bin-scala_${scala.binary.version}.tgz</flink.release.name>
         
<flink.release.mirror>https://dlcdn.apache.org/flink/flink-${flink.version}</flink.release.mirror>
+        
<flink.hadoop.compatibility.artifact-id>flink-hadoop-compatibility_2.12</flink.hadoop.compatibility.artifact-id>
         <maven.plugin.download.version>1.6.8</maven.plugin.download.version>
         <iceberg.version>1.10.1</iceberg.version>
         <hive.version>2.3.9</hive.version>
@@ -169,7 +170,7 @@ limitations under the License.
         </dependency>
         <dependency>
             <groupId>org.apache.paimon</groupId>
-            <artifactId>paimon-flink-${flink.major.version}</artifactId>
+            <artifactId>paimon-flink-${paimon.flink.major.version}</artifactId>
             <version>${paimon.version}</version>
             <scope>test</scope>
         </dependency>
@@ -775,7 +776,7 @@ limitations under the License.
                         </artifactItem>
                         <artifactItem>
                             <groupId>org.apache.flink</groupId>
-                            
<artifactId>flink-hadoop-compatibility_2.12</artifactId>
+                            
<artifactId>${flink.hadoop.compatibility.artifact-id}</artifactId>
                             <version>${flink.version}</version>
                             
<destFileName>flink-hadoop-compatibility.jar</destFileName>
                             <type>jar</type>
@@ -843,4 +844,14 @@ limitations under the License.
             </plugin>
         </plugins>
     </build>
+
+    <profiles>
+        <profile>
+            <id>flink2</id>
+            <properties>
+                <flink.version>${flink.2.x.version}</flink.version>
+                
<flink.hadoop.compatibility.artifact-id>flink-hadoop-compatibility</flink.hadoop.compatibility.artifact-id>
+            </properties>
+        </profile>
+    </profiles>
 </project>
diff --git 
a/flink-cdc-e2e-tests/flink-cdc-pipeline-e2e-tests/src/test/java/org/apache/flink/cdc/pipeline/tests/SchemaCoordinatorRecoveryE2eITCase.java
 
b/flink-cdc-e2e-tests/flink-cdc-pipeline-e2e-tests/src/test/java/org/apache/flink/cdc/pipeline/tests/SchemaCoordinatorRecoveryE2eITCase.java
new file mode 100644
index 0000000000..d70f85386c
--- /dev/null
+++ 
b/flink-cdc-e2e-tests/flink-cdc-pipeline-e2e-tests/src/test/java/org/apache/flink/cdc/pipeline/tests/SchemaCoordinatorRecoveryE2eITCase.java
@@ -0,0 +1,260 @@
+/*
+ * 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.pipeline.tests;
+
+import org.apache.flink.api.common.JobID;
+import org.apache.flink.api.common.JobStatus;
+import org.apache.flink.cdc.pipeline.tests.recovery.RecoveryE2eFixtureFactory;
+import org.apache.flink.cdc.pipeline.tests.utils.PipelineTestEnvironment;
+
+import org.assertj.core.api.Assertions;
+import org.junit.jupiter.api.io.TempDir;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+import org.testcontainers.containers.Container.ExecResult;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.time.Duration;
+import java.util.Arrays;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.UUID;
+import java.util.concurrent.TimeUnit;
+import java.util.jar.JarEntry;
+import java.util.jar.JarOutputStream;
+import java.util.stream.Collectors;
+
+/** End-to-end recovery coverage for regular and distributed schema 
coordinators. */
+class SchemaCoordinatorRecoveryE2eITCase extends PipelineTestEnvironment {
+
+    private static final Duration JOB_TIMEOUT = Duration.ofMinutes(2);
+
+    @TempDir Path tempDir;
+
+    @Override
+    protected String getFlinkProperties() {
+        return super.getFlinkProperties()
+                .replace(
+                        "restart-strategy.type: off",
+                        "restart-strategy.type: fixed-delay\n"
+                                + "restart-strategy.fixed-delay.attempts: 1\n"
+                                + "restart-strategy.fixed-delay.delay: 0 s\n"
+                                + "execution.checkpointing.min-pause: 2 s");
+    }
+
+    @ParameterizedTest(name = "parallel metadata source: {0}")
+    @ValueSource(booleans = {false, true})
+    void recoversFailedSchemaChangeAndContinues(boolean distributed) throws 
Exception {
+        String evidencePath = "/tmp/shared/schema-coordinator-recovery-" + 
UUID.randomUUID();
+        Path fixtureJar = createFixtureJar();
+        String pipelineJob =
+                String.format(
+                        "source:\n"
+                                + "  type: %s\n"
+                                + "  evidence-path: %s\n"
+                                + "  parallel-metadata-source: %s\n"
+                                + "\n"
+                                + "sink:\n"
+                                + "  type: %s\n"
+                                + "  evidence-path: %s\n"
+                                + "\n"
+                                + "pipeline:\n"
+                                + "  parallelism: %d\n"
+                                + "  schema.change.behavior: lenient\n",
+                        RecoveryE2eFixtureFactory.IDENTIFIER,
+                        evidencePath,
+                        distributed,
+                        RecoveryE2eFixtureFactory.IDENTIFIER,
+                        evidencePath,
+                        distributed ? 2 : 1);
+
+        JobID jobId = submitPipelineJob(pipelineJob, fixtureJar);
+        Assertions.assertThat(jobId).as("pipeline job ID").isNotNull();
+        waitUntilJobFinished(JOB_TIMEOUT);
+        
Assertions.assertThat(getRestClusterClient().getJobStatus(jobId).get(10, 
TimeUnit.SECONDS))
+                .as("pipeline job completion")
+                .isEqualTo(JobStatus.FINISHED);
+
+        List<String> evidence =
+                Arrays.asList(readContainerFile(evidencePath + 
".events").split("\\n"));
+        Assertions.assertThat(occurrences(evidence, "SOURCE\tattempt"))
+                .as("job was restarted after the metadata failure")
+                .isGreaterThanOrEqualTo(2);
+        Assertions.assertThat(occurrences(evidence, 
"SOURCE\textra_v1-emitted"))
+                .as("the failed schema event was replayed")
+                .isEqualTo(2);
+        assertRestoredFromCompletedCheckpoint(evidence);
+        Assertions.assertThat(evidence)
+                .contains(
+                        "SOURCE\tcreate-replayed",
+                        "METADATA\textra_v1-applied",
+                        "METADATA\textra_v1-replayed",
+                        "METADATA\textra_v2-applied");
+        Assertions.assertThat(occurrences(evidence, 
"METADATA\textra_v1-applied"))
+                .as("the no-rollback metadata effect happened once")
+                .isEqualTo(1);
+        Assertions.assertThat(occurrences(evidence, 
"METADATA\textra_v1-replayed"))
+                .as("the metadata failure was replayed once")
+                .isEqualTo(1);
+        Assertions.assertThat(occurrences(evidence, 
"METADATA\textra_v2-applied"))
+                .as("the follow-up schema change was applied once")
+                .isEqualTo(1);
+        String schema = readContainerFile(evidencePath + ".schema").trim();
+        Assertions.assertThat(schema)
+                .as("follow-up schema change completed after recovery")
+                .isEqualTo("id,value,extra_v1,extra_v2");
+        Assertions.assertThat(dataEvents(evidence))
+                .as("complete data-change multiset")
+                .containsExactlyInAnyOrder(
+                        "DATA\tINSERT\t\t=>\t1,before",
+                        "DATA\tUPDATE\t1,before,\t=>\t1,after,v1",
+                        "DATA\tUPDATE\t1,after,v1,\t=>\t1,after,v1,v2",
+                        "DATA\tINSERT\t\t=>\t2,deleted,v1,v2",
+                        "DATA\tDELETE\t2,deleted,v1,v2\t=>\t");
+        Assertions.assertThat(terminalRows(evidence, schema))
+                .as("PK upsert and deletion materialized from every emitted 
data event")
+                .containsExactly(Map.entry("1", "1,after,v1,v2"));
+    }
+
+    private Path createFixtureJar() throws IOException {
+        Path fixtureJar = 
tempDir.resolve("schema-coordinator-recovery-fixture.jar");
+        try (JarOutputStream output = new 
JarOutputStream(Files.newOutputStream(fixtureJar))) {
+            for (Class<?> fixtureClass : 
RecoveryE2eFixtureFactory.fixtureClasses()) {
+                addClassToJar(fixtureClass, output);
+            }
+            output.putNextEntry(
+                    new JarEntry(
+                            
"META-INF/services/org.apache.flink.cdc.common.factories.Factory"));
+            output.write(
+                    (RecoveryE2eFixtureFactory.class.getName() + "\n")
+                            .getBytes(StandardCharsets.UTF_8));
+            output.closeEntry();
+        }
+        return fixtureJar;
+    }
+
+    private static void addClassToJar(Class<?> clazz, JarOutputStream output) 
throws IOException {
+        String resourceName = clazz.getName().replace('.', '/') + ".class";
+        try (InputStream input = 
clazz.getClassLoader().getResourceAsStream(resourceName)) {
+            Assertions.assertThat(input).as("class resource %s", 
resourceName).isNotNull();
+            output.putNextEntry(new JarEntry(resourceName));
+            input.transferTo(output);
+            output.closeEntry();
+        }
+    }
+
+    private String readContainerFile(String path) throws IOException, 
InterruptedException {
+        ExecResult result = jobManager.execInContainer("cat", path);
+        Assertions.assertThat(result.getExitCode()).as("read evidence file 
%s", path).isZero();
+        return result.getStdout();
+    }
+
+    private static int occurrences(List<String> evidence, String expected) {
+        return (int) evidence.stream().filter(expected::equals).count();
+    }
+
+    private static List<String> dataEvents(List<String> evidence) {
+        return evidence.stream()
+                .filter(line -> line.startsWith("DATA\t"))
+                .collect(Collectors.toList());
+    }
+
+    private static void assertRestoredFromCompletedCheckpoint(List<String> 
evidence) {
+        int firstColumnEmission = evidence.indexOf("SOURCE\textra_v1-emitted");
+        int restoredCheckpoint = indexOfPrefix(evidence, 
"SOURCE\tcheckpoint-restored\t");
+        Assertions.assertThat(firstColumnEmission).as("initial extra_v1 
emission").isGreaterThan(0);
+        Assertions.assertThat(restoredCheckpoint)
+                .as("source restore after the failure")
+                .isGreaterThan(firstColumnEmission);
+
+        int completedCheckpointIndex = -1;
+        for (int index = 0; index < firstColumnEmission; index++) {
+            if 
(evidence.get(index).startsWith("SOURCE\tcheckpoint-completed\t")) {
+                completedCheckpointIndex = index;
+            }
+        }
+        Assertions.assertThat(completedCheckpointIndex)
+                .as("checkpoint completed before initial extra_v1 emission")
+                .isGreaterThanOrEqualTo(0);
+
+        String completedCheckpoint = evidence.get(completedCheckpointIndex);
+        String checkpointId = completedCheckpoint.split("\\t", -1)[2];
+        String snapshot = "SOURCE\tcheckpoint-snapshot\t" + checkpointId + 
"\tcursor=2";
+        int snapshotIndex = evidence.indexOf(snapshot);
+        Assertions.assertThat(snapshotIndex)
+                .as("selected checkpoint captured the pre-failure cursor")
+                .isGreaterThanOrEqualTo(0);
+        Assertions.assertThat(snapshotIndex)
+                .as("checkpoint snapshot precedes its completion")
+                .isLessThan(completedCheckpointIndex);
+        Assertions.assertThat(evidence)
+                .contains(snapshot, "SOURCE\tcheckpoint-restored\t" + 
checkpointId + "\tcursor=2");
+        for (int index = snapshotIndex; index < restoredCheckpoint; index++) {
+            String line = evidence.get(index);
+            if (line.startsWith("SOURCE\tcheckpoint-snapshot\t")
+                    || line.startsWith("SOURCE\tcheckpoint-completed\t")) {
+                Assertions.assertThat(line)
+                        .as("no checkpoint may advance past the replay cursor 
before restore")
+                        .endsWith("\tcursor=2");
+            }
+        }
+    }
+
+    private static int indexOfPrefix(List<String> evidence, String prefix) {
+        for (int index = 0; index < evidence.size(); index++) {
+            if (evidence.get(index).startsWith(prefix)) {
+                return index;
+            }
+        }
+        return -1;
+    }
+
+    private static Map<String, String> terminalRows(List<String> evidence, 
String schema) {
+        Map<String, String> rows = new LinkedHashMap<>();
+        for (String line : evidence) {
+            if (!line.startsWith("DATA\t")) {
+                continue;
+            }
+            String[] parts = line.split("\\t", -1);
+            String operation = parts[1];
+            String row = "DELETE".equals(operation) ? parts[2] : parts[4];
+            String id = row.split(",", -1)[0];
+            if ("DELETE".equals(operation)) {
+                rows.remove(id);
+            } else {
+                rows.put(id, row);
+            }
+        }
+        int columnCount = schema.split(",", -1).length;
+        rows.replaceAll((id, row) -> padToColumnCount(row, columnCount));
+        return rows;
+    }
+
+    private static String padToColumnCount(String row, int columnCount) {
+        StringBuilder paddedRow = new StringBuilder(row);
+        for (int index = row.split(",", -1).length; index < columnCount; 
index++) {
+            paddedRow.append(',');
+        }
+        return paddedRow.toString();
+    }
+}
diff --git 
a/flink-cdc-e2e-tests/flink-cdc-pipeline-e2e-tests/src/test/java/org/apache/flink/cdc/pipeline/tests/recovery/RecoveryE2eFixtureFactory.java
 
b/flink-cdc-e2e-tests/flink-cdc-pipeline-e2e-tests/src/test/java/org/apache/flink/cdc/pipeline/tests/recovery/RecoveryE2eFixtureFactory.java
new file mode 100644
index 0000000000..d34af765f6
--- /dev/null
+++ 
b/flink-cdc-e2e-tests/flink-cdc-pipeline-e2e-tests/src/test/java/org/apache/flink/cdc/pipeline/tests/recovery/RecoveryE2eFixtureFactory.java
@@ -0,0 +1,522 @@
+/*
+ * 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.pipeline.tests.recovery;
+
+import org.apache.flink.api.common.state.CheckpointListener;
+import org.apache.flink.api.common.state.ListState;
+import org.apache.flink.api.common.state.ListStateDescriptor;
+import org.apache.flink.api.common.typeinfo.Types;
+import org.apache.flink.cdc.common.configuration.ConfigOption;
+import org.apache.flink.cdc.common.configuration.ConfigOptions;
+import org.apache.flink.cdc.common.data.RecordData;
+import org.apache.flink.cdc.common.data.binary.BinaryStringData;
+import org.apache.flink.cdc.common.event.AddColumnEvent;
+import org.apache.flink.cdc.common.event.CreateTableEvent;
+import org.apache.flink.cdc.common.event.DataChangeEvent;
+import org.apache.flink.cdc.common.event.Event;
+import org.apache.flink.cdc.common.event.SchemaChangeEvent;
+import org.apache.flink.cdc.common.event.TableId;
+import org.apache.flink.cdc.common.factories.DataSinkFactory;
+import org.apache.flink.cdc.common.factories.DataSourceFactory;
+import org.apache.flink.cdc.common.schema.Column;
+import org.apache.flink.cdc.common.schema.Schema;
+import org.apache.flink.cdc.common.sink.DataSink;
+import org.apache.flink.cdc.common.sink.EventSinkProvider;
+import org.apache.flink.cdc.common.sink.FlinkSinkFunctionProvider;
+import org.apache.flink.cdc.common.sink.MetadataApplier;
+import org.apache.flink.cdc.common.source.DataSource;
+import org.apache.flink.cdc.common.source.EventSourceProvider;
+import org.apache.flink.cdc.common.source.FlinkSourceFunctionProvider;
+import org.apache.flink.cdc.common.source.MetadataAccessor;
+import org.apache.flink.cdc.common.types.DataType;
+import org.apache.flink.cdc.common.types.DataTypes;
+import org.apache.flink.cdc.runtime.typeutils.BinaryRecordDataGenerator;
+import org.apache.flink.cdc.source.RuntimeContextAdapter;
+import org.apache.flink.runtime.state.FunctionInitializationContext;
+import org.apache.flink.runtime.state.FunctionSnapshotContext;
+import org.apache.flink.streaming.api.checkpoint.CheckpointedFunction;
+import org.apache.flink.streaming.api.functions.sink.SinkFunction;
+import 
org.apache.flink.streaming.api.functions.source.RichParallelSourceFunction;
+import org.apache.flink.streaming.api.functions.source.SourceFunction;
+import org.apache.flink.streaming.api.operators.StreamingRuntimeContext;
+
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.FileAlreadyExistsException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.nio.file.StandardOpenOption;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+/** Test-only source and sink fixture packaged into the pipeline job JAR. */
+public class RecoveryE2eFixtureFactory implements DataSourceFactory, 
DataSinkFactory {
+
+    public static final String IDENTIFIER = "recovery-e2e";
+
+    public static final ConfigOption<String> EVIDENCE_PATH =
+            ConfigOptions.key("evidence-path").stringType().noDefaultValue();
+
+    public static final ConfigOption<Boolean> PARALLEL_METADATA_SOURCE =
+            
ConfigOptions.key("parallel-metadata-source").booleanType().defaultValue(false);
+
+    @Override
+    public DataSource createDataSource(DataSourceFactory.Context context) {
+        return new RecoveryE2eDataSource(
+                context.getFactoryConfiguration().get(EVIDENCE_PATH),
+                
context.getFactoryConfiguration().get(PARALLEL_METADATA_SOURCE));
+    }
+
+    @Override
+    public DataSink createDataSink(DataSinkFactory.Context context) {
+        return new 
RecoveryE2eDataSink(context.getFactoryConfiguration().get(EVIDENCE_PATH));
+    }
+
+    @Override
+    public String identifier() {
+        return IDENTIFIER;
+    }
+
+    @Override
+    public Set<ConfigOption<?>> requiredOptions() {
+        return Collections.singleton(EVIDENCE_PATH);
+    }
+
+    @Override
+    public Set<ConfigOption<?>> optionalOptions() {
+        return Collections.singleton(PARALLEL_METADATA_SOURCE);
+    }
+
+    public static Class<?>[] fixtureClasses() {
+        return new Class<?>[] {
+            RecoveryE2eFixtureFactory.class,
+            RecoveryE2eDataSource.class,
+            RecoveryE2eDataSink.class,
+            RecoveryE2eSourceFunction.class,
+            RecoveryE2eSinkFunction.class,
+            RecoveryE2eMetadataApplier.class,
+            RecoveryE2eEvents.class,
+            RecoveryE2eEvidence.class
+        };
+    }
+}
+
+class RecoveryE2eDataSource implements DataSource {
+
+    private final String evidencePath;
+    private final boolean parallelMetadataSource;
+
+    RecoveryE2eDataSource(String evidencePath, boolean parallelMetadataSource) 
{
+        this.evidencePath = evidencePath;
+        this.parallelMetadataSource = parallelMetadataSource;
+    }
+
+    @Override
+    public EventSourceProvider getEventSourceProvider() {
+        return FlinkSourceFunctionProvider.of(new 
RecoveryE2eSourceFunction(evidencePath));
+    }
+
+    @Override
+    public MetadataAccessor getMetadataAccessor() {
+        throw new UnsupportedOperationException("Recovery E2E source has no 
metadata accessor.");
+    }
+
+    @Override
+    public boolean isParallelMetadataSource() {
+        return parallelMetadataSource;
+    }
+}
+
+class RecoveryE2eDataSink implements DataSink {
+
+    private final String evidencePath;
+
+    RecoveryE2eDataSink(String evidencePath) {
+        this.evidencePath = evidencePath;
+    }
+
+    @Override
+    public EventSinkProvider getEventSinkProvider() {
+        return FlinkSinkFunctionProvider.of(new 
RecoveryE2eSinkFunction(evidencePath));
+    }
+
+    @Override
+    public MetadataApplier getMetadataApplier() {
+        return new RecoveryE2eMetadataApplier(evidencePath);
+    }
+}
+
+class RecoveryE2eSourceFunction extends RichParallelSourceFunction<Event>
+        implements CheckpointedFunction, CheckpointListener {
+
+    private static final int ADD_FIRST_COLUMN_POSITION = 2;
+
+    private final String evidencePath;
+    private transient ListState<Integer> cursorState;
+    private transient ListState<Long> checkpointIdState;
+    private transient Map<Long, Integer> checkpointCursors;
+    private volatile boolean running = true;
+    private volatile boolean checkpointCompleted;
+    private int cursor;
+    private long restoredCheckpointId = -1L;
+    private boolean restored;
+
+    RecoveryE2eSourceFunction(String evidencePath) {
+        this.evidencePath = evidencePath;
+    }
+
+    @Override
+    public void run(SourceFunction.SourceContext<Event> context) throws 
Exception {
+        if (RuntimeContextAdapter.getIndexOfThisSubtask(
+                        (StreamingRuntimeContext) getRuntimeContext())
+                != 0) {
+            return;
+        }
+
+        RecoveryE2eEvidence.append(evidencePath, "SOURCE\tattempt");
+        List<Event> events = RecoveryE2eEvents.events();
+        if (restored) {
+            synchronized (context.getCheckpointLock()) {
+                context.collect(events.get(0));
+            }
+            RecoveryE2eEvidence.append(evidencePath, 
"SOURCE\tcreate-replayed");
+        }
+
+        while (running && cursor < events.size()) {
+            if (cursor == ADD_FIRST_COLUMN_POSITION && !checkpointCompleted) {
+                Thread.sleep(10L);
+                continue;
+            }
+            if (cursor == ADD_FIRST_COLUMN_POSITION) {
+                synchronized (context.getCheckpointLock()) {
+                    RecoveryE2eEvidence.append(evidencePath, 
"SOURCE\textra_v1-emitted");
+                    context.collect(events.get(cursor));
+                    if (restored) {
+                        cursor++;
+                    }
+                }
+                while (running && !restored) {
+                    Thread.sleep(10L);
+                }
+                continue;
+            }
+            synchronized (context.getCheckpointLock()) {
+                context.collect(events.get(cursor));
+                cursor++;
+            }
+        }
+    }
+
+    @Override
+    public void cancel() {
+        running = false;
+    }
+
+    @Override
+    public void snapshotState(FunctionSnapshotContext context) throws 
Exception {
+        cursorState.update(Collections.singletonList(cursor));
+        
checkpointIdState.update(Collections.singletonList(context.getCheckpointId()));
+        checkpointCursors.put(context.getCheckpointId(), cursor);
+        if (isEmitterSubtask()) {
+            RecoveryE2eEvidence.append(
+                    evidencePath,
+                    "SOURCE\tcheckpoint-snapshot\t"
+                            + context.getCheckpointId()
+                            + "\tcursor="
+                            + cursor);
+        }
+    }
+
+    @Override
+    public void initializeState(FunctionInitializationContext context) throws 
Exception {
+        cursorState =
+                context.getOperatorStateStore()
+                        .getListState(new 
ListStateDescriptor<>("event-cursor", Types.INT));
+        checkpointIdState =
+                context.getOperatorStateStore()
+                        .getListState(new 
ListStateDescriptor<>("checkpoint-id", Types.LONG));
+        checkpointCursors = new HashMap<>();
+        for (Integer restoredCursor : cursorState.get()) {
+            cursor = restoredCursor;
+        }
+        for (Long checkpointId : checkpointIdState.get()) {
+            restoredCheckpointId = checkpointId;
+        }
+        restored = context.isRestored();
+        if (restored && isEmitterSubtask()) {
+            RecoveryE2eEvidence.append(
+                    evidencePath,
+                    "SOURCE\tcheckpoint-restored\t" + restoredCheckpointId + 
"\tcursor=" + cursor);
+        }
+    }
+
+    @Override
+    public void notifyCheckpointComplete(long checkpointId) {
+        Integer checkpointCursor = checkpointCursors.remove(checkpointId);
+        if (checkpointCursor == null || checkpointCursor != 
ADD_FIRST_COLUMN_POSITION) {
+            return;
+        }
+        if (isEmitterSubtask()) {
+            RecoveryE2eEvidence.append(
+                    evidencePath,
+                    "SOURCE\tcheckpoint-completed\t"
+                            + checkpointId
+                            + "\tcursor="
+                            + checkpointCursor);
+        }
+        checkpointCompleted = true;
+    }
+
+    private boolean isEmitterSubtask() {
+        return RuntimeContextAdapter.getIndexOfThisSubtask(
+                        (StreamingRuntimeContext) getRuntimeContext())
+                == 0;
+    }
+}
+
+class RecoveryE2eSinkFunction implements SinkFunction<Event> {
+
+    private final String evidencePath;
+
+    RecoveryE2eSinkFunction(String evidencePath) {
+        this.evidencePath = evidencePath;
+    }
+
+    @Override
+    public void invoke(Event event, Context context) {
+        if (event instanceof DataChangeEvent) {
+            DataChangeEvent dataChangeEvent = (DataChangeEvent) event;
+            RecoveryE2eEvidence.append(
+                    evidencePath,
+                    "DATA\t"
+                            + dataChangeEvent.op()
+                            + "\t"
+                            + format(dataChangeEvent.before())
+                            + "\t=>\t"
+                            + format(dataChangeEvent.after()));
+        }
+    }
+
+    private static String format(RecordData record) {
+        if (record == null) {
+            return "";
+        }
+        StringBuilder builder = new StringBuilder();
+        for (int index = 0; index < record.getArity(); index++) {
+            if (index > 0) {
+                builder.append(',');
+            }
+            if (!record.isNullAt(index)) {
+                builder.append(record.getString(index));
+            }
+        }
+        return builder.toString();
+    }
+}
+
+class RecoveryE2eMetadataApplier implements MetadataApplier {
+
+    private final String evidencePath;
+
+    RecoveryE2eMetadataApplier(String evidencePath) {
+        this.evidencePath = evidencePath;
+    }
+
+    @Override
+    public void applySchemaChange(SchemaChangeEvent schemaChangeEvent) {
+        if (schemaChangeEvent instanceof CreateTableEvent) {
+            RecoveryE2eEvidence.initializeSchema(evidencePath, "id,value");
+            RecoveryE2eEvidence.append(evidencePath, "METADATA\tcreate");
+            return;
+        }
+        if (!(schemaChangeEvent instanceof AddColumnEvent)) {
+            return;
+        }
+
+        String columnName =
+                ((AddColumnEvent) schemaChangeEvent)
+                        .getAddedColumns()
+                        .get(0)
+                        .getAddColumn()
+                        .getName();
+        if ("extra_v1".equals(columnName)) {
+            if (RecoveryE2eEvidence.createMarker(evidencePath + 
".extra-v1-applied")) {
+                RecoveryE2eEvidence.writeSchema(evidencePath, 
"id,value,extra_v1");
+                RecoveryE2eEvidence.append(evidencePath, 
"METADATA\textra_v1-applied");
+                throw new RuntimeException("Fail after applying extra_v1 
without rollback.");
+            }
+            RecoveryE2eEvidence.append(evidencePath, 
"METADATA\textra_v1-replayed");
+            return;
+        }
+        if ("extra_v2".equals(columnName)) {
+            RecoveryE2eEvidence.writeSchema(evidencePath, 
"id,value,extra_v1,extra_v2");
+            RecoveryE2eEvidence.append(evidencePath, 
"METADATA\textra_v2-applied");
+        }
+    }
+}
+
+class RecoveryE2eEvents {
+
+    private RecoveryE2eEvents() {}
+
+    static List<Event> events() {
+        TableId tableId = TableId.tableId("recovery", "schema", "orders");
+        Schema initialSchema =
+                Schema.newBuilder()
+                        .physicalColumn("id", DataTypes.STRING())
+                        .physicalColumn("value", DataTypes.STRING())
+                        .primaryKey("id")
+                        .build();
+        BinaryRecordDataGenerator twoColumns =
+                new BinaryRecordDataGenerator(
+                        new DataType[] {DataTypes.STRING(), 
DataTypes.STRING()});
+        BinaryRecordDataGenerator threeColumns =
+                new BinaryRecordDataGenerator(
+                        new DataType[] {
+                            DataTypes.STRING(), DataTypes.STRING(), 
DataTypes.STRING()
+                        });
+        BinaryRecordDataGenerator fourColumns =
+                new BinaryRecordDataGenerator(
+                        new DataType[] {
+                            DataTypes.STRING(),
+                            DataTypes.STRING(),
+                            DataTypes.STRING(),
+                            DataTypes.STRING()
+                        });
+        return Arrays.asList(
+                new CreateTableEvent(tableId, initialSchema),
+                DataChangeEvent.insertEvent(
+                        tableId,
+                        twoColumns.generate(
+                                new Object[] {
+                                    BinaryStringData.fromString("1"),
+                                    BinaryStringData.fromString("before")
+                                })),
+                new AddColumnEvent(
+                        tableId,
+                        Collections.singletonList(
+                                new AddColumnEvent.ColumnWithPosition(
+                                        Column.physicalColumn("extra_v1", 
DataTypes.STRING())))),
+                DataChangeEvent.updateEvent(
+                        tableId,
+                        threeColumns.generate(
+                                new Object[] {
+                                    BinaryStringData.fromString("1"),
+                                    BinaryStringData.fromString("before"),
+                                    null
+                                }),
+                        threeColumns.generate(
+                                new Object[] {
+                                    BinaryStringData.fromString("1"),
+                                    BinaryStringData.fromString("after"),
+                                    BinaryStringData.fromString("v1")
+                                })),
+                new AddColumnEvent(
+                        tableId,
+                        Collections.singletonList(
+                                new AddColumnEvent.ColumnWithPosition(
+                                        Column.physicalColumn("extra_v2", 
DataTypes.STRING())))),
+                DataChangeEvent.updateEvent(
+                        tableId,
+                        fourColumns.generate(
+                                new Object[] {
+                                    BinaryStringData.fromString("1"),
+                                    BinaryStringData.fromString("after"),
+                                    BinaryStringData.fromString("v1"),
+                                    null
+                                }),
+                        fourColumns.generate(
+                                new Object[] {
+                                    BinaryStringData.fromString("1"),
+                                    BinaryStringData.fromString("after"),
+                                    BinaryStringData.fromString("v1"),
+                                    BinaryStringData.fromString("v2")
+                                })),
+                DataChangeEvent.insertEvent(
+                        tableId,
+                        fourColumns.generate(
+                                new Object[] {
+                                    BinaryStringData.fromString("2"),
+                                    BinaryStringData.fromString("deleted"),
+                                    BinaryStringData.fromString("v1"),
+                                    BinaryStringData.fromString("v2")
+                                })),
+                DataChangeEvent.deleteEvent(
+                        tableId,
+                        fourColumns.generate(
+                                new Object[] {
+                                    BinaryStringData.fromString("2"),
+                                    BinaryStringData.fromString("deleted"),
+                                    BinaryStringData.fromString("v1"),
+                                    BinaryStringData.fromString("v2")
+                                })));
+    }
+}
+
+class RecoveryE2eEvidence {
+
+    private RecoveryE2eEvidence() {}
+
+    static void append(String evidencePath, String line) {
+        try {
+            Files.write(
+                    Paths.get(evidencePath + ".events"),
+                    (line + 
System.lineSeparator()).getBytes(StandardCharsets.UTF_8),
+                    StandardOpenOption.CREATE,
+                    StandardOpenOption.APPEND);
+        } catch (IOException e) {
+            throw new UncheckedIOException(e);
+        }
+    }
+
+    static boolean createMarker(String markerPath) {
+        try {
+            Files.createFile(Paths.get(markerPath));
+            return true;
+        } catch (FileAlreadyExistsException ignored) {
+            return false;
+        } catch (IOException e) {
+            throw new UncheckedIOException(e);
+        }
+    }
+
+    static void initializeSchema(String evidencePath, String schema) {
+        Path schemaPath = Paths.get(evidencePath + ".schema");
+        if (!Files.exists(schemaPath)) {
+            writeSchema(evidencePath, schema);
+        }
+    }
+
+    static void writeSchema(String evidencePath, String schema) {
+        try {
+            Files.write(
+                    Paths.get(evidencePath + ".schema"),
+                    schema.getBytes(StandardCharsets.UTF_8),
+                    StandardOpenOption.CREATE,
+                    StandardOpenOption.TRUNCATE_EXISTING);
+        } catch (IOException e) {
+            throw new UncheckedIOException(e);
+        }
+    }
+}
diff --git 
a/flink-cdc-e2e-tests/flink-cdc-pipeline-e2e-tests/src/test/java/org/apache/flink/cdc/pipeline/tests/utils/PipelineTestEnvironment.java
 
b/flink-cdc-e2e-tests/flink-cdc-pipeline-e2e-tests/src/test/java/org/apache/flink/cdc/pipeline/tests/utils/PipelineTestEnvironment.java
index 65e8448f1f..2ee0b97c75 100644
--- 
a/flink-cdc-e2e-tests/flink-cdc-pipeline-e2e-tests/src/test/java/org/apache/flink/cdc/pipeline/tests/utils/PipelineTestEnvironment.java
+++ 
b/flink-cdc-e2e-tests/flink-cdc-pipeline-e2e-tests/src/test/java/org/apache/flink/cdc/pipeline/tests/utils/PipelineTestEnvironment.java
@@ -154,6 +154,10 @@ public abstract class PipelineTestEnvironment extends 
TestLogger {
                     "taskmanager.memory.jvm-metaspace.size: 512mb");
     public static final String FLINK_PROPERTIES = String.join("\n", 
EXTERNAL_PROPS);
 
+    protected String getFlinkProperties() {
+        return FLINK_PROPERTIES;
+    }
+
     @Nullable protected RestClusterClient<StandaloneClusterId> 
restClusterClient;
 
     protected GenericContainer<?> jobManager;
@@ -189,7 +193,7 @@ public abstract class PipelineTestEnvironment extends 
TestLogger {
                         .withNetwork(NETWORK)
                         .withNetworkAliases(INTER_CONTAINER_JM_ALIAS)
                         .withExposedPorts(JOB_MANAGER_REST_PORT)
-                        .withEnv("FLINK_PROPERTIES", FLINK_PROPERTIES)
+                        .withEnv("FLINK_PROPERTIES", getFlinkProperties())
                         .withCreateContainerCmdModifier(cmd -> 
cmd.withVolumes(sharedVolume))
                         .withLogConsumer(jobManagerConsumer);
 
@@ -211,7 +215,7 @@ public abstract class PipelineTestEnvironment extends 
TestLogger {
                         .withCommand("taskmanager")
                         .withNetwork(NETWORK)
                         .withNetworkAliases(INTER_CONTAINER_TM_ALIAS)
-                        .withEnv("FLINK_PROPERTIES", FLINK_PROPERTIES)
+                        .withEnv("FLINK_PROPERTIES", getFlinkProperties())
                         .dependsOn(jobManager)
                         .withVolumesFrom(jobManager, BindMode.READ_WRITE)
                         .withLogConsumer(taskManagerConsumer);
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 af0cd6f4cb..5dc1ff99e6 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
@@ -55,6 +55,8 @@ import java.util.Set;
 import java.util.concurrent.CompletableFuture;
 import java.util.concurrent.ConcurrentHashMap;
 import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
 import java.util.concurrent.TimeoutException;
 import java.util.function.BooleanSupplier;
 
@@ -100,6 +102,10 @@ public abstract class SchemaRegistry implements 
OperatorCoordinator, Coordinatio
     protected transient SchemaManager schemaManager;
     protected transient TableIdRouter router;
 
+    private final Object lifecycleLock = new Object();
+    private volatile boolean resetting;
+    private volatile long generation;
+
     protected SchemaRegistry(
             OperatorCoordinator.Context context,
             String operatorName,
@@ -125,6 +131,13 @@ public abstract class SchemaRegistry implements 
OperatorCoordinator, Coordinatio
     @Override
     public void start() throws Exception {
         LOG.info("Starting SchemaRegistry - {}.", operatorName);
+        initializeBaseRuntimeState();
+        initialize();
+        LOG.info(
+                "Started SchemaRegistry for {}. Parallelism: {}", 
operatorName, currentParallelism);
+    }
+
+    private void initializeBaseRuntimeState() {
         this.currentParallelism = context.currentParallelism();
         this.activeSinkWriters = ConcurrentHashMap.newKeySet();
         this.failedReasons = new ConcurrentHashMap<>();
@@ -155,6 +168,15 @@ public abstract class SchemaRegistry implements 
OperatorCoordinator, Coordinatio
     /** Restore schema registry state from byte array. */
     protected abstract void restore(byte[] checkpointData) throws Exception;
 
+    /**
+     * Stops schema-change work from the previous coordinator generation. 
Implementations must block
+     * until the previous generation's worker has fully exited, within {@link 
#rpcTimeout}.
+     */
+    protected abstract void shutdown() throws Exception;
+
+    /** (Re)initializes transient state, both on {@link #start()} and after a 
coordinator reset. */
+    protected abstract void initialize();
+
     // ------------------------------------
     // Overridable event & request handlers
     // ------------------------------------
@@ -258,6 +280,7 @@ public abstract class SchemaRegistry implements 
OperatorCoordinator, Coordinatio
                         handleCustomCoordinationRequest(request, future);
                     }
                 },
+                future,
                 "Handling request - %s",
                 request);
         return future;
@@ -313,7 +336,11 @@ public abstract class SchemaRegistry implements 
OperatorCoordinator, Coordinatio
     public final void checkpointCoordinator(
             long checkpointId, CompletableFuture<byte[]> completableFuture) 
throws Exception {
         LOG.info("Going to start checkpoint No.{}", checkpointId);
-        runInEventLoop(() -> snapshot(completableFuture), "Taking checkpoint - 
%d", checkpointId);
+        runInEventLoop(
+                () -> snapshot(completableFuture),
+                completableFuture,
+                "Taking checkpoint - %d",
+                checkpointId);
     }
 
     @Override
@@ -325,10 +352,22 @@ public abstract class SchemaRegistry implements 
OperatorCoordinator, Coordinatio
     public final void resetToCheckpoint(long checkpointId, @Nullable byte[] 
checkpointData)
             throws Exception {
         LOG.info("Going to restore from checkpoint No.{}", checkpointId);
+        synchronized (lifecycleLock) {
+            resetting = true;
+            generation++;
+        }
+        awaitCoordinatorExecutor();
+        shutdown();
         if (checkpointData == null) {
-            return;
+            schemaManager = new SchemaManager();
+        } else {
+            restore(checkpointData);
+        }
+        initializeBaseRuntimeState();
+        initialize();
+        synchronized (lifecycleLock) {
+            resetting = false;
         }
-        restore(checkpointData);
     }
 
     // ---------------------------
@@ -344,21 +383,65 @@ public abstract class SchemaRegistry implements 
OperatorCoordinator, Coordinatio
             final ThrowingRunnable<Throwable> action,
             final String actionName,
             final Object... actionNameFormatParameters) {
+        runInEventLoop(action, null, actionName, actionNameFormatParameters);
+    }
+
+    private void runInEventLoop(
+            final ThrowingRunnable<Throwable> action,
+            @Nullable CompletableFuture<?> cancellationFuture,
+            final String actionName,
+            final Object... actionNameFormatParameters) {
+        final long actionGeneration;
+        synchronized (lifecycleLock) {
+            if (resetting) {
+                reject(cancellationFuture);
+                return;
+            }
+            actionGeneration = generation;
+        }
         coordinatorExecutor.execute(
                 () -> {
+                    synchronized (lifecycleLock) {
+                        if (resetting || actionGeneration != generation) {
+                            reject(cancellationFuture);
+                            return;
+                        }
+                    }
                     try {
                         action.run();
                     } catch (Throwable t) {
-                        // if we have a JVM critical error, promote it 
immediately, there is a good
+                        // if we have a JVM critical error, promote it 
immediately, there is a
+                        // good
                         // chance the logging or job failing will not succeed 
anymore
                         ExceptionUtils.rethrowIfFatalErrorOrOOM(t);
-                        handleUnrecoverableError(
-                                String.format(actionName, 
actionNameFormatParameters), t);
-                        context.failJob(t);
+                        synchronized (lifecycleLock) {
+                            if (resetting || actionGeneration != generation) {
+                                reject(cancellationFuture);
+                                return;
+                            }
+                            failJob(String.format(actionName, 
actionNameFormatParameters), t);
+                        }
                     }
                 });
     }
 
+    private void awaitCoordinatorExecutor() throws Exception {
+        Future<?> barrier = coordinatorExecutor.submit(() -> {});
+        try {
+            barrier.get(rpcTimeout.toMillis(), TimeUnit.MILLISECONDS);
+        } catch (InterruptedException e) {
+            Thread.currentThread().interrupt();
+            throw e;
+        }
+    }
+
+    private void reject(@Nullable CompletableFuture<?> future) {
+        if (future != null) {
+            future.completeExceptionally(
+                    new FlinkRuntimeException("Schema coordinator is 
resetting."));
+        }
+    }
+
     /**
      * Keeps checking if {@code conditionChecker} is satisfied. If not, emit a 
message and retry.
      */
@@ -391,10 +474,15 @@ public abstract class SchemaRegistry implements 
OperatorCoordinator, Coordinatio
     }
 
     protected <T extends Throwable> void failJob(String taskDescription, T t) {
-        ExceptionUtils.rethrowIfFatalErrorOrOOM(t);
-        LOG.error("An exception was triggered from {}. Job will fail now.", 
taskDescription, t);
-        handleUnrecoverableError(taskDescription, t);
-        context.failJob(t);
+        synchronized (lifecycleLock) {
+            ExceptionUtils.rethrowIfFatalErrorOrOOM(t);
+            if (resetting) {
+                return;
+            }
+            LOG.error("An exception was triggered from {}. Job will fail 
now.", taskDescription, t);
+            handleUnrecoverableError(taskDescription, t);
+            context.failJob(t);
+        }
     }
 
     // ------------------------
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 f9aad19b85..286f89bfe7 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
@@ -64,6 +64,7 @@ import java.util.concurrent.CompletableFuture;
 import java.util.concurrent.ConcurrentHashMap;
 import java.util.concurrent.ExecutorService;
 import java.util.concurrent.Executors;
+import java.util.concurrent.TimeUnit;
 import java.util.concurrent.TimeoutException;
 import java.util.concurrent.atomic.AtomicReference;
 import java.util.stream.Collectors;
@@ -102,7 +103,7 @@ public class SchemaCoordinator extends SchemaRegistry {
             alreadyHandledSchemaChangeEvents;
 
     /** Executor service to execute schema change. */
-    private final ExecutorService schemaChangeThreadPool;
+    private transient ExecutorService schemaChangeThreadPool;
 
     public SchemaCoordinator(
             String operatorName,
@@ -129,15 +130,40 @@ public class SchemaCoordinator extends SchemaRegistry {
     // Lifecycle methods
     // -----------------
     @Override
-    public void start() throws Exception {
-        super.start();
+    protected void initialize() {
+        if (pendingRequests != null) {
+            pendingRequests.forEach(
+                    (index, tuple) ->
+                            tuple.f1.completeExceptionally(
+                                    new FlinkRuntimeException(
+                                            "Schema coordinator request was 
cancelled by checkpoint reset.")));
+        }
         this.evolvingStatus = new AtomicReference<>(RequestStatus.IDLE);
         this.pendingRequests = new ConcurrentHashMap<>();
         this.flushedSinkWriters = ConcurrentHashMap.newKeySet();
         this.upstreamSchemaTable = HashBasedTable.create();
         this.alreadyHandledSchemaChangeEvents = HashMultimap.create();
-        LOG.info(
-                "Started SchemaRegistry for {}. Parallelism: {}", 
operatorName, currentParallelism);
+        if (schemaChangeThreadPool == null || 
schemaChangeThreadPool.isShutdown()) {
+            schemaChangeThreadPool = Executors.newSingleThreadExecutor();
+        }
+    }
+
+    @Override
+    protected void shutdown() throws Exception {
+        schemaChangeThreadPool.shutdownNow();
+        if (!schemaChangeThreadPool.awaitTermination(
+                rpcTimeout.toMillis(), TimeUnit.MILLISECONDS)) {
+            throw new TimeoutException("Schema change executor did not 
terminate during reset.");
+        }
+    }
+
+    @Override
+    protected void handleUnrecoverableError(String taskDescription, Throwable 
t) {
+        super.handleUnrecoverableError(taskDescription, t);
+        if (pendingRequests != null) {
+            pendingRequests.forEach((index, tuple) -> 
tuple.f1.completeExceptionally(t));
+        }
+        LOG.info("Current upstream table state: {}", upstreamSchemaTable);
     }
 
     @Override
@@ -208,16 +234,6 @@ public class SchemaCoordinator extends SchemaRegistry {
         flushedSinkWriters.add(event.getSinkSubTaskId());
     }
 
-    @Override
-    protected void handleUnrecoverableError(String taskDescription, Throwable 
t) {
-        super.handleUnrecoverableError(taskDescription, t);
-        LOG.info("Current upstream table state: {}", upstreamSchemaTable);
-        pendingRequests.forEach(
-                (index, tuple) -> {
-                    tuple.f1.completeExceptionally(t);
-                });
-    }
-
     // -------------------------
     // Schema evolving logic
     // -------------------------
@@ -472,12 +488,8 @@ public class SchemaCoordinator extends SchemaRegistry {
                     schemaChangeEvent);
             return true;
         } catch (Throwable t) {
-            handleUnrecoverableError(
-                    "Apply schema change event - " + schemaChangeEvent,
-                    new FlinkRuntimeException(
-                            "Failed to apply schema change event " + 
schemaChangeEvent + ".", t));
-            context.failJob(t);
-            throw t;
+            throw new FlinkRuntimeException(
+                    "Failed to apply schema change event " + schemaChangeEvent 
+ ".", t);
         }
     }
 
diff --git 
a/flink-cdc-runtime/src/main/java/org/apache/flink/cdc/runtime/operators/schema/regular/SchemaCoordinator.java
 
b/flink-cdc-runtime/src/main/java/org/apache/flink/cdc/runtime/operators/schema/regular/SchemaCoordinator.java
index 3a241aac48..da7d78e306 100644
--- 
a/flink-cdc-runtime/src/main/java/org/apache/flink/cdc/runtime/operators/schema/regular/SchemaCoordinator.java
+++ 
b/flink-cdc-runtime/src/main/java/org/apache/flink/cdc/runtime/operators/schema/regular/SchemaCoordinator.java
@@ -64,6 +64,7 @@ import java.util.concurrent.CompletableFuture;
 import java.util.concurrent.ConcurrentHashMap;
 import java.util.concurrent.ExecutorService;
 import java.util.concurrent.Executors;
+import java.util.concurrent.TimeUnit;
 import java.util.concurrent.TimeoutException;
 import java.util.stream.Collectors;
 
@@ -74,7 +75,7 @@ public class SchemaCoordinator extends SchemaRegistry {
     private static final Logger LOG = 
LoggerFactory.getLogger(SchemaCoordinator.class);
 
     /** Executor service to execute schema change. */
-    private final ExecutorService schemaChangeThreadPool;
+    private transient ExecutorService schemaChangeThreadPool;
 
     /**
      * Sink writers which have sent flush success events for the request.<br>
@@ -117,10 +118,28 @@ public class SchemaCoordinator extends SchemaRegistry {
     }
 
     @Override
-    public void start() throws Exception {
-        super.start();
+    protected void initialize() {
+        if (pendingRequests != null) {
+            pendingRequests.forEach(
+                    (index, tuple) ->
+                            tuple.f1.completeExceptionally(
+                                    new FlinkRuntimeException(
+                                            "Schema coordinator request was 
cancelled by checkpoint reset.")));
+        }
         this.flushedSinkWriters = new ConcurrentHashMap<>();
         this.pendingRequests = new ConcurrentHashMap<>();
+        if (schemaChangeThreadPool == null || 
schemaChangeThreadPool.isShutdown()) {
+            schemaChangeThreadPool = Executors.newSingleThreadExecutor();
+        }
+    }
+
+    @Override
+    protected void shutdown() throws Exception {
+        schemaChangeThreadPool.shutdownNow();
+        if (!schemaChangeThreadPool.awaitTermination(
+                rpcTimeout.toMillis(), TimeUnit.MILLISECONDS)) {
+            throw new TimeoutException("Schema change executor did not 
terminate during reset.");
+        }
     }
 
     @Override
@@ -227,10 +246,9 @@ public class SchemaCoordinator extends SchemaRegistry {
         super.handleUnrecoverableError(taskDescription, t);
 
         // For each pending future, release it exceptionally before quitting
-        pendingRequests.forEach(
-                (index, tuple) -> {
-                    tuple.f1.completeExceptionally(t);
-                });
+        if (pendingRequests != null) {
+            pendingRequests.forEach((index, tuple) -> 
tuple.f1.completeExceptionally(t));
+        }
     }
 
     /**
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
new file mode 100644
index 0000000000..35d92e1764
--- /dev/null
+++ 
b/flink-cdc-runtime/src/test/java/org/apache/flink/cdc/runtime/operators/schema/distributed/SchemaCoordinatorTest.java
@@ -0,0 +1,426 @@
+/*
+ * 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.runtime.operators.schema.distributed;
+
+import org.apache.flink.cdc.common.event.AddColumnEvent;
+import org.apache.flink.cdc.common.event.CreateTableEvent;
+import org.apache.flink.cdc.common.event.SchemaChangeEvent;
+import org.apache.flink.cdc.common.event.TableId;
+import org.apache.flink.cdc.common.exceptions.SchemaEvolveException;
+import org.apache.flink.cdc.common.pipeline.RouteMode;
+import org.apache.flink.cdc.common.pipeline.SchemaChangeBehavior;
+import org.apache.flink.cdc.common.schema.Column;
+import org.apache.flink.cdc.common.schema.Schema;
+import org.apache.flink.cdc.common.sink.MetadataApplier;
+import org.apache.flink.cdc.common.types.DataTypes;
+import org.apache.flink.cdc.common.utils.SchemaUtils;
+import 
org.apache.flink.cdc.runtime.operators.schema.common.event.FlushSuccessEvent;
+import 
org.apache.flink.cdc.runtime.operators.schema.common.event.GetEvolvedSchemaRequest;
+import 
org.apache.flink.cdc.runtime.operators.schema.common.event.GetEvolvedSchemaResponse;
+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.runtime.jobgraph.OperatorID;
+import org.apache.flink.runtime.operators.coordination.CoordinationResponse;
+import org.apache.flink.runtime.operators.coordination.OperatorCoordinator;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.LinkedBlockingQueue;
+import java.util.concurrent.ThreadPoolExecutor;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicReference;
+
+import static 
org.apache.flink.cdc.runtime.operators.schema.common.CoordinationResponseUtils.unwrap;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/** Tests for {@link SchemaCoordinator}. */
+class SchemaCoordinatorTest {
+
+    private static final TableId TABLE_ID = 
TableId.parse("db.schema_recovery");
+    private static final Schema INITIAL_SCHEMA =
+            Schema.newBuilder().physicalColumn("id", 
DataTypes.INT().notNull()).build();
+    private static final AddColumnEvent ADD_EXTRA_V2 =
+            new AddColumnEvent(
+                    TABLE_ID,
+                    Collections.singletonList(
+                            new AddColumnEvent.ColumnWithPosition(
+                                    Column.physicalColumn("extra_v2", 
DataTypes.STRING()),
+                                    AddColumnEvent.ColumnPosition.LAST,
+                                    null)));
+    private static final Schema EVOLVED_SCHEMA =
+            Schema.newBuilder()
+                    .physicalColumn("id", DataTypes.INT().notNull())
+                    .physicalColumn("extra_v2", DataTypes.STRING())
+                    .build();
+
+    @Test
+    void resetsFailedSchemaEvolutionBeforeReplayingSchemaEvents() throws 
Exception {
+        FailOnceMetadataApplier metadataApplier = new 
FailOnceMetadataApplier();
+        ExecutorService coordinatorExecutor = 
Executors.newSingleThreadExecutor();
+        SchemaCoordinator coordinator =
+                new SchemaCoordinator(
+                        "schema-coordinator",
+                        new MockedOperatorCoordinatorContext(
+                                new OperatorID(), 
Thread.currentThread().getContextClassLoader()),
+                        coordinatorExecutor,
+                        metadataApplier,
+                        Collections.emptyList(),
+                        RouteMode.ALL_MATCH,
+                        SchemaChangeBehavior.LENIENT,
+                        Duration.ofSeconds(10));
+        coordinator.start();
+
+        try {
+            requestSchemaChange(coordinator, new CreateTableEvent(TABLE_ID, 
INITIAL_SCHEMA));
+            byte[] checkpoint = checkpoint(coordinator);
+
+            CompletableFuture<CoordinationResponse> failedRequest =
+                    submitSchemaChange(coordinator, ADD_EXTRA_V2);
+            assertThat(metadataApplier.failedAdd.await(10, 
TimeUnit.SECONDS)).isTrue();
+            assertThatThrownBy(() -> failedRequest.get(10, TimeUnit.SECONDS))
+                    .isInstanceOf(ExecutionException.class);
+
+            coordinator.resetToCheckpoint(1L, checkpoint);
+
+            requestSchemaChange(coordinator, new CreateTableEvent(TABLE_ID, 
INITIAL_SCHEMA));
+            SchemaChangeResponse response = requestSchemaChange(coordinator, 
ADD_EXTRA_V2);
+
+            assertThat(response.getEvolvedSchemas()).containsEntry(TABLE_ID, 
EVOLVED_SCHEMA);
+            
assertThat(response.getEvolvedSchemaChangeEvents()).containsExactly(ADD_EXTRA_V2);
+            
assertThat(metadataApplier.physicalSchemas).containsEntry(TABLE_ID, 
EVOLVED_SCHEMA);
+        } finally {
+            coordinator.close();
+        }
+    }
+
+    @Test
+    void nullCheckpointClearsSchemaStateBeforeReplay() throws Exception {
+        FailOnceMetadataApplier metadataApplier = new 
FailOnceMetadataApplier(false);
+        SchemaCoordinator coordinator = createCoordinator(metadataApplier, 
Duration.ofSeconds(10));
+        coordinator.start();
+
+        try {
+            requestSchemaChange(coordinator, new CreateTableEvent(TABLE_ID, 
INITIAL_SCHEMA));
+            coordinator.resetToCheckpoint(1L, null);
+
+            SchemaChangeResponse response =
+                    requestSchemaChange(
+                            coordinator, new CreateTableEvent(TABLE_ID, 
INITIAL_SCHEMA));
+
+            assertThat(response.getEvolvedSchemaChangeEvents())
+                    .containsExactly(new CreateTableEvent(TABLE_ID, 
INITIAL_SCHEMA));
+            assertThat(metadataApplier.appliedEvents)
+                    .containsExactly(
+                            new CreateTableEvent(TABLE_ID, INITIAL_SCHEMA),
+                            new CreateTableEvent(TABLE_ID, INITIAL_SCHEMA));
+        } finally {
+            coordinator.close();
+        }
+    }
+
+    @Test
+    void restoresCheckpointBeforeStart() throws Exception {
+        FailOnceMetadataApplier sourceApplier = new 
FailOnceMetadataApplier(false);
+        SchemaCoordinator source = createCoordinator(sourceApplier, 
Duration.ofSeconds(10));
+        source.start();
+        byte[] checkpoint;
+        try {
+            requestSchemaChange(source, new CreateTableEvent(TABLE_ID, 
INITIAL_SCHEMA));
+            checkpoint = checkpoint(source);
+        } finally {
+            source.close();
+        }
+
+        SchemaCoordinator restored =
+                createCoordinator(new FailOnceMetadataApplier(false), 
Duration.ofSeconds(10));
+        restored.resetToCheckpoint(1L, checkpoint);
+        restored.start();
+        try {
+            GetEvolvedSchemaResponse response =
+                    unwrap(
+                            restored.handleCoordinationRequest(
+                                            new GetEvolvedSchemaRequest(
+                                                    TABLE_ID,
+                                                    
GetEvolvedSchemaRequest.LATEST_SCHEMA_VERSION))
+                                    .get(10, TimeUnit.SECONDS));
+
+            assertThat(response.getSchema()).contains(INITIAL_SCHEMA);
+        } finally {
+            restored.close();
+        }
+    }
+
+    @ParameterizedTest
+    @ValueSource(booleans = {false, true})
+    void resetWaitsForAnUninterruptibleSchemaChangeBeforeRetrying(boolean 
failLate)
+            throws Exception {
+        FailOnceMetadataApplier metadataApplier =
+                new FailOnceMetadataApplier(false, true, failLate);
+        CountingCoordinatorContext context = new CountingCoordinatorContext();
+        SchemaCoordinator coordinator =
+                createCoordinator(metadataApplier, context, 
Duration.ofMillis(200));
+        coordinator.start();
+
+        try {
+            requestSchemaChange(coordinator, new CreateTableEvent(TABLE_ID, 
INITIAL_SCHEMA));
+            CompletableFuture<CoordinationResponse> oldRequest =
+                    submitSchemaChange(coordinator, ADD_EXTRA_V2);
+            assertThat(metadataApplier.blockedAdd.await(10, 
TimeUnit.SECONDS)).isTrue();
+
+            assertThatThrownBy(() -> coordinator.resetToCheckpoint(1L, null))
+                    .isInstanceOf(TimeoutException.class);
+            CompletableFuture<CoordinationResponse> rejectedRequest =
+                    coordinator.handleCoordinationRequest(
+                            new GetEvolvedSchemaRequest(
+                                    TABLE_ID, 
GetEvolvedSchemaRequest.LATEST_SCHEMA_VERSION));
+            CompletableFuture<byte[]> rejectedCheckpoint = new 
CompletableFuture<>();
+            coordinator.checkpointCoordinator(2L, rejectedCheckpoint);
+            assertThatThrownBy(() -> rejectedRequest.get(1, TimeUnit.SECONDS))
+                    .isInstanceOf(ExecutionException.class);
+            assertThatThrownBy(() -> rejectedCheckpoint.get(1, 
TimeUnit.SECONDS))
+                    .isInstanceOf(ExecutionException.class);
+            assertThat(metadataApplier.appliedEvents).hasSize(1);
+
+            metadataApplier.releaseAdd.countDown();
+            coordinator.resetToCheckpoint(1L, null);
+            if (failLate) {
+                assertThatThrownBy(() -> oldRequest.get(10, TimeUnit.SECONDS))
+                        .isInstanceOf(ExecutionException.class);
+            } else {
+                oldRequest.get(10, TimeUnit.SECONDS);
+            }
+            assertThat(context.failures.get()).isZero();
+
+            requestSchemaChange(coordinator, new CreateTableEvent(TABLE_ID, 
INITIAL_SCHEMA));
+            requestSchemaChange(coordinator, ADD_EXTRA_V2);
+        } finally {
+            metadataApplier.releaseAdd.countDown();
+            coordinator.close();
+        }
+    }
+
+    @Test
+    void rejectsQueuedRequestAndCheckpointAcrossReset() throws Exception {
+        CountDownLatch releaseExecutor = new CountDownLatch(1);
+        CountingExecutor coordinatorExecutor = new 
CountingExecutor(releaseExecutor);
+        FailOnceMetadataApplier metadataApplier = new 
FailOnceMetadataApplier(false);
+        SchemaCoordinator coordinator =
+                new SchemaCoordinator(
+                        "schema-coordinator",
+                        new CountingCoordinatorContext(),
+                        coordinatorExecutor,
+                        metadataApplier,
+                        Collections.emptyList(),
+                        RouteMode.ALL_MATCH,
+                        SchemaChangeBehavior.LENIENT,
+                        Duration.ofSeconds(2));
+        AtomicReference<Throwable> resetFailure = new AtomicReference<>();
+        SchemaCoordinator resettingCoordinator = coordinator;
+        Thread resetThread =
+                new Thread(
+                        () -> {
+                            try {
+                                resettingCoordinator.resetToCheckpoint(1L, 
null);
+                            } catch (Throwable t) {
+                                resetFailure.set(t);
+                            }
+                        });
+        try {
+            coordinator.start();
+            CompletableFuture<CoordinationResponse> request =
+                    coordinator.handleCoordinationRequest(
+                            new SchemaChangeRequest(
+                                    0, 0, new CreateTableEvent(TABLE_ID, 
INITIAL_SCHEMA)));
+            CompletableFuture<byte[]> checkpoint = new CompletableFuture<>();
+            coordinator.checkpointCoordinator(1L, checkpoint);
+            resetThread.start();
+            assertThat(coordinatorExecutor.barrierSubmitted.await(10, 
TimeUnit.SECONDS)).isTrue();
+            releaseExecutor.countDown();
+            resetThread.join(10_000);
+            assertThat(resetThread.isAlive()).isFalse();
+            assertThat(resetFailure.get()).isNull();
+            assertThatThrownBy(() -> request.get(1, TimeUnit.SECONDS))
+                    .isInstanceOf(ExecutionException.class);
+            assertThatThrownBy(() -> checkpoint.get(1, TimeUnit.SECONDS))
+                    .isInstanceOf(ExecutionException.class);
+            assertThat(metadataApplier.appliedEvents).isEmpty();
+        } finally {
+            releaseExecutor.countDown();
+            resetThread.join(10_000);
+            coordinator.close();
+        }
+    }
+
+    private static SchemaCoordinator createCoordinator(
+            FailOnceMetadataApplier metadataApplier, Duration rpcTimeout) {
+        return createCoordinator(
+                metadataApplier,
+                new MockedOperatorCoordinatorContext(
+                        new OperatorID(), 
Thread.currentThread().getContextClassLoader()),
+                rpcTimeout);
+    }
+
+    private static SchemaCoordinator createCoordinator(
+            FailOnceMetadataApplier metadataApplier,
+            OperatorCoordinator.Context context,
+            Duration rpcTimeout) {
+        return new SchemaCoordinator(
+                "schema-coordinator",
+                context,
+                Executors.newSingleThreadExecutor(),
+                metadataApplier,
+                Collections.emptyList(),
+                RouteMode.ALL_MATCH,
+                SchemaChangeBehavior.LENIENT,
+                rpcTimeout);
+    }
+
+    private static byte[] checkpoint(SchemaCoordinator coordinator) throws 
Exception {
+        CompletableFuture<byte[]> checkpoint = new CompletableFuture<>();
+        coordinator.checkpointCoordinator(1L, checkpoint);
+        return checkpoint.get(10, TimeUnit.SECONDS);
+    }
+
+    private static SchemaChangeResponse requestSchemaChange(
+            SchemaCoordinator coordinator, SchemaChangeEvent event) throws 
Exception {
+        return unwrap(submitSchemaChange(coordinator, event).get(10, 
TimeUnit.SECONDS));
+    }
+
+    private static CompletableFuture<CoordinationResponse> submitSchemaChange(
+            SchemaCoordinator coordinator, SchemaChangeEvent event) {
+        CompletableFuture<CoordinationResponse> response =
+                coordinator.handleCoordinationRequest(new 
SchemaChangeRequest(0, 0, event));
+        coordinator.handleEventFromOperator(0, 0, new FlushSuccessEvent(0, 0));
+        return response;
+    }
+
+    private static final class FailOnceMetadataApplier implements 
MetadataApplier {
+        private final AtomicBoolean failNextAdd;
+        private final CountDownLatch failedAdd = new CountDownLatch(1);
+        private final CountDownLatch blockedAdd = new CountDownLatch(1);
+        private final CountDownLatch releaseAdd = new CountDownLatch(1);
+        private final Map<TableId, Schema> physicalSchemas = new 
ConcurrentHashMap<>();
+        private final List<SchemaChangeEvent> appliedEvents = new 
ArrayList<>();
+        private final boolean blockAdd;
+        private final AtomicBoolean failLate;
+
+        private FailOnceMetadataApplier() {
+            this(true);
+        }
+
+        private FailOnceMetadataApplier(boolean failNextAdd) {
+            this(failNextAdd, false);
+        }
+
+        private FailOnceMetadataApplier(boolean failNextAdd, boolean blockAdd) 
{
+            this(failNextAdd, blockAdd, false);
+        }
+
+        private FailOnceMetadataApplier(boolean failNextAdd, boolean blockAdd, 
boolean failLate) {
+            this.failNextAdd = new AtomicBoolean(failNextAdd);
+            this.blockAdd = blockAdd;
+            this.failLate = new AtomicBoolean(failLate);
+        }
+
+        @Override
+        public void applySchemaChange(SchemaChangeEvent schemaChangeEvent) {
+            if (schemaChangeEvent instanceof AddColumnEvent
+                    && failNextAdd.compareAndSet(true, false)) {
+                failedAdd.countDown();
+                throw new SchemaEvolveException(schemaChangeEvent, "Injected 
metadata failure");
+            }
+            if (schemaChangeEvent instanceof AddColumnEvent && blockAdd) {
+                blockedAdd.countDown();
+                while (releaseAdd.getCount() > 0) {
+                    try {
+                        releaseAdd.await();
+                    } catch (InterruptedException ignored) {
+                        // Simulates an external DDL call that ignores 
interruption.
+                    }
+                }
+            }
+            if (schemaChangeEvent instanceof AddColumnEvent
+                    && failLate.compareAndSet(true, false)) {
+                throw new SchemaEvolveException(
+                        schemaChangeEvent, "Injected late metadata failure");
+            }
+            physicalSchemas.put(
+                    schemaChangeEvent.tableId(),
+                    SchemaUtils.applySchemaChangeEvent(
+                            physicalSchemas.get(schemaChangeEvent.tableId()), 
schemaChangeEvent));
+            appliedEvents.add(schemaChangeEvent);
+        }
+    }
+
+    private static final class CountingCoordinatorContext extends 
MockedOperatorCoordinatorContext {
+        private final AtomicInteger failures = new AtomicInteger();
+
+        private CountingCoordinatorContext() {
+            super(new OperatorID(), 
Thread.currentThread().getContextClassLoader());
+        }
+
+        @Override
+        public void failJob(Throwable cause) {
+            failures.incrementAndGet();
+            super.failJob(cause);
+        }
+    }
+
+    private static final class CountingExecutor extends ThreadPoolExecutor {
+        private final AtomicInteger submissions = new AtomicInteger();
+        private final CountDownLatch barrierSubmitted = new CountDownLatch(1);
+
+        private CountingExecutor(CountDownLatch release) {
+            super(1, 1, 0, TimeUnit.MILLISECONDS, new LinkedBlockingQueue<>());
+            execute(
+                    () -> {
+                        try {
+                            release.await();
+                        } catch (InterruptedException e) {
+                            Thread.currentThread().interrupt();
+                        }
+                    });
+        }
+
+        @Override
+        public void execute(Runnable command) {
+            if (submissions.incrementAndGet() == 4) {
+                barrierSubmitted.countDown();
+            }
+            super.execute(command);
+        }
+    }
+}
diff --git 
a/flink-cdc-runtime/src/test/java/org/apache/flink/cdc/runtime/operators/schema/regular/SchemaCoordinatorTest.java
 
b/flink-cdc-runtime/src/test/java/org/apache/flink/cdc/runtime/operators/schema/regular/SchemaCoordinatorTest.java
new file mode 100644
index 0000000000..a30127f510
--- /dev/null
+++ 
b/flink-cdc-runtime/src/test/java/org/apache/flink/cdc/runtime/operators/schema/regular/SchemaCoordinatorTest.java
@@ -0,0 +1,137 @@
+/*
+ * 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.runtime.operators.schema.regular;
+
+import org.apache.flink.cdc.common.event.AddColumnEvent;
+import org.apache.flink.cdc.common.event.CreateTableEvent;
+import org.apache.flink.cdc.common.event.SchemaChangeEvent;
+import org.apache.flink.cdc.common.event.TableId;
+import org.apache.flink.cdc.common.exceptions.SchemaEvolveException;
+import org.apache.flink.cdc.common.pipeline.RouteMode;
+import org.apache.flink.cdc.common.pipeline.SchemaChangeBehavior;
+import org.apache.flink.cdc.common.schema.Column;
+import org.apache.flink.cdc.common.schema.Schema;
+import org.apache.flink.cdc.common.sink.MetadataApplier;
+import org.apache.flink.cdc.common.types.DataTypes;
+import 
org.apache.flink.cdc.runtime.operators.schema.common.event.FlushSuccessEvent;
+import 
org.apache.flink.cdc.runtime.operators.schema.regular.event.SchemaChangeRequest;
+import 
org.apache.flink.cdc.runtime.operators.schema.regular.event.SchemaChangeResponse;
+import 
org.apache.flink.cdc.runtime.testutils.operators.MockedOperatorCoordinatorContext;
+import org.apache.flink.runtime.jobgraph.OperatorID;
+import org.apache.flink.runtime.operators.coordination.CoordinationResponse;
+
+import org.junit.jupiter.api.Test;
+
+import java.time.Duration;
+import java.util.Collections;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.Executors;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+import static 
org.apache.flink.cdc.runtime.operators.schema.common.CoordinationResponseUtils.unwrap;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/** Tests for the regular {@link SchemaCoordinator}. */
+class SchemaCoordinatorTest {
+
+    private static final TableId TABLE_ID = 
TableId.parse("db.regular_recovery");
+    private static final Schema INITIAL_SCHEMA =
+            Schema.newBuilder().physicalColumn("id", 
DataTypes.INT().notNull()).build();
+    private static final AddColumnEvent ADD_EXTRA_V2 =
+            new AddColumnEvent(
+                    TABLE_ID,
+                    Collections.singletonList(
+                            new AddColumnEvent.ColumnWithPosition(
+                                    Column.physicalColumn("extra_v2", 
DataTypes.STRING()),
+                                    AddColumnEvent.ColumnPosition.LAST,
+                                    null)));
+
+    @Test
+    void resetsFailedSchemaEvolutionBeforeReplay() throws Exception {
+        FailOnceMetadataApplier metadataApplier = new 
FailOnceMetadataApplier();
+        SchemaCoordinator coordinator =
+                new SchemaCoordinator(
+                        "regular-schema-coordinator",
+                        new MockedOperatorCoordinatorContext(
+                                new OperatorID(), 
Thread.currentThread().getContextClassLoader()),
+                        Executors.newSingleThreadExecutor(),
+                        metadataApplier,
+                        Collections.emptyList(),
+                        RouteMode.ALL_MATCH,
+                        SchemaChangeBehavior.LENIENT,
+                        Duration.ofSeconds(10));
+        coordinator.start();
+
+        try {
+            requestSchemaChange(coordinator, new CreateTableEvent(TABLE_ID, 
INITIAL_SCHEMA));
+            byte[] checkpoint = checkpoint(coordinator);
+
+            CompletableFuture<CoordinationResponse> failedRequest =
+                    submitSchemaChange(coordinator, ADD_EXTRA_V2);
+            assertThat(metadataApplier.failedAdd.await(10, 
TimeUnit.SECONDS)).isTrue();
+            assertThatThrownBy(() -> failedRequest.get(10, TimeUnit.SECONDS))
+                    .isInstanceOf(ExecutionException.class);
+
+            coordinator.resetToCheckpoint(1L, checkpoint);
+
+            requestSchemaChange(coordinator, new CreateTableEvent(TABLE_ID, 
INITIAL_SCHEMA));
+            SchemaChangeResponse response = requestSchemaChange(coordinator, 
ADD_EXTRA_V2);
+
+            
assertThat(response.getAppliedSchemaChangeEvents()).containsExactly(ADD_EXTRA_V2);
+        } finally {
+            coordinator.close();
+        }
+    }
+
+    private static byte[] checkpoint(SchemaCoordinator coordinator) throws 
Exception {
+        CompletableFuture<byte[]> checkpoint = new CompletableFuture<>();
+        coordinator.checkpointCoordinator(1L, checkpoint);
+        return checkpoint.get(10, TimeUnit.SECONDS);
+    }
+
+    private static SchemaChangeResponse requestSchemaChange(
+            SchemaCoordinator coordinator, SchemaChangeEvent event) throws 
Exception {
+        return unwrap(submitSchemaChange(coordinator, event).get(10, 
TimeUnit.SECONDS));
+    }
+
+    private static CompletableFuture<CoordinationResponse> submitSchemaChange(
+            SchemaCoordinator coordinator, SchemaChangeEvent event) {
+        CompletableFuture<CoordinationResponse> response =
+                coordinator.handleCoordinationRequest(new 
SchemaChangeRequest(TABLE_ID, event, 0));
+        coordinator.handleEventFromOperator(0, 0, new FlushSuccessEvent(0, 0));
+        return response;
+    }
+
+    private static final class FailOnceMetadataApplier implements 
MetadataApplier {
+        private final AtomicBoolean failNextAdd = new AtomicBoolean(true);
+        private final CountDownLatch failedAdd = new CountDownLatch(1);
+
+        @Override
+        public void applySchemaChange(SchemaChangeEvent schemaChangeEvent) {
+            if (schemaChangeEvent instanceof AddColumnEvent
+                    && failNextAdd.compareAndSet(true, false)) {
+                failedAdd.countDown();
+                throw new SchemaEvolveException(schemaChangeEvent, "Injected 
metadata failure");
+            }
+        }
+    }
+}

Reply via email to