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

davidzollo pushed a commit to branch dev
in repository https://gitbox.apache.org/repos/asf/seatunnel.git


The following commit(s) were added to refs/heads/dev by this push:
     new 5bdeda5fde [Fix][Connector-V2] Support schema evolution in Redis sink 
(#11646)
5bdeda5fde is described below

commit 5bdeda5fde41c51b5e719f94f5eb8df494c4576f
Author: limin <[email protected]>
AuthorDate: Sun Aug 23 09:34:57 2026 +0800

    [Fix][Connector-V2] Support schema evolution in Redis sink (#11646)
    
    Co-authored-by: limin <[email protected]>
---
 docs/en/connectors/sink/Redis.md                   |  19 +
 .../introduction/configuration/schema-evolution.md |   1 +
 docs/zh/connectors/sink/Redis.md                   |  16 +
 .../introduction/configuration/schema-evolution.md |   1 +
 .../connectors/seatunnel/redis/sink/RedisSink.java |  63 ++-
 .../seatunnel/redis/sink/RedisSinkWriter.java      |  81 +++-
 .../seatunnel/redis/RedisFactoryTest.java          |  17 +
 .../seatunnel/redis/sink/RedisSinkTest.java        | 212 ++++++++++
 .../seatunnel/redis/sink/RedisSinkWriterTest.java  | 154 ++++++++
 .../connector-redis-e2e/pom.xml                    |  26 ++
 .../e2e/connector/redis/RedisSchemaChangeIT.java   | 438 +++++++++++++++++++++
 .../mysqlcdc_to_redis_with_schema_change.conf      |  46 +++
 12 files changed, 1063 insertions(+), 11 deletions(-)

diff --git a/docs/en/connectors/sink/Redis.md b/docs/en/connectors/sink/Redis.md
index ea0b18d739..eaa81845b0 100644
--- a/docs/en/connectors/sink/Redis.md
+++ b/docs/en/connectors/sink/Redis.md
@@ -96,6 +96,25 @@ Replica count for multi-table sink writers. It applies when 
upstream rows carry
 For multi-table jobs, `key` may include `${table_name}` so rows from different 
upstream tables are written to separate
 Redis keys, for example `key = "redis-result-${table_name}"`.
 
+## Schema Evolution
+
+Redis Sink supports schema evolution with SeaTunnel Zeta. When the upstream is 
a CDC source, enable
+`schema-changes.enabled = true` in the source configuration so schema change 
events are sent to the sink.
+
+Redis is schema-less, so schema evolution does not execute DDL in Redis. 
Instead, when Redis Sink serializes the whole
+upstream row as JSON or TEXT, it refreshes the serializer after a supported 
schema change event. Newly added fields are
+included, and dropped fields are no longer written. See
+[Schema Evolution](../../introduction/configuration/schema-evolution.md) for 
the supported event types.
+
+Schema evolution does not rewrite field names configured in `key`, custom key 
placeholders, `value_field`,
+`hash_key_field`, or `hash_value_field`. Do not rename or drop a field 
referenced by these options while the job is
+running. If a configured field no longer exists, Redis Sink applies the 
missing-field behavior described in
+[Write Rules](#write-rules), which can turn the configured field name into a 
literal key or value.
+
+Before applying a schema change, Redis Sink flushes rows buffered with the 
previous schema. It also stores the latest
+schema in checkpoint state and restores that schema after recovery. Restoring 
a job from a checkpoint taken after a DDL
+while increasing the Redis sink parallelism is not currently supported.
+
 ## Examples
 
 ### Write Rows To A Redis List
diff --git a/docs/en/introduction/configuration/schema-evolution.md 
b/docs/en/introduction/configuration/schema-evolution.md
index 70af86ea2d..4cd6612059 100644
--- a/docs/en/introduction/configuration/schema-evolution.md
+++ b/docs/en/introduction/configuration/schema-evolution.md
@@ -28,6 +28,7 @@ Schema Evolution means that the schema of a data table can be 
changed and the da
 [Doris](../../connectors/sink/Doris.md)
 [Paimon](../../connectors/sink/Paimon.md#schema-evolution)
 [Elasticsearch](../../connectors/sink/Elasticsearch.md#schema-evolution)
+[Redis](../../connectors/sink/Redis.md#schema-evolution)
 
 Note:  
 * The schema evolution is not support the transform at now. The schema 
evolution of different types of databases(Oracle-CDC -> Jdbc-Mysql)is currently 
not supported the default value of the column in ddl.
diff --git a/docs/zh/connectors/sink/Redis.md b/docs/zh/connectors/sink/Redis.md
index d08cd96d95..c430f69bb6 100644
--- a/docs/zh/connectors/sink/Redis.md
+++ b/docs/zh/connectors/sink/Redis.md
@@ -93,6 +93,22 @@ Redis 接收器连接器可以在批处理或流处理作业中把上游数据
 多表作业中,`key` 可以包含 `${table_name}`,这样不同上游表的数据会写入不同 Redis key,例如
 `key = "redis-result-${table_name}"`。
 
+## 模式演变
+
+Redis Sink 在 SeaTunnel Zeta 引擎中支持模式演变。上游使用 CDC Source 时,需要在 Source 配置中设置
+`schema-changes.enabled = true`,使模式变更事件能够发送到 Sink。
+
+Redis 本身没有表结构,因此模式演变不会在 Redis 中执行 DDL。当 Redis Sink 使用 JSON 或 TEXT
+序列化完整上游行时,它会在收到支持的模式变更事件后更新序列化器:新增字段会写入 Redis,已删除字段不再写入。
+支持的事件类型请参见[模式演变](../../introduction/configuration/schema-evolution.md)。
+
+模式演变不会自动改写 `key`、自定义 Key 占位符、`value_field`、`hash_key_field` 或 
`hash_value_field` 中配置的
+字段名。作业运行期间,请勿重命名或删除这些选项引用的字段。如果配置的字段已不存在,Redis Sink 会按
+[写入规则](#写入规则)中的缺失字段行为处理,配置的字段名可能会被当作字面量 Key 或 Value 写入。
+
+应用模式变更前,Redis Sink 会先刷新按旧模式缓存的数据。它还会把最新模式保存到 checkpoint 状态,并在恢复时使用
+该模式。当前不支持从包含 DDL 后状态的 checkpoint 恢复作业时增加 Redis Sink 的并行度。
+
 ## 示例
 
 ### 写入 Redis List
diff --git a/docs/zh/introduction/configuration/schema-evolution.md 
b/docs/zh/introduction/configuration/schema-evolution.md
index 7dd6cb8ee0..359d7c4991 100644
--- a/docs/zh/introduction/configuration/schema-evolution.md
+++ b/docs/zh/introduction/configuration/schema-evolution.md
@@ -28,6 +28,7 @@
 [Doris](../../connectors/sink/Doris.md)
 [Paimon](../../connectors/sink/Paimon.md#模式演变)
 [Elasticsearch](../../connectors/sink/Elasticsearch.md#模式演变)
+[Redis](../../connectors/sink/Redis.md#模式演变)
 
 注意: 
 * 目前模式演进不支持transform。不同类型数据库(Oracle-CDC -> Jdbc-Mysql)的模式演进目前不支持ddl中列的默认值。
diff --git 
a/seatunnel-connectors-v2/connector-redis/src/main/java/org/apache/seatunnel/connectors/seatunnel/redis/sink/RedisSink.java
 
b/seatunnel-connectors-v2/connector-redis/src/main/java/org/apache/seatunnel/connectors/seatunnel/redis/sink/RedisSink.java
index 67774821ed..215be889ca 100644
--- 
a/seatunnel-connectors-v2/connector-redis/src/main/java/org/apache/seatunnel/connectors/seatunnel/redis/sink/RedisSink.java
+++ 
b/seatunnel-connectors-v2/connector-redis/src/main/java/org/apache/seatunnel/connectors/seatunnel/redis/sink/RedisSink.java
@@ -18,22 +18,28 @@
 package org.apache.seatunnel.connectors.seatunnel.redis.sink;
 
 import org.apache.seatunnel.api.configuration.ReadonlyConfig;
+import org.apache.seatunnel.api.serialization.DefaultSerializer;
+import org.apache.seatunnel.api.serialization.Serializer;
 import org.apache.seatunnel.api.sink.SinkWriter;
 import org.apache.seatunnel.api.sink.SupportMultiTableSink;
+import org.apache.seatunnel.api.sink.SupportSchemaEvolutionSink;
 import org.apache.seatunnel.api.table.catalog.CatalogTable;
+import org.apache.seatunnel.api.table.catalog.TableSchema;
+import org.apache.seatunnel.api.table.schema.SchemaChangeType;
 import org.apache.seatunnel.api.table.type.SeaTunnelRow;
-import org.apache.seatunnel.api.table.type.SeaTunnelRowType;
 import 
org.apache.seatunnel.connectors.seatunnel.common.sink.AbstractSimpleSink;
 import org.apache.seatunnel.connectors.seatunnel.redis.config.RedisBaseOptions;
 import org.apache.seatunnel.connectors.seatunnel.redis.config.RedisParameters;
 
 import java.io.IOException;
+import java.util.Arrays;
+import java.util.List;
 import java.util.Optional;
 
-public class RedisSink extends AbstractSimpleSink<SeaTunnelRow, Void>
-        implements SupportMultiTableSink {
+public class RedisSink extends AbstractSimpleSink<SeaTunnelRow, TableSchema>
+        implements SupportMultiTableSink, SupportSchemaEvolutionSink {
     private final RedisParameters redisParameters = new RedisParameters();
-    private final SeaTunnelRowType seaTunnelRowType;
+    private final TableSchema tableSchema;
     private final ReadonlyConfig readonlyConfig;
     private final CatalogTable catalogTable;
 
@@ -41,7 +47,7 @@ public class RedisSink extends 
AbstractSimpleSink<SeaTunnelRow, Void>
         this.readonlyConfig = config;
         this.catalogTable = table;
         this.redisParameters.buildWithConfig(config);
-        this.seaTunnelRowType = catalogTable.getSeaTunnelRowType();
+        this.tableSchema = catalogTable.getTableSchema();
     }
 
     @Override
@@ -51,11 +57,56 @@ public class RedisSink extends 
AbstractSimpleSink<SeaTunnelRow, Void>
 
     @Override
     public RedisSinkWriter createWriter(SinkWriter.Context context) throws 
IOException {
-        return new RedisSinkWriter(seaTunnelRowType, redisParameters);
+        return new RedisSinkWriter(tableSchema, redisParameters);
+    }
+
+    /**
+     * Restores the latest writer schema. Rescaling can provide state from 
multiple writers, and
+     * every state must contain the same schema because selecting the widest 
schema would restore a
+     * stale definition after a drop-column event.
+     */
+    @Override
+    public RedisSinkWriter restoreWriter(SinkWriter.Context context, 
List<TableSchema> states)
+            throws IOException {
+        if (states == null || states.isEmpty()) {
+            return createWriter(context);
+        }
+        TableSchema restoredSchema = states.get(0);
+        for (int stateIndex = 1; stateIndex < states.size(); stateIndex++) {
+            TableSchema state = states.get(stateIndex);
+            if (!restoredSchema.equals(state)) {
+                throw new IOException(
+                        String.format(
+                                "Redis sink cannot restore writer for table %s 
because state 0 fields %s differ from state %d fields %s",
+                                catalogTable.getTablePath().getFullName(),
+                                schemaFields(restoredSchema),
+                                stateIndex,
+                                schemaFields(state)));
+            }
+        }
+        return new RedisSinkWriter(restoredSchema, redisParameters);
+    }
+
+    @Override
+    public Optional<Serializer<TableSchema>> getWriterStateSerializer() {
+        return Optional.of(new DefaultSerializer<>());
     }
 
     @Override
     public Optional<CatalogTable> getWriteCatalogTable() {
         return Optional.ofNullable(catalogTable);
     }
+
+    @Override
+    public List<SchemaChangeType> supports() {
+        return Arrays.asList(
+                SchemaChangeType.ADD_COLUMN,
+                SchemaChangeType.DROP_COLUMN,
+                SchemaChangeType.RENAME_COLUMN,
+                SchemaChangeType.UPDATE_COLUMN);
+    }
+
+    private static String schemaFields(TableSchema schema) {
+        return Arrays.toString(schema.toPhysicalRowDataType().getFieldNames());
+    }
 }
diff --git 
a/seatunnel-connectors-v2/connector-redis/src/main/java/org/apache/seatunnel/connectors/seatunnel/redis/sink/RedisSinkWriter.java
 
b/seatunnel-connectors-v2/connector-redis/src/main/java/org/apache/seatunnel/connectors/seatunnel/redis/sink/RedisSinkWriter.java
index 21b3233620..f4c5755c5f 100644
--- 
a/seatunnel-connectors-v2/connector-redis/src/main/java/org/apache/seatunnel/connectors/seatunnel/redis/sink/RedisSinkWriter.java
+++ 
b/seatunnel-connectors-v2/connector-redis/src/main/java/org/apache/seatunnel/connectors/seatunnel/redis/sink/RedisSinkWriter.java
@@ -22,7 +22,13 @@ import 
org.apache.seatunnel.shade.org.apache.commons.lang3.StringUtils;
 import org.apache.seatunnel.api.common.SeaTunnelAPIErrorCode;
 import org.apache.seatunnel.api.serialization.SerializationSchema;
 import org.apache.seatunnel.api.sink.SupportMultiTableSinkWriter;
+import org.apache.seatunnel.api.sink.SupportSchemaEvolutionSinkWriter;
+import org.apache.seatunnel.api.table.catalog.PhysicalColumn;
+import org.apache.seatunnel.api.table.catalog.TableSchema;
+import org.apache.seatunnel.api.table.schema.event.SchemaChangeEvent;
+import 
org.apache.seatunnel.api.table.schema.handler.TableSchemaChangeEventDispatcher;
 import org.apache.seatunnel.api.table.type.RowKind;
+import org.apache.seatunnel.api.table.type.SeaTunnelDataType;
 import org.apache.seatunnel.api.table.type.SeaTunnelRow;
 import org.apache.seatunnel.api.table.type.SeaTunnelRowType;
 import org.apache.seatunnel.common.constants.PluginType;
@@ -43,6 +49,7 @@ import lombok.extern.slf4j.Slf4j;
 import java.io.IOException;
 import java.util.ArrayList;
 import java.util.Arrays;
+import java.util.Collections;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
@@ -51,15 +58,25 @@ import java.util.regex.Matcher;
 import java.util.regex.Pattern;
 
 @Slf4j
-public class RedisSinkWriter extends AbstractSinkWriter<SeaTunnelRow, Void>
-        implements SupportMultiTableSinkWriter<Void> {
+public class RedisSinkWriter extends AbstractSinkWriter<SeaTunnelRow, 
TableSchema>
+        implements SupportMultiTableSinkWriter<Void>, 
SupportSchemaEvolutionSinkWriter {
     private static final Pattern LEGACY_PLACEHOLDER_PATTERN =
             Pattern.compile("(?<!\\$)\\{([^{}]+)\\}");
     private static final Pattern PLACEHOLDER_PATTERN = 
Pattern.compile("\\$\\{([^}]+)\\}");
-    private final SeaTunnelRowType seaTunnelRowType;
+
+    /** Authoritative schema saved in checkpoints and updated by ordered 
schema-change events. */
+    private TableSchema tableSchema;
+
+    /** Physical row type derived from {@link #tableSchema} for key and value 
field lookup. */
+    private SeaTunnelRowType seaTunnelRowType;
+
     private final RedisParameters redisParameters;
-    private final SerializationSchema serializationSchema;
+
+    /** Serializer derived from {@link #tableSchema} and refreshed together 
with the row type. */
+    private SerializationSchema serializationSchema;
+
     private final RedisClient redisClient;
+    private final TableSchemaChangeEventDispatcher schemaChangeEventDispatcher;
 
     private final int batchSize;
 
@@ -67,11 +84,25 @@ public class RedisSinkWriter extends 
AbstractSinkWriter<SeaTunnelRow, Void>
     private final List<String> keyBuffer;
     private final List<String> valueBuffer;
 
+    /**
+     * Creates a writer from a physical row type.
+     *
+     * @deprecated Use {@link #RedisSinkWriter(TableSchema, RedisParameters)} 
so schema evolution
+     *     and checkpoint recovery retain the complete table schema.
+     */
+    @Deprecated
     public RedisSinkWriter(SeaTunnelRowType seaTunnelRowType, RedisParameters 
redisParameters) {
-        this.seaTunnelRowType = seaTunnelRowType;
+        this(toTableSchema(seaTunnelRowType), redisParameters);
+    }
+
+    /** Creates a writer whose schema can be updated and restored from 
checkpoint state. */
+    public RedisSinkWriter(TableSchema tableSchema, RedisParameters 
redisParameters) {
+        this.tableSchema = normalizeTableSchema(tableSchema);
+        this.seaTunnelRowType = this.tableSchema.toPhysicalRowDataType();
         this.redisParameters = redisParameters;
         this.serializationSchema = createSerializationSchema(redisParameters, 
seaTunnelRowType);
         this.redisClient = redisParameters.buildRedisClient();
+        this.schemaChangeEventDispatcher = new 
TableSchemaChangeEventDispatcher();
         this.batchSize = redisParameters.getBatchSize();
         this.rowKinds = new ArrayList<>(batchSize);
         this.keyBuffer = new ArrayList<>(batchSize);
@@ -93,6 +124,40 @@ public class RedisSinkWriter extends 
AbstractSinkWriter<SeaTunnelRow, Void>
         log.debug("write redis key: {}, value: {}, rowKind: {}", key, value, 
element.getRowKind());
     }
 
+    /**
+     * Flushes all rows serialized with the previous schema before refreshing 
the writer's
+     * schema-derived views. The engine orders this callback with row 
processing, so all three views
+     * are updated before any subsequent row is processed.
+     */
+    @Override
+    public void applySchemaChange(SchemaChangeEvent event) {
+        flush();
+        tableSchema = 
schemaChangeEventDispatcher.reset(tableSchema).apply(event);
+        seaTunnelRowType = tableSchema.toPhysicalRowDataType();
+        serializationSchema = createSerializationSchema(redisParameters, 
seaTunnelRowType);
+    }
+
+    private static TableSchema normalizeTableSchema(TableSchema tableSchema) {
+        if (tableSchema.getConstraintKeys() != null) {
+            return tableSchema;
+        }
+        return TableSchema.builder()
+                .columns(tableSchema.getColumns())
+                .primaryKey(tableSchema.getPrimaryKey())
+                .build();
+    }
+
+    private static TableSchema toTableSchema(SeaTunnelRowType 
seaTunnelRowType) {
+        TableSchema.Builder schemaBuilder = TableSchema.builder();
+        String[] fieldNames = seaTunnelRowType.getFieldNames();
+        SeaTunnelDataType<?>[] fieldTypes = seaTunnelRowType.getFieldTypes();
+        for (int i = 0; i < fieldNames.length; i++) {
+            schemaBuilder.column(
+                    PhysicalColumn.of(fieldNames[i], fieldTypes[i], 0L, true, 
null, null));
+        }
+        return schemaBuilder.build();
+    }
+
     private String getKey(SeaTunnelRow element, List<String> fields) {
         String key = redisParameters.getKeyField();
         Boolean supportCustomKey = redisParameters.getSupportCustomKey();
@@ -291,6 +356,12 @@ public class RedisSinkWriter extends 
AbstractSinkWriter<SeaTunnelRow, Void>
         return Optional.empty();
     }
 
+    /** Returns an isolated copy of the latest schema for checkpoint and 
rescale recovery. */
+    @Override
+    public List<TableSchema> snapshotState(long checkpointId) {
+        return Collections.singletonList(tableSchema.copy());
+    }
+
     private synchronized void flush() {
         if (!keyBuffer.isEmpty()) {
             doBatchWrite();
diff --git 
a/seatunnel-connectors-v2/connector-redis/src/test/java/org/apache/seatunnel/connectors/seatunnel/redis/RedisFactoryTest.java
 
b/seatunnel-connectors-v2/connector-redis/src/test/java/org/apache/seatunnel/connectors/seatunnel/redis/RedisFactoryTest.java
index cf2ca61d79..67fb4a8989 100644
--- 
a/seatunnel-connectors-v2/connector-redis/src/test/java/org/apache/seatunnel/connectors/seatunnel/redis/RedisFactoryTest.java
+++ 
b/seatunnel-connectors-v2/connector-redis/src/test/java/org/apache/seatunnel/connectors/seatunnel/redis/RedisFactoryTest.java
@@ -21,6 +21,7 @@ import org.apache.seatunnel.api.configuration.ReadonlyConfig;
 import org.apache.seatunnel.api.configuration.util.ConfigValidator;
 import org.apache.seatunnel.api.configuration.util.OptionRule;
 import org.apache.seatunnel.api.configuration.util.OptionValidationException;
+import org.apache.seatunnel.api.serialization.Serializer;
 import org.apache.seatunnel.api.table.catalog.CatalogTable;
 import org.apache.seatunnel.api.table.catalog.PhysicalColumn;
 import org.apache.seatunnel.api.table.catalog.TableIdentifier;
@@ -28,6 +29,7 @@ import org.apache.seatunnel.api.table.catalog.TableSchema;
 import org.apache.seatunnel.api.table.factory.TableSinkFactoryContext;
 import org.apache.seatunnel.api.table.factory.TableSourceFactoryContext;
 import org.apache.seatunnel.api.table.type.BasicType;
+import org.apache.seatunnel.connectors.seatunnel.redis.sink.RedisSink;
 import org.apache.seatunnel.connectors.seatunnel.redis.sink.RedisSinkFactory;
 import 
org.apache.seatunnel.connectors.seatunnel.redis.source.RedisSourceFactory;
 
@@ -37,6 +39,7 @@ import org.junit.jupiter.params.ParameterizedTest;
 import org.junit.jupiter.params.provider.Arguments;
 import org.junit.jupiter.params.provider.MethodSource;
 
+import java.io.IOException;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collections;
@@ -144,6 +147,20 @@ class RedisFactoryTest {
                 () -> new RedisSinkFactory().createSink(context).createSink());
     }
 
+    @Test
+    void sinkExposesSchemaWriterStateSerializer() throws IOException {
+        RedisSink sink = new 
RedisSink(ReadonlyConfig.fromMap(singleSinkConfig()), catalogTable());
+
+        Assertions.assertTrue(sink.getWriterStateSerializer().isPresent());
+        @SuppressWarnings("unchecked")
+        Serializer<TableSchema> serializer =
+                (Serializer<TableSchema>) (Serializer<?>) 
sink.getWriterStateSerializer().get();
+        TableSchema tableSchema = catalogTable().getTableSchema();
+
+        Assertions.assertEquals(
+                tableSchema, 
serializer.deserialize(serializer.serialize(tableSchema)));
+    }
+
     // parameterized-case providers
 
     static Stream<Arguments> invalidSingleConnections() {
diff --git 
a/seatunnel-connectors-v2/connector-redis/src/test/java/org/apache/seatunnel/connectors/seatunnel/redis/sink/RedisSinkTest.java
 
b/seatunnel-connectors-v2/connector-redis/src/test/java/org/apache/seatunnel/connectors/seatunnel/redis/sink/RedisSinkTest.java
new file mode 100644
index 0000000000..e387639cb1
--- /dev/null
+++ 
b/seatunnel-connectors-v2/connector-redis/src/test/java/org/apache/seatunnel/connectors/seatunnel/redis/sink/RedisSinkTest.java
@@ -0,0 +1,212 @@
+/*
+ * 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.seatunnel.connectors.seatunnel.redis.sink;
+
+import org.apache.seatunnel.api.configuration.ReadonlyConfig;
+import org.apache.seatunnel.api.serialization.Serializer;
+import org.apache.seatunnel.api.sink.SinkWriter;
+import org.apache.seatunnel.api.sink.SupportSchemaEvolutionSink;
+import org.apache.seatunnel.api.table.catalog.CatalogTable;
+import org.apache.seatunnel.api.table.catalog.PhysicalColumn;
+import org.apache.seatunnel.api.table.catalog.TableIdentifier;
+import org.apache.seatunnel.api.table.catalog.TableSchema;
+import org.apache.seatunnel.api.table.schema.SchemaChangeType;
+import org.apache.seatunnel.api.table.type.BasicType;
+import org.apache.seatunnel.api.table.type.RowKind;
+import org.apache.seatunnel.api.table.type.SeaTunnelRow;
+import org.apache.seatunnel.connectors.seatunnel.redis.client.RedisClient;
+import org.apache.seatunnel.connectors.seatunnel.redis.config.RedisBaseOptions;
+import org.apache.seatunnel.connectors.seatunnel.redis.config.RedisDataType;
+import org.apache.seatunnel.connectors.seatunnel.redis.config.RedisParameters;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mockito;
+
+import java.io.IOException;
+import java.lang.reflect.Field;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import static org.mockito.Mockito.when;
+
+class RedisSinkTest {
+
+    private static final TableIdentifier TABLE_IDENTIFIER =
+            TableIdentifier.of("catalog", "shop", null, "schema_events");
+
+    private RedisClient redisClient;
+    private RedisParameters redisParameters;
+    private RedisSink redisSink;
+
+    @BeforeEach
+    void setUp() throws ReflectiveOperationException {
+        redisClient = Mockito.mock(RedisClient.class);
+        redisParameters = Mockito.mock(RedisParameters.class);
+        when(redisParameters.buildRedisClient()).thenReturn(redisClient);
+        when(redisParameters.getBatchSize()).thenReturn(1);
+        
when(redisParameters.getFormat()).thenReturn(RedisBaseOptions.Format.JSON);
+        when(redisParameters.getFieldDelimiter()).thenReturn(",");
+        when(redisParameters.getKeyField()).thenReturn("schema-change:${id}");
+        when(redisParameters.getSupportCustomKey()).thenReturn(true);
+        when(redisParameters.getRedisDataType()).thenReturn(RedisDataType.KEY);
+        when(redisParameters.getExpire()).thenReturn(0L);
+
+        redisSink =
+                new RedisSink(ReadonlyConfig.fromMap(sinkConfig()), 
catalogTable(initialSchema()));
+        Field redisParametersField = 
RedisSink.class.getDeclaredField("redisParameters");
+        redisParametersField.setAccessible(true);
+        redisParametersField.set(redisSink, redisParameters);
+    }
+
+    @Test
+    void testAdvertisesSupportedSchemaChanges() {
+        Assertions.assertInstanceOf(SupportSchemaEvolutionSink.class, 
redisSink);
+        SupportSchemaEvolutionSink schemaEvolutionSink = 
(SupportSchemaEvolutionSink) redisSink;
+
+        Assertions.assertEquals(
+                Arrays.asList(
+                        SchemaChangeType.ADD_COLUMN,
+                        SchemaChangeType.DROP_COLUMN,
+                        SchemaChangeType.RENAME_COLUMN,
+                        SchemaChangeType.UPDATE_COLUMN),
+                schemaEvolutionSink.supports());
+    }
+
+    @Test
+    void testRestoreWriterUsesConfiguredSchemaWhenStatesAreNull() throws 
IOException {
+        RedisSinkWriter writer = redisSink.restoreWriter(context(), null);
+
+        Assertions.assertEquals(initialSchema(), 
writer.snapshotState(1L).get(0));
+    }
+
+    @Test
+    void testRestoreWriterUsesConfiguredSchemaWhenStatesAreEmpty() throws 
IOException {
+        RedisSinkWriter writer = redisSink.restoreWriter(context(), 
Collections.emptyList());
+
+        Assertions.assertEquals(initialSchema(), 
writer.snapshotState(1L).get(0));
+    }
+
+    @Test
+    void testRestoreWriterUsesSingleState() throws IOException {
+        TableSchema restoredSchema = evolvedSchema();
+
+        RedisSinkWriter writer =
+                redisSink.restoreWriter(context(), 
Collections.singletonList(restoredSchema));
+
+        Assertions.assertEquals(restoredSchema, 
writer.snapshotState(1L).get(0));
+    }
+
+    @Test
+    void testRestoreWriterAcceptsIdenticalStates() throws IOException {
+        TableSchema restoredSchema = evolvedSchema();
+
+        RedisSinkWriter writer =
+                redisSink.restoreWriter(
+                        context(), Arrays.asList(restoredSchema, 
restoredSchema.copy()));
+
+        Assertions.assertEquals(restoredSchema, 
writer.snapshotState(1L).get(0));
+    }
+
+    @Test
+    void testRestoreWriterRejectsConflictingStatesWithTableAndFieldContext() {
+        IOException exception =
+                Assertions.assertThrows(
+                        IOException.class,
+                        () ->
+                                redisSink.restoreWriter(
+                                        context(),
+                                        Arrays.asList(initialSchema(), 
evolvedSchema())));
+
+        Assertions.assertTrue(
+                
exception.getMessage().contains(TABLE_IDENTIFIER.toTablePath().getFullName()),
+                exception.getMessage());
+        Assertions.assertTrue(exception.getMessage().contains("state 1"), 
exception.getMessage());
+        Assertions.assertTrue(
+                exception.getMessage().contains("[id, name]"), 
exception.getMessage());
+        Assertions.assertTrue(
+                exception.getMessage().contains("[id, name, email]"), 
exception.getMessage());
+    }
+
+    @Test
+    void testSerializedStateRestoresWriterWithLatestSchema() throws 
IOException {
+        List<List<String>> writtenValues = new ArrayList<>();
+        Mockito.doAnswer(
+                        invocation -> {
+                            List<String> values = invocation.getArgument(2);
+                            writtenValues.add(new ArrayList<>(values));
+                            return null;
+                        })
+                .when(redisClient)
+                .batchWriteString(
+                        Mockito.anyList(), Mockito.anyList(), 
Mockito.anyList(), Mockito.anyLong());
+        Serializer<TableSchema> serializer =
+                
redisSink.getWriterStateSerializer().orElseThrow(AssertionError::new);
+        TableSchema restoredSchema = 
serializer.deserialize(serializer.serialize(evolvedSchema()));
+        RedisSinkWriter writer =
+                redisSink.restoreWriter(context(), 
Collections.singletonList(restoredSchema));
+        SeaTunnelRow row = new SeaTunnelRow(new Object[] {1L, "Alice", 
"[email protected]"});
+        row.setRowKind(RowKind.INSERT);
+
+        writer.write(row);
+
+        Assertions.assertEquals(1, writtenValues.size());
+        Assertions.assertEquals(
+                
"{\"id\":1,\"name\":\"Alice\",\"email\":\"[email protected]\"}",
+                writtenValues.get(0).get(0));
+    }
+
+    private static SinkWriter.Context context() {
+        return Mockito.mock(SinkWriter.Context.class);
+    }
+
+    private static CatalogTable catalogTable(TableSchema schema) {
+        return CatalogTable.of(
+                TABLE_IDENTIFIER, schema, new HashMap<>(), new ArrayList<>(), 
null, "catalog");
+    }
+
+    private static TableSchema initialSchema() {
+        return TableSchema.builder()
+                .column(PhysicalColumn.of("id", BasicType.LONG_TYPE, 0L, 
false, null, null))
+                .column(PhysicalColumn.of("name", BasicType.STRING_TYPE, 64L, 
true, null, null))
+                .build();
+    }
+
+    private static TableSchema evolvedSchema() {
+        return TableSchema.builder()
+                .column(PhysicalColumn.of("id", BasicType.LONG_TYPE, 0L, 
false, null, null))
+                .column(PhysicalColumn.of("name", BasicType.STRING_TYPE, 64L, 
true, null, null))
+                .column(PhysicalColumn.of("email", BasicType.STRING_TYPE, 
128L, true, null, null))
+                .build();
+    }
+
+    private static Map<String, Object> sinkConfig() {
+        Map<String, Object> config = new HashMap<>();
+        config.put("mode", "SINGLE");
+        config.put("host", "localhost");
+        config.put("port", 6379);
+        config.put("key", "schema-change:${id}");
+        config.put("data_type", "KEY");
+        return config;
+    }
+}
diff --git 
a/seatunnel-connectors-v2/connector-redis/src/test/java/org/apache/seatunnel/connectors/seatunnel/redis/sink/RedisSinkWriterTest.java
 
b/seatunnel-connectors-v2/connector-redis/src/test/java/org/apache/seatunnel/connectors/seatunnel/redis/sink/RedisSinkWriterTest.java
index a7adcc928f..9acd43d9d6 100644
--- 
a/seatunnel-connectors-v2/connector-redis/src/test/java/org/apache/seatunnel/connectors/seatunnel/redis/sink/RedisSinkWriterTest.java
+++ 
b/seatunnel-connectors-v2/connector-redis/src/test/java/org/apache/seatunnel/connectors/seatunnel/redis/sink/RedisSinkWriterTest.java
@@ -17,6 +17,12 @@
 
 package org.apache.seatunnel.connectors.seatunnel.redis.sink;
 
+import org.apache.seatunnel.api.sink.SupportSchemaEvolutionSinkWriter;
+import org.apache.seatunnel.api.table.catalog.PhysicalColumn;
+import org.apache.seatunnel.api.table.catalog.TableIdentifier;
+import org.apache.seatunnel.api.table.catalog.TableSchema;
+import org.apache.seatunnel.api.table.schema.event.AlterTableAddColumnEvent;
+import org.apache.seatunnel.api.table.schema.event.AlterTableDropColumnEvent;
 import org.apache.seatunnel.api.table.type.BasicType;
 import org.apache.seatunnel.api.table.type.RowKind;
 import org.apache.seatunnel.api.table.type.SeaTunnelDataType;
@@ -32,12 +38,18 @@ import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 import org.mockito.Mockito;
 
+import java.io.IOException;
+import java.util.ArrayList;
 import java.util.Arrays;
+import java.util.List;
 
 import static org.mockito.Mockito.when;
 
 public class RedisSinkWriterTest {
 
+    private static final TableIdentifier TABLE_IDENTIFIER =
+            TableIdentifier.of("catalog", "shop", null, "schema_events");
+
     private RedisClient mockRedisClient;
 
     private RedisParameters mockRedisParameters;
@@ -157,4 +169,146 @@ public class RedisSinkWriterTest {
 
         Assertions.assertEquals("user:{1}:profile", customKey);
     }
+
+    @Test
+    void testAddColumnUsesLatestSchemaForJsonSerialization() throws 
IOException {
+        List<List<String>> writtenValues = captureStringWrites();
+        configureJsonKeySink(1);
+        redisSinkWriter = new RedisSinkWriter(initialSchema(), 
mockRedisParameters);
+
+        schemaEvolutionWriter().applySchemaChange(addEmailEvent());
+        redisSinkWriter.write(insertRow(1L, "Alice", "[email protected]", 
"legacy-value"));
+
+        Assertions.assertEquals(1, writtenValues.size());
+        Assertions.assertEquals(
+                
"{\"id\":1,\"name\":\"Alice\",\"email\":\"[email protected]\","
+                        + "\"legacy_note\":\"legacy-value\"}",
+                writtenValues.get(0).get(0));
+    }
+
+    @Test
+    void testDropColumnRemovesFieldFromJsonSerialization() throws IOException {
+        List<List<String>> writtenValues = captureStringWrites();
+        configureJsonKeySink(1);
+        redisSinkWriter = new RedisSinkWriter(initialSchema(), 
mockRedisParameters);
+
+        SupportSchemaEvolutionSinkWriter schemaEvolutionWriter = 
schemaEvolutionWriter();
+        schemaEvolutionWriter.applySchemaChange(addEmailEvent());
+        schemaEvolutionWriter.applySchemaChange(
+                new AlterTableDropColumnEvent(TABLE_IDENTIFIER, 
"legacy_note"));
+        redisSinkWriter.write(insertRow(1L, "Alice", "[email protected]"));
+
+        Assertions.assertEquals(1, writtenValues.size());
+        Assertions.assertEquals(
+                
"{\"id\":1,\"name\":\"Alice\",\"email\":\"[email protected]\"}",
+                writtenValues.get(0).get(0));
+    }
+
+    @Test
+    void testSchemaChangeFlushesOldRowsBeforeSerializerRefresh() throws 
IOException {
+        List<List<String>> writtenValues = captureStringWrites();
+        configureJsonKeySink(2);
+        redisSinkWriter = new RedisSinkWriter(initialSchema(), 
mockRedisParameters);
+
+        redisSinkWriter.write(insertRow(1L, "Before", "old-schema-value"));
+        schemaEvolutionWriter().applySchemaChange(addEmailEvent());
+
+        Assertions.assertEquals(1, writtenValues.size());
+        Assertions.assertEquals(
+                
"{\"id\":1,\"name\":\"Before\",\"legacy_note\":\"old-schema-value\"}",
+                writtenValues.get(0).get(0));
+
+        redisSinkWriter.write(insertRow(2L, "After", "[email protected]", 
"new-schema-value"));
+        redisSinkWriter.prepareCommit();
+
+        Assertions.assertEquals(2, writtenValues.size());
+        Assertions.assertEquals(
+                
"{\"id\":2,\"name\":\"After\",\"email\":\"[email protected]\","
+                        + "\"legacy_note\":\"new-schema-value\"}",
+                writtenValues.get(1).get(0));
+    }
+
+    @Test
+    void testSnapshotStateRestoresLatestSchema() throws IOException {
+        List<List<String>> writtenValues = captureStringWrites();
+        configureJsonKeySink(1);
+        redisSinkWriter = new RedisSinkWriter(initialSchema(), 
mockRedisParameters);
+
+        schemaEvolutionWriter().applySchemaChange(addEmailEvent());
+        List<?> states = redisSinkWriter.snapshotState(1L);
+
+        Assertions.assertEquals(1, states.size());
+        redisSinkWriter = new RedisSinkWriter((TableSchema) states.get(0), 
mockRedisParameters);
+        redisSinkWriter.write(insertRow(1L, "Recovered", 
"[email protected]", "legacy"));
+
+        Assertions.assertEquals(1, writtenValues.size());
+        Assertions.assertEquals(
+                
"{\"id\":1,\"name\":\"Recovered\",\"email\":\"[email protected]\","
+                        + "\"legacy_note\":\"legacy\"}",
+                writtenValues.get(0).get(0));
+    }
+
+    @Test
+    void testSnapshotStateNormalizesMissingConstraintKeys() {
+        configureJsonKeySink(1);
+        TableSchema schema = new TableSchema(initialSchema().getColumns(), 
null, null);
+        redisSinkWriter = new RedisSinkWriter(schema, mockRedisParameters);
+
+        List<TableSchema> states = redisSinkWriter.snapshotState(1L);
+
+        Assertions.assertEquals(1, states.size());
+        Assertions.assertNotSame(schema, states.get(0));
+        Assertions.assertNotNull(states.get(0).getConstraintKeys());
+        Assertions.assertTrue(states.get(0).getConstraintKeys().isEmpty());
+    }
+
+    private SupportSchemaEvolutionSinkWriter schemaEvolutionWriter() {
+        Assertions.assertInstanceOf(SupportSchemaEvolutionSinkWriter.class, 
redisSinkWriter);
+        return (SupportSchemaEvolutionSinkWriter) redisSinkWriter;
+    }
+
+    private List<List<String>> captureStringWrites() {
+        List<List<String>> writtenValues = new ArrayList<>();
+        Mockito.doAnswer(
+                        invocation -> {
+                            List<String> values = invocation.getArgument(2);
+                            writtenValues.add(new ArrayList<>(values));
+                            return null;
+                        })
+                .when(mockRedisClient)
+                .batchWriteString(
+                        Mockito.anyList(), Mockito.anyList(), 
Mockito.anyList(), Mockito.anyLong());
+        return writtenValues;
+    }
+
+    private void configureJsonKeySink(int batchSize) {
+        when(mockRedisParameters.getBatchSize()).thenReturn(batchSize);
+        
when(mockRedisParameters.getKeyField()).thenReturn("schema-change:${id}");
+        when(mockRedisParameters.getSupportCustomKey()).thenReturn(true);
+        
when(mockRedisParameters.getRedisDataType()).thenReturn(RedisDataType.KEY);
+        when(mockRedisParameters.getExpire()).thenReturn(0L);
+    }
+
+    private static SeaTunnelRow insertRow(Object... fields) {
+        SeaTunnelRow row = new SeaTunnelRow(fields);
+        row.setRowKind(RowKind.INSERT);
+        return row;
+    }
+
+    private static AlterTableAddColumnEvent addEmailEvent() {
+        return AlterTableAddColumnEvent.addAfter(
+                TABLE_IDENTIFIER,
+                PhysicalColumn.of("email", BasicType.STRING_TYPE, 128L, true, 
null, null),
+                "name");
+    }
+
+    private static TableSchema initialSchema() {
+        return TableSchema.builder()
+                .column(PhysicalColumn.of("id", BasicType.LONG_TYPE, 0L, 
false, null, null))
+                .column(PhysicalColumn.of("name", BasicType.STRING_TYPE, 64L, 
true, null, null))
+                .column(
+                        PhysicalColumn.of(
+                                "legacy_note", BasicType.STRING_TYPE, 128L, 
true, null, null))
+                .build();
+    }
 }
diff --git 
a/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-redis-e2e/pom.xml 
b/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-redis-e2e/pom.xml
index 1f38de643f..c13f97359e 100644
--- a/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-redis-e2e/pom.xml
+++ b/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-redis-e2e/pom.xml
@@ -25,6 +25,10 @@
     <artifactId>connector-redis-e2e</artifactId>
     <name>SeaTunnel : E2E : Connector V2 : Redis</name>
 
+    <properties>
+        <mysql.version>8.0.27</mysql.version>
+    </properties>
+
     <dependencies>
         <!-- SeaTunnel connectors -->
         <dependency>
@@ -41,6 +45,28 @@
             <type>test-jar</type>
             <scope>test</scope>
         </dependency>
+
+        <dependency>
+            <groupId>org.apache.seatunnel</groupId>
+            <artifactId>connector-cdc-mysql</artifactId>
+            <version>${project.version}</version>
+            <scope>test</scope>
+        </dependency>
+
+        <dependency>
+            <groupId>mysql</groupId>
+            <artifactId>mysql-connector-java</artifactId>
+            <version>${mysql.version}</version>
+            <scope>test</scope>
+        </dependency>
+
+        <dependency>
+            <groupId>org.testcontainers</groupId>
+            <artifactId>mysql</artifactId>
+            <version>${testcontainer.version}</version>
+            <scope>test</scope>
+        </dependency>
+
         <dependency>
             <groupId>com.alibaba</groupId>
             <artifactId>dns-cache-manipulator</artifactId>
diff --git 
a/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-redis-e2e/src/test/java/org/apache/seatunnel/e2e/connector/redis/RedisSchemaChangeIT.java
 
b/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-redis-e2e/src/test/java/org/apache/seatunnel/e2e/connector/redis/RedisSchemaChangeIT.java
new file mode 100644
index 0000000000..2427dd5e09
--- /dev/null
+++ 
b/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-redis-e2e/src/test/java/org/apache/seatunnel/e2e/connector/redis/RedisSchemaChangeIT.java
@@ -0,0 +1,438 @@
+/*
+ * 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.seatunnel.e2e.connector.redis;
+
+import 
org.apache.seatunnel.shade.com.fasterxml.jackson.databind.node.ObjectNode;
+
+import org.apache.seatunnel.common.utils.JsonUtils;
+import org.apache.seatunnel.e2e.common.TestResource;
+import org.apache.seatunnel.e2e.common.TestSuiteBase;
+import org.apache.seatunnel.e2e.common.container.ContainerExtendedFactory;
+import org.apache.seatunnel.e2e.common.container.EngineType;
+import org.apache.seatunnel.e2e.common.container.TestContainer;
+import org.apache.seatunnel.e2e.common.junit.DisabledOnContainer;
+import org.apache.seatunnel.e2e.common.junit.TestContainerExtension;
+import org.apache.seatunnel.e2e.common.util.JobIdGenerator;
+
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.TestTemplate;
+import org.testcontainers.containers.Container;
+import org.testcontainers.containers.GenericContainer;
+import org.testcontainers.containers.MySQLContainer;
+import org.testcontainers.containers.output.Slf4jLogConsumer;
+import org.testcontainers.containers.wait.strategy.HostPortWaitStrategy;
+import org.testcontainers.lifecycle.Startables;
+import org.testcontainers.utility.DockerImageName;
+import org.testcontainers.utility.DockerLoggerFactory;
+import org.testcontainers.utility.MountableFile;
+
+import com.mysql.cj.jdbc.Driver;
+import lombok.extern.slf4j.Slf4j;
+import redis.clients.jedis.Jedis;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.sql.Connection;
+import java.sql.DriverManager;
+import java.sql.SQLException;
+import java.sql.Statement;
+import java.time.Duration;
+import java.util.Locale;
+import java.util.Objects;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CompletionException;
+import java.util.concurrent.TimeUnit;
+import java.util.function.Consumer;
+import java.util.stream.Stream;
+
+import static org.awaitility.Awaitility.await;
+
+@Slf4j
+@DisabledOnContainer(
+        value = {},
+        type = {EngineType.SPARK, EngineType.FLINK},
+        disabledReason = "Only Zeta supports schema evolution for this 
streaming CDC case.")
+public class RedisSchemaChangeIT extends TestSuiteBase implements TestResource 
{
+    private static final String MYSQL_HOST = "mysql_cdc_redis_e2e";
+    private static final String REDIS_HOST = "redis_cdc_e2e";
+    private static final int REDIS_PORT = 6379;
+    private static final String REDIS_PASSWORD = "SeaTunnel";
+    private static final String REDIS_IMAGE = "redis:7";
+    private static final String MYSQL_IMAGE = "mysql:8.0.43";
+
+    private static final String DATABASE = "shop";
+    private static final String TABLE = "schema_events";
+    private static final String MYSQL_USER_NAME = "root";
+    private static final String MYSQL_USER_PASSWORD = "mysqlpw";
+
+    private static final String KEY_1 = "schema-change:1";
+    private static final String KEY_2 = "schema-change:2";
+    private static final String JOB_CONFIG = 
"/mysqlcdc_to_redis_with_schema_change.conf";
+    private static final String INCREMENTAL_READ_MARKER =
+            "Start incremental read task for incremental split";
+    private static final String MYSQL_CDC_PLUGIN_LIB = 
"/tmp/seatunnel/plugins/MySQL-CDC/lib";
+
+    private static final MySQLContainer<?> MYSQL_CONTAINER = 
createMySqlContainer();
+
+    private GenericContainer<?> redisContainer;
+    private Connection mysqlConnection;
+    private Jedis jedis;
+
+    @TestContainerExtension
+    protected final ContainerExtendedFactory extendedFactory =
+            RedisSchemaChangeIT::copyMySQLDriverToContainer;
+
+    @BeforeAll
+    @Override
+    public void startUp() throws SQLException {
+        redisContainer =
+                new GenericContainer<>(DockerImageName.parse(REDIS_IMAGE))
+                        .withNetwork(NETWORK)
+                        .withNetworkAliases(REDIS_HOST)
+                        .withExposedPorts(REDIS_PORT)
+                        .withCommand("redis-server --requirepass " + 
REDIS_PASSWORD)
+                        .withLogConsumer(
+                                new 
Slf4jLogConsumer(DockerLoggerFactory.getLogger(REDIS_IMAGE)))
+                        .waitingFor(
+                                new HostPortWaitStrategy()
+                                        
.withStartupTimeout(Duration.ofMinutes(2)));
+
+        Startables.deepStart(Stream.of(MYSQL_CONTAINER, 
redisContainer)).join();
+
+        mysqlConnection =
+                DriverManager.getConnection(
+                        MYSQL_CONTAINER.getJdbcUrl(),
+                        MYSQL_CONTAINER.getUsername(),
+                        MYSQL_CONTAINER.getPassword());
+        jedis = new Jedis(redisContainer.getHost(), 
redisContainer.getFirstMappedPort());
+        jedis.auth(REDIS_PASSWORD);
+        Assertions.assertEquals("PONG", jedis.ping());
+
+        executeSql(
+                "CREATE USER IF NOT EXISTS 'st_user_source'@'%' IDENTIFIED BY 
'mysqlpw'",
+                "GRANT SELECT, RELOAD, SHOW DATABASES, REPLICATION SLAVE, "
+                        + "REPLICATION CLIENT, DROP, LOCK TABLES ON *.* TO 
'st_user_source'@'%'",
+                "FLUSH PRIVILEGES",
+                "DROP TABLE IF EXISTS " + DATABASE + "." + TABLE,
+                "CREATE TABLE "
+                        + DATABASE
+                        + "."
+                        + TABLE
+                        + " ("
+                        + "id BIGINT NOT NULL PRIMARY KEY,"
+                        + "name VARCHAR(64),"
+                        + "legacy_note VARCHAR(128))",
+                "INSERT INTO "
+                        + DATABASE
+                        + "."
+                        + TABLE
+                        + "(id,name,legacy_note) VALUES "
+                        + "(1,'before-change','to-be-dropped')");
+        jedis.del(KEY_1, KEY_2);
+    }
+
+    @TestTemplate
+    public void testMysqlCdcToRedisSchemaEvolution(TestContainer container) 
throws Exception {
+        String jobId = String.valueOf(JobIdGenerator.newJobId());
+        CompletableFuture<Container.ExecResult> jobFuture =
+                CompletableFuture.supplyAsync(
+                        () -> {
+                            try {
+                                return container.executeJob(JOB_CONFIG, jobId);
+                            } catch (Exception e) {
+                                throw new CompletionException(e);
+                            }
+                        });
+
+        try {
+            awaitRedisJson(
+                    jobFuture,
+                    KEY_1,
+                    json -> {
+                        Assertions.assertEquals(3, json.size());
+                        Assertions.assertEquals(1L, json.get("id").asLong());
+                        Assertions.assertEquals("before-change", 
json.get("name").asText());
+                        Assertions.assertEquals("to-be-dropped", 
json.get("legacy_note").asText());
+                    });
+            waitForIncrementalRead(container, jobFuture);
+
+            executeSql(
+                    "ALTER TABLE "
+                            + DATABASE
+                            + "."
+                            + TABLE
+                            + " ADD COLUMN email VARCHAR(128) NULL AFTER name",
+                    "UPDATE "
+                            + DATABASE
+                            + "."
+                            + TABLE
+                            + " SET email='[email protected]' WHERE id=1");
+
+            awaitRedisJson(
+                    jobFuture,
+                    KEY_1,
+                    json -> {
+                        Assertions.assertEquals(4, json.size());
+                        Assertions.assertEquals("[email protected]", 
json.get("email").asText());
+                        Assertions.assertEquals("to-be-dropped", 
json.get("legacy_note").asText());
+                    });
+
+            executeSql(
+                    "ALTER TABLE "
+                            + DATABASE
+                            + "."
+                            + TABLE
+                            + " ADD COLUMN score INT NULL AFTER email",
+                    "UPDATE " + DATABASE + "." + TABLE + " SET score=42 WHERE 
id=1",
+                    "INSERT INTO "
+                            + DATABASE
+                            + "."
+                            + TABLE
+                            + "(id,name,email,score,legacy_note) VALUES "
+                            + 
"(2,'second-row','[email protected]',84,'second-legacy')");
+
+            awaitRedisJson(
+                    jobFuture,
+                    KEY_1,
+                    json -> {
+                        Assertions.assertEquals(5, json.size());
+                        Assertions.assertEquals("[email protected]", 
json.get("email").asText());
+                        Assertions.assertEquals(42, json.get("score").asInt());
+                    });
+            awaitRedisJson(
+                    jobFuture,
+                    KEY_2,
+                    json -> {
+                        Assertions.assertEquals(5, json.size());
+                        Assertions.assertEquals("second-row", 
json.get("name").asText());
+                        Assertions.assertEquals("[email protected]", 
json.get("email").asText());
+                        Assertions.assertEquals(84, json.get("score").asInt());
+                        Assertions.assertEquals("second-legacy", 
json.get("legacy_note").asText());
+                    });
+
+            executeSql(
+                    "ALTER TABLE " + DATABASE + "." + TABLE + " DROP COLUMN 
legacy_note",
+                    "UPDATE "
+                            + DATABASE
+                            + "."
+                            + TABLE
+                            + " SET name='after-drop' WHERE id IN (1,2)");
+
+            awaitRedisJson(jobFuture, KEY_1, 
RedisSchemaChangeIT::assertAfterDrop);
+            awaitRedisJson(jobFuture, KEY_2, 
RedisSchemaChangeIT::assertAfterDrop);
+        } finally {
+            Throwable cleanupFailure = stopJobAndAwait(container, jobId, 
jobFuture);
+            try {
+                jedis.del(KEY_1, KEY_2);
+            } catch (Throwable failure) {
+                cleanupFailure = mergeFailure(cleanupFailure, failure);
+            }
+            rethrowFailure(cleanupFailure);
+        }
+    }
+
+    private static MySQLContainer<?> createMySqlContainer() {
+        return new MySQLContainer<>(DockerImageName.parse(MYSQL_IMAGE))
+                .withNetwork(NETWORK)
+                .withNetworkAliases(MYSQL_HOST)
+                .withDatabaseName(DATABASE)
+                .withUsername(MYSQL_USER_NAME)
+                .withPassword(MYSQL_USER_PASSWORD)
+                .withCommand(
+                        "--server-id=223344",
+                        "--log-bin=mysql-bin",
+                        "--binlog-format=ROW",
+                        "--gtid-mode=ON",
+                        "--enforce-gtid-consistency=ON")
+                .withLogConsumer(
+                        new 
Slf4jLogConsumer(DockerLoggerFactory.getLogger("mysql-docker-image")));
+    }
+
+    private void awaitRedisJson(
+            CompletableFuture<Container.ExecResult> jobFuture,
+            String key,
+            Consumer<ObjectNode> assertion) {
+        await().atMost(180, TimeUnit.SECONDS)
+                .pollInterval(2, TimeUnit.SECONDS)
+                .untilAsserted(
+                        () -> {
+                            assertJobHasNotFinished(jobFuture);
+                            String value = jedis.get(key);
+                            Assertions.assertNotNull(value, "Redis key is not 
available: " + key);
+                            assertion.accept(JsonUtils.parseObject(value));
+                        });
+    }
+
+    private void waitForIncrementalRead(
+            TestContainer container, CompletableFuture<Container.ExecResult> 
jobFuture) {
+        await().atMost(180, TimeUnit.SECONDS)
+                .pollInterval(2, TimeUnit.SECONDS)
+                .untilAsserted(
+                        () -> {
+                            assertJobHasNotFinished(jobFuture);
+                            String serverLogs = container.getServerLogs();
+                            Assertions.assertTrue(
+                                    
serverLogs.contains(INCREMENTAL_READ_MARKER),
+                                    "Incremental reader has not started yet");
+                            Assertions.assertTrue(
+                                    serverLogs.contains(DATABASE + "." + 
TABLE),
+                                    "Incremental reader has not captured the 
source table");
+                        });
+    }
+
+    private void executeSql(String... sqlStatements) {
+        try (Statement statement = mysqlConnection.createStatement()) {
+            for (String sql : sqlStatements) {
+                statement.execute(sql);
+                String statementType = sql.trim().split("\\s+", 
2)[0].toUpperCase(Locale.ROOT);
+                log.info("Executed MySQL {} statement", statementType);
+            }
+        } catch (SQLException e) {
+            throw new IllegalStateException("Failed to execute MySQL schema 
evolution SQL", e);
+        }
+    }
+
+    private static void assertAfterDrop(ObjectNode json) {
+        Assertions.assertEquals(4, json.size());
+        Assertions.assertEquals("after-drop", json.get("name").asText());
+        Assertions.assertTrue(json.has("email"));
+        Assertions.assertTrue(json.has("score"));
+        Assertions.assertFalse(json.has("legacy_note"));
+    }
+
+    private static void 
assertJobHasNotFinished(CompletableFuture<Container.ExecResult> jobFuture) {
+        if (!jobFuture.isDone()) {
+            return;
+        }
+        try {
+            Container.ExecResult result = jobFuture.getNow(null);
+            Assertions.fail(
+                    "Streaming job finished before Redis assertion. exitCode="
+                            + result.getExitCode()
+                            + ", stderr="
+                            + result.getStderr());
+        } catch (CompletionException e) {
+            Assertions.fail("Streaming job failed before Redis assertion", 
e.getCause());
+        }
+    }
+
+    private static Throwable stopJobAndAwait(
+            TestContainer container,
+            String jobId,
+            CompletableFuture<Container.ExecResult> jobFuture) {
+        Throwable failure = null;
+        if (!jobFuture.isDone()) {
+            try {
+                Container.ExecResult cancelResult = container.cancelJob(jobId);
+                Assertions.assertEquals(0, cancelResult.getExitCode(), 
cancelResult.getStderr());
+            } catch (Throwable cancelFailure) {
+                failure = mergeFailure(failure, cancelFailure);
+            }
+        }
+        try {
+            Container.ExecResult result = jobFuture.get(60, TimeUnit.SECONDS);
+            Assertions.assertEquals(0, result.getExitCode(), 
result.getStderr());
+        } catch (Throwable jobFailure) {
+            failure = mergeFailure(failure, jobFailure);
+        }
+        return failure;
+    }
+
+    private static Throwable mergeFailure(Throwable previousFailure, Throwable 
failure) {
+        if (previousFailure == null) {
+            return failure;
+        }
+        previousFailure.addSuppressed(failure);
+        return previousFailure;
+    }
+
+    private static void rethrowFailure(Throwable failure) throws Exception {
+        if (failure == null) {
+            return;
+        }
+        if (failure instanceof Exception) {
+            throw (Exception) failure;
+        }
+        if (failure instanceof Error) {
+            throw (Error) failure;
+        }
+        throw new AssertionError("Failed to stop schema evolution test job", 
failure);
+    }
+
+    private static void copyMySQLDriverToContainer(GenericContainer<?> 
container)
+            throws IOException, InterruptedException {
+        Container.ExecResult mkdirResult =
+                container.execInContainer("bash", "-c", "mkdir -p " + 
MYSQL_CDC_PLUGIN_LIB);
+        Assertions.assertEquals(0, mkdirResult.getExitCode(), 
mkdirResult.getStderr());
+
+        Path driverJarPath = mysqlDriverJarPath();
+        container.copyFileToContainer(
+                MountableFile.forHostPath(driverJarPath),
+                MYSQL_CDC_PLUGIN_LIB + "/" + driverJarPath.getFileName());
+    }
+
+    private static Path mysqlDriverJarPath() {
+        try {
+            Path driverJarPath =
+                    Paths.get(
+                            Driver.class
+                                    .getProtectionDomain()
+                                    .getCodeSource()
+                                    .getLocation()
+                                    .toURI());
+            Assertions.assertTrue(Files.isRegularFile(driverJarPath));
+            return driverJarPath;
+        } catch (Exception e) {
+            throw new IllegalStateException(
+                    "Failed to resolve MySQL JDBC driver jar from the test 
classpath", e);
+        }
+    }
+
+    @AfterAll
+    @Override
+    public void tearDown() throws Exception {
+        Throwable failure = null;
+        failure = closeResource(jedis, failure);
+        failure = closeResource(mysqlConnection, failure);
+        failure = closeResource(redisContainer, failure);
+        failure = closeResource(MYSQL_CONTAINER, failure);
+        if (failure != null) {
+            throw new Exception(
+                    "Failed to close one or more schema evolution test 
resources", failure);
+        }
+    }
+
+    private static Throwable closeResource(AutoCloseable resource, Throwable 
previousFailure) {
+        if (Objects.isNull(resource)) {
+            return previousFailure;
+        }
+        try {
+            resource.close();
+        } catch (Throwable failure) {
+            if (previousFailure == null) {
+                return failure;
+            }
+            previousFailure.addSuppressed(failure);
+        }
+        return previousFailure;
+    }
+}
diff --git 
a/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-redis-e2e/src/test/resources/mysqlcdc_to_redis_with_schema_change.conf
 
b/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-redis-e2e/src/test/resources/mysqlcdc_to_redis_with_schema_change.conf
new file mode 100644
index 0000000000..30c2890dd7
--- /dev/null
+++ 
b/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-redis-e2e/src/test/resources/mysqlcdc_to_redis_with_schema_change.conf
@@ -0,0 +1,46 @@
+#
+# 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.
+#
+
+env {
+  parallelism = 1
+  job.mode = "STREAMING"
+  checkpoint.interval = 2000
+}
+
+source {
+  MySQL-CDC {
+    server-id = 5950-5955
+    username = "st_user_source"
+    password = "mysqlpw"
+    table-names = ["shop.schema_events"]
+    url = "jdbc:mysql://mysql_cdc_redis_e2e:3306/shop"
+    schema-changes.enabled = true
+  }
+}
+
+sink {
+  Redis {
+    host = "redis_cdc_e2e"
+    port = 6379
+    auth = "SeaTunnel"
+    key = "schema-change:{id}"
+    support_custom_key = true
+    data_type = key
+    batch_size = 1
+    format = json
+  }
+}

Reply via email to