diff --git a/docs/content.zh/docs/core-concept/data-pipeline.md b/docs/content.zh/docs/core-concept/data-pipeline.md
index bf4f3212e12..62d9c023434 100644
--- a/docs/content.zh/docs/core-concept/data-pipeline.md
+++ b/docs/content.zh/docs/core-concept/data-pipeline.md
@@ -125,5 +125,13 @@ under the License.
| `operator.uid.prefix` | Pipeline 中算子 UID 的前缀。如果不设置,Flink 会为每个算子生成唯一的 UID。 建议设置这个参数以提供稳定和可识别的算子 ID,这有助于有状态升级、问题排查和在 Flink UI 上的诊断。 | optional |
| `sink.partitioning.strategy` | Sink 写入数据时使用的分区策略。数据类型:String。默认值:`SINK_DEFINED`。备注:可配置的值如下:`SINK_DEFINED`:使用 Sink 定义的分区策略;`PRIMARY_KEY`:按表 ID 和主键分区;`TABLE_ID`:仅按表 ID 分区。 | optional |
| `transform.decimal.precision.mode` | transform 表达式求值中 DECIMAL 类型的最大精度模式。可选值:`UP_TO_19`(默认,使用 Calcite 默认类型系统)或 `UP_TO_38`(允许 DECIMAL 精度最高为 38 位)。 | optional |
+| `transform.async-execution.enabled` | 是否为 PostTransform 开启有序异步执行,默认值为 `false`。 | optional |
+| `transform.async-execution.timeout` | 有序异步执行中每个 PostTransform 事件的超时时间,默认值为 5 分钟。 | optional |
+| `transform.async-execution.capacity` | 异步执行中最多允许同时处理的 PostTransform 事件数,默认值为 100。 | optional |
+| `transform.async-execution.worker-threads` | 每个异步 PostTransform 任务使用的工作线程数,默认值为 16。 | optional |
+
+> **实验性且不稳定:** 异步 PostTransform 执行目前是实验性功能,其配置和行为可能会在未来版本中发生变化。
+
+异步 PostTransform 适用于 AI 模型调用等 I/O 密集型表达式。DataChangeEvent 可能并发调用 UDF 和 AI 模型客户端,但输出和所有 SchemaChangeEvent 仍保持有序,因此 UDF 和 AI 模型客户端实现必须是线程安全的。为保证 schema 状态一致,checkpoint 或 savepoint 前会等待尚未完成的异步请求,长时间运行的请求可能会延长 checkpoint 时间。savepoint 只支持在并行度不变时恢复,并且不能在从已有 savepoint 恢复时开启或关闭此选项。
注意:虽然上述参数都是可选的,但至少需要指定其中一个。`pipeline` 部分是必需的,不能为空。
diff --git a/docs/content/docs/core-concept/data-pipeline.md b/docs/content/docs/core-concept/data-pipeline.md
index 4b35ad6ca3c..664c5c5db4e 100644
--- a/docs/content/docs/core-concept/data-pipeline.md
+++ b/docs/content/docs/core-concept/data-pipeline.md
@@ -127,5 +127,13 @@ Note that whilst the parameters are each individually optional, at least one of
| `operator.uid.prefix` | The prefix to use for all pipeline operator UIDs. If not set, all pipeline operator UIDs will be generated by Flink. It is recommended to set this parameter to ensure stable and recognizable operator UIDs, which can help with stateful upgrades, troubleshooting, and Flink UI diagnostics. | optional |
| `sink.partitioning.strategy` | The partitioning strategy used when writing data to the sink. Data type: String. Default value: `SINK_DEFINED`. Available values: `SINK_DEFINED`: uses the partitioning strategy defined by the sink; `PRIMARY_KEY`: partitions by table ID and primary key; `TABLE_ID`: partitions only by table ID. | optional |
| `transform.decimal.precision.mode` | Maximum precision mode for DECIMAL type in transform expression evaluation. One of: `UP_TO_19` (default, match Calcite's default type system) or `UP_TO_38` (allow DECIMAL precision up to 38 digits). | optional |
+| `transform.async-execution.enabled` | Whether to enable ordered asynchronous execution for post-transform. Defaults to `false`. | optional |
+| `transform.async-execution.timeout` | The timeout for each post-transform event in ordered asynchronous execution. Defaults to 5 minutes. | optional |
+| `transform.async-execution.capacity` | The maximum number of in-flight post-transform events. Defaults to 100. | optional |
+| `transform.async-execution.worker-threads` | The number of worker threads used by each asynchronous post-transform task. Defaults to 16. | optional |
+
+> **Experimental and unstable:** Asynchronous post-transform execution is an experimental feature. Its configuration and behavior may change in future releases.
+
+Asynchronous post-transform execution is intended for I/O-bound expressions such as AI model calls. Data change events may invoke UDFs and AI model clients concurrently, while their output and all schema changes remain ordered. UDF and AI model client implementations must therefore be thread-safe. Pending asynchronous requests are completed before a checkpoint or savepoint to keep schema state consistent, so long-running requests may extend checkpoint duration. Savepoint restore is supported only with unchanged parallelism, and this option must not be enabled or disabled when restoring an existing savepoint.
NOTE: Whilst the above parameters are each individually optional, at least one of them must be specified. The `pipeline` section is mandatory and cannot be empty.
diff --git a/flink-cdc-common/src/main/java/org/apache/flink/cdc/common/model/AiModelClient.java b/flink-cdc-common/src/main/java/org/apache/flink/cdc/common/model/AiModelClient.java
index 0e46508bb2e..512c684ac4e 100644
--- a/flink-cdc-common/src/main/java/org/apache/flink/cdc/common/model/AiModelClient.java
+++ b/flink-cdc-common/src/main/java/org/apache/flink/cdc/common/model/AiModelClient.java
@@ -27,6 +27,10 @@
*
*
Implementations must be {@link Serializable} so that they can be distributed across Flink task
* managers together with the operator that holds them.
+ *
+ *
When {@code transform.async-execution.enabled} is enabled, the same client instance may be
+ * invoked concurrently. Implementations used in asynchronous transforms must therefore be
+ * thread-safe.
*/
@Experimental
public interface AiModelClient extends Serializable, AutoCloseable {
diff --git a/flink-cdc-common/src/main/java/org/apache/flink/cdc/common/pipeline/PipelineOptions.java b/flink-cdc-common/src/main/java/org/apache/flink/cdc/common/pipeline/PipelineOptions.java
index d46c94c6530..3b39645d096 100644
--- a/flink-cdc-common/src/main/java/org/apache/flink/cdc/common/pipeline/PipelineOptions.java
+++ b/flink-cdc-common/src/main/java/org/apache/flink/cdc/common/pipeline/PipelineOptions.java
@@ -175,5 +175,33 @@ public class PipelineOptions {
"UP_TO_38: Allows DECIMAL precision up to 38 digits, matching Flink CDC's extended type system.")))
.build());
+ public static final ConfigOption PIPELINE_TRANSFORM_ASYNC_EXECUTION_ENABLED =
+ ConfigOptions.key("transform.async-execution.enabled")
+ .booleanType()
+ .defaultValue(false)
+ .withDescription(
+ "Whether to enable ordered async execution for post-transform.");
+
+ public static final ConfigOption PIPELINE_TRANSFORM_ASYNC_EXECUTION_TIMEOUT =
+ ConfigOptions.key("transform.async-execution.timeout")
+ .durationType()
+ .defaultValue(Duration.ofMinutes(5))
+ .withDescription(
+ "The timeout for each post-transform event in ordered async execution.");
+
+ public static final ConfigOption PIPELINE_TRANSFORM_ASYNC_EXECUTION_CAPACITY =
+ ConfigOptions.key("transform.async-execution.capacity")
+ .intType()
+ .defaultValue(100)
+ .withDescription(
+ "The maximum number of in-flight post-transform events in ordered async execution.");
+
+ public static final ConfigOption PIPELINE_TRANSFORM_ASYNC_EXECUTION_WORKER_THREADS =
+ ConfigOptions.key("transform.async-execution.worker-threads")
+ .intType()
+ .defaultValue(16)
+ .withDescription(
+ "The number of worker threads used by each async post-transform task.");
+
private PipelineOptions() {}
}
diff --git a/flink-cdc-common/src/main/java/org/apache/flink/cdc/common/udf/UserDefinedFunction.java b/flink-cdc-common/src/main/java/org/apache/flink/cdc/common/udf/UserDefinedFunction.java
index 0fa5d78c8c9..f20343b138a 100644
--- a/flink-cdc-common/src/main/java/org/apache/flink/cdc/common/udf/UserDefinedFunction.java
+++ b/flink-cdc-common/src/main/java/org/apache/flink/cdc/common/udf/UserDefinedFunction.java
@@ -23,6 +23,10 @@
/**
* Base interface for creating a UDF in transform projection and filtering expressions. You should
* define at least one {@code eval} method.
+ *
+ *
When {@code transform.async-execution.enabled} is enabled, the same UDF instance may be
+ * invoked concurrently. Implementations used in asynchronous transforms must therefore be
+ * thread-safe.
*/
@PublicEvolving
public interface UserDefinedFunction {
diff --git a/flink-cdc-composer/src/main/java/org/apache/flink/cdc/composer/flink/FlinkPipelineComposer.java b/flink-cdc-composer/src/main/java/org/apache/flink/cdc/composer/flink/FlinkPipelineComposer.java
index c0d693ed201..3b73a283e50 100644
--- a/flink-cdc-composer/src/main/java/org/apache/flink/cdc/composer/flink/FlinkPipelineComposer.java
+++ b/flink-cdc-composer/src/main/java/org/apache/flink/cdc/composer/flink/FlinkPipelineComposer.java
@@ -192,19 +192,40 @@ private void translate(StreamExecutionEnvironment env, PipelineDef pipelineDef)
dataSource.supportedMetadataColumns());
// PreTransform ---> PostTransform
- stream =
- transformTranslator.translatePostTransform(
- stream,
- pipelineDef.getTransforms(),
- pipelineDef.getConfig().get(PipelineOptions.PIPELINE_LOCAL_TIME_ZONE),
- pipelineDef
- .getConfig()
- .get(PipelineOptions.PIPELINE_TRANSFORM_DECIMAL_PRECISION_MODE),
- pipelineDef.getUdfs(),
- pipelineDef.getModels(),
- dataSource.supportedMetadataColumns(),
- operatorUidGenerator,
- env);
+ if (pipelineDefConfig.get(PipelineOptions.PIPELINE_TRANSFORM_ASYNC_EXECUTION_ENABLED)) {
+ stream =
+ transformTranslator.translateAsyncPostTransform(
+ stream,
+ pipelineDef.getTransforms(),
+ pipelineDefConfig.get(PipelineOptions.PIPELINE_LOCAL_TIME_ZONE),
+ pipelineDefConfig.get(
+ PipelineOptions.PIPELINE_TRANSFORM_DECIMAL_PRECISION_MODE),
+ pipelineDef.getUdfs(),
+ pipelineDef.getModels(),
+ dataSource.supportedMetadataColumns(),
+ operatorUidGenerator,
+ pipelineDefConfig.get(
+ PipelineOptions.PIPELINE_TRANSFORM_ASYNC_EXECUTION_TIMEOUT),
+ pipelineDefConfig.get(
+ PipelineOptions.PIPELINE_TRANSFORM_ASYNC_EXECUTION_CAPACITY),
+ pipelineDefConfig.get(
+ PipelineOptions
+ .PIPELINE_TRANSFORM_ASYNC_EXECUTION_WORKER_THREADS),
+ env);
+ } else {
+ stream =
+ transformTranslator.translatePostTransform(
+ stream,
+ pipelineDef.getTransforms(),
+ pipelineDefConfig.get(PipelineOptions.PIPELINE_LOCAL_TIME_ZONE),
+ pipelineDefConfig.get(
+ PipelineOptions.PIPELINE_TRANSFORM_DECIMAL_PRECISION_MODE),
+ pipelineDef.getUdfs(),
+ pipelineDef.getModels(),
+ dataSource.supportedMetadataColumns(),
+ operatorUidGenerator,
+ env);
+ }
if (isParallelMetadataSource) {
// Translate a distributed topology for sources with distributed tables
diff --git a/flink-cdc-composer/src/main/java/org/apache/flink/cdc/composer/flink/translator/TransformTranslator.java b/flink-cdc-composer/src/main/java/org/apache/flink/cdc/composer/flink/translator/TransformTranslator.java
index 99be446f200..a8c650c07b2 100644
--- a/flink-cdc-composer/src/main/java/org/apache/flink/cdc/composer/flink/translator/TransformTranslator.java
+++ b/flink-cdc-composer/src/main/java/org/apache/flink/cdc/composer/flink/translator/TransformTranslator.java
@@ -35,16 +35,22 @@
import org.apache.flink.cdc.runtime.operators.transform.PostTransformOperatorBuilder;
import org.apache.flink.cdc.runtime.operators.transform.PreTransformOperator;
import org.apache.flink.cdc.runtime.operators.transform.PreTransformOperatorBuilder;
+import org.apache.flink.cdc.runtime.operators.transform.async.AsyncPostTransformFunction;
+import org.apache.flink.cdc.runtime.operators.transform.async.AsyncPostTransformFunctionBuilder;
+import org.apache.flink.cdc.runtime.operators.transform.async.AsyncPostTransformOperatorFactory;
import org.apache.flink.cdc.runtime.typeutils.EventTypeInfo;
import org.apache.flink.streaming.api.datastream.DataStream;
import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;
+import java.time.Duration;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
+import static org.apache.flink.cdc.common.utils.Preconditions.checkArgument;
+
/**
* Translator used to build {@link PreTransformOperator} and {@link PostTransformOperator} for event
* transform.
@@ -148,6 +154,70 @@ public DataStream translatePostTransform(
.uid(operatorUidGenerator.generateUid("post-transform"));
}
+ public DataStream translateAsyncPostTransform(
+ DataStream input,
+ List transforms,
+ String timezone,
+ DecimalPrecisionMode decimalPrecisionMode,
+ List udfFunctions,
+ List models,
+ SupportedMetadataColumn[] supportedMetadataColumns,
+ OperatorUidGenerator operatorUidGenerator,
+ Duration asyncTransformTimeout,
+ int asyncTransformCapacity,
+ int asyncTransformWorkerThreads,
+ StreamExecutionEnvironment env) {
+ if (transforms.isEmpty()) {
+ return input;
+ }
+ checkArgument(
+ asyncTransformTimeout.toMillis() > 0,
+ "Async transform timeout must be greater than 0.");
+ checkArgument(
+ asyncTransformCapacity > 0, "Async transform capacity must be greater than 0.");
+ checkArgument(
+ asyncTransformWorkerThreads > 0,
+ "Async transform worker threads must be greater than 0.");
+
+ AsyncPostTransformFunctionBuilder asyncPostTransformFunctionBuilder =
+ AsyncPostTransformFunction.newBuilder();
+ for (TransformDef transform : transforms) {
+ asyncPostTransformFunctionBuilder.addTransform(
+ transform.getSourceTable(),
+ transform.getProjection(),
+ transform.getFilter(),
+ transform.getPrimaryKeys(),
+ transform.getPartitionKeys(),
+ transform.getTableOptions(),
+ transform.getTableOptionsDelimiter(),
+ transform.getPostTransformConverter(),
+ supportedMetadataColumns);
+ }
+ asyncPostTransformFunctionBuilder.addTimezone(timezone);
+ asyncPostTransformFunctionBuilder.addDecimalPrecisionMode(decimalPrecisionMode);
+ asyncPostTransformFunctionBuilder.addUdfFunctions(
+ udfFunctions.stream().map(this::udfDefToUDFTuple).collect(Collectors.toList()));
+ asyncPostTransformFunctionBuilder.addUdfFunctions(
+ models.stream()
+ .filter(ModelDef::isLegacy)
+ .map(this::modelToUDFTuple)
+ .collect(Collectors.toList()));
+ Map modelClients = loadModelClients(models, env);
+ asyncPostTransformFunctionBuilder.addModelClients(modelClients);
+ asyncPostTransformFunctionBuilder.addAsyncWorkerThreads(asyncTransformWorkerThreads);
+
+ long timeoutMillis = asyncTransformTimeout.toMillis();
+ return input.transform(
+ "Transform:Data",
+ new EventTypeInfo(),
+ new AsyncPostTransformOperatorFactory(
+ asyncPostTransformFunctionBuilder.build(),
+ timeoutMillis,
+ asyncTransformCapacity))
+ .name("Transform:Data")
+ .uid(operatorUidGenerator.generateUid("post-transform"));
+ }
+
private Tuple3> modelToUDFTuple(ModelDef model) {
return Tuple3.of(
model.getModelName(),
diff --git a/flink-cdc-composer/src/test/java/org/apache/flink/cdc/composer/flink/FlinkPipelineUdfITCase.java b/flink-cdc-composer/src/test/java/org/apache/flink/cdc/composer/flink/FlinkPipelineUdfITCase.java
index 9d5a8b22e23..2bb4b14f38e 100644
--- a/flink-cdc-composer/src/test/java/org/apache/flink/cdc/composer/flink/FlinkPipelineUdfITCase.java
+++ b/flink-cdc-composer/src/test/java/org/apache/flink/cdc/composer/flink/FlinkPipelineUdfITCase.java
@@ -18,8 +18,19 @@
package org.apache.flink.cdc.composer.flink;
import org.apache.flink.cdc.common.configuration.Configuration;
+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.pipeline.PipelineOptions;
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.types.DataType;
+import org.apache.flink.cdc.common.udf.UserDefinedFunction;
+import org.apache.flink.cdc.common.udf.UserDefinedFunctionContext;
import org.apache.flink.cdc.composer.PipelineExecution;
import org.apache.flink.cdc.composer.definition.ModelDef;
import org.apache.flink.cdc.composer.definition.PipelineDef;
@@ -33,6 +44,7 @@
import org.apache.flink.cdc.connectors.values.sink.ValuesDataSinkOptions;
import org.apache.flink.cdc.connectors.values.source.ValuesDataSourceHelper;
import org.apache.flink.cdc.connectors.values.source.ValuesDataSourceOptions;
+import org.apache.flink.cdc.runtime.typeutils.BinaryRecordDataGenerator;
import org.apache.flink.runtime.testutils.MiniClusterResourceConfiguration;
import org.apache.flink.test.junit5.MiniClusterExtension;
@@ -41,6 +53,7 @@
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Disabled;
+import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.RegisterExtension;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
@@ -48,19 +61,41 @@
import java.io.ByteArrayOutputStream;
import java.io.PrintStream;
+import java.time.Duration;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
import java.util.stream.Stream;
+import static org.apache.flink.cdc.common.types.DataTypes.BIGINT;
+import static org.apache.flink.cdc.common.types.DataTypes.INT;
+import static org.apache.flink.cdc.common.types.DataTypes.STRING;
import static org.apache.flink.configuration.CoreOptions.ALWAYS_PARENT_FIRST_LOADER_PATTERNS_ADDITIONAL;
import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.junit.jupiter.params.provider.Arguments.arguments;
/** Integration test for UDFs. */
class FlinkPipelineUdfITCase {
private static final int MAX_PARALLELISM = 4;
+ private static final TableId ASYNC_TRANSFORM_TABLE_ID =
+ TableId.tableId("foo", "bar", "async_transform");
+ private static final Schema ASYNC_TRANSFORM_SCHEMA =
+ Schema.newBuilder()
+ .physicalColumn("id_", BIGINT().notNull())
+ .physicalColumn("name_", STRING())
+ .build();
+ private static final Schema ASYNC_TRANSFORM_SCHEMA_WITH_REGION =
+ Schema.newBuilder()
+ .physicalColumn("id_", BIGINT().notNull())
+ .physicalColumn("name_", STRING())
+ .physicalColumn("region_", STRING())
+ .build();
// Always use parent-first classloader for CDC classes.
// The reason is that ValuesDatabase uses static field for holding data, we need to make sure
@@ -106,6 +141,113 @@ void cleanup() {
// ----------------------
// CDC pipeline UDF tests
// ----------------------
+ @Test
+ void testAsyncTransformExecutesDataConcurrentlyInOrderWithSchemaBarrier() throws Exception {
+ FlinkPipelineComposer composer = FlinkPipelineComposer.ofMiniCluster();
+
+ Configuration sourceConfig = new Configuration();
+ sourceConfig.set(
+ ValuesDataSourceOptions.EVENT_SET_ID,
+ ValuesDataSourceHelper.EventSetId.CUSTOM_SOURCE_EVENTS);
+ SourceDef sourceDef =
+ new SourceDef(ValuesDataFactory.IDENTIFIER, "Value Source", sourceConfig);
+
+ Configuration sinkConfig = new Configuration();
+ sinkConfig.set(ValuesDataSinkOptions.PRINT_ENABLED, true);
+ SinkDef sinkDef = new SinkDef(ValuesDataFactory.IDENTIFIER, "Value Sink", sinkConfig);
+
+ ValuesDataSourceHelper.setSourceEvents(
+ Collections.singletonList(createAsyncTransformEvents()));
+
+ TransformDef transformDef =
+ new TransformDef(
+ ASYNC_TRANSFORM_TABLE_ID.toString(),
+ "*, completion_order(id_) AS completion_order_",
+ null,
+ null,
+ null,
+ null,
+ "async transform ordering",
+ null);
+ UdfDef udfDef = new UdfDef("completion_order", CompletionOrderUdf.class.getName());
+
+ Configuration pipelineConfig = new Configuration();
+ pipelineConfig.set(PipelineOptions.PIPELINE_PARALLELISM, 1);
+ pipelineConfig.set(
+ PipelineOptions.PIPELINE_SCHEMA_CHANGE_BEHAVIOR, SchemaChangeBehavior.EVOLVE);
+ pipelineConfig.set(PipelineOptions.PIPELINE_TRANSFORM_ASYNC_EXECUTION_ENABLED, true);
+ pipelineConfig.set(PipelineOptions.PIPELINE_TRANSFORM_ASYNC_EXECUTION_WORKER_THREADS, 2);
+ PipelineDef pipelineDef =
+ new PipelineDef(
+ sourceDef,
+ sinkDef,
+ Collections.emptyList(),
+ Collections.singletonList(transformDef),
+ Collections.singletonList(udfDef),
+ pipelineConfig);
+
+ PipelineExecution execution = composer.compose(pipelineDef);
+ execution.execute();
+
+ assertThat(outCaptor.toString().trim().split("\n"))
+ .containsExactly(
+ "CreateTableEvent{tableId=foo.bar.async_transform, schema=columns={`id_` BIGINT NOT NULL,`name_` STRING,`completion_order_` INT}, primaryKeys=, options=()}",
+ "DataChangeEvent{tableId=foo.bar.async_transform, before=[], after=[1, name-1, 2], op=INSERT, meta=()}",
+ "DataChangeEvent{tableId=foo.bar.async_transform, before=[], after=[2, name-2, 1], op=INSERT, meta=()}",
+ "AddColumnEvent{tableId=foo.bar.async_transform, addedColumns=[ColumnWithPosition{column=`region_` STRING, position=AFTER, existedColumnName=name_}]}",
+ "DataChangeEvent{tableId=foo.bar.async_transform, before=[], after=[3, name-3, region-3, 3], op=INSERT, meta=()}");
+ }
+
+ @ParameterizedTest(name = "{0}")
+ @MethodSource("asyncTransformFailureCases")
+ void testAsyncTransformPropagatesFailures(
+ String testName, String udfClassName, Duration timeout, String expectedMessage) {
+ FlinkPipelineComposer composer = FlinkPipelineComposer.ofMiniCluster();
+
+ Configuration sourceConfig = new Configuration();
+ sourceConfig.set(
+ ValuesDataSourceOptions.EVENT_SET_ID,
+ ValuesDataSourceHelper.EventSetId.CUSTOM_SOURCE_EVENTS);
+ SourceDef sourceDef =
+ new SourceDef(ValuesDataFactory.IDENTIFIER, "Value Source", sourceConfig);
+
+ Configuration sinkConfig = new Configuration();
+ SinkDef sinkDef = new SinkDef(ValuesDataFactory.IDENTIFIER, "Value Sink", sinkConfig);
+
+ ValuesDataSourceHelper.setSourceEvents(
+ Collections.singletonList(createSingleAsyncTransformRecord()));
+
+ TransformDef transformDef =
+ new TransformDef(
+ ASYNC_TRANSFORM_TABLE_ID.toString(),
+ "*, test_failure(name_) AS result_",
+ null,
+ null,
+ null,
+ null,
+ testName,
+ null);
+ UdfDef udfDef = new UdfDef("test_failure", udfClassName);
+
+ Configuration pipelineConfig = new Configuration();
+ pipelineConfig.set(PipelineOptions.PIPELINE_PARALLELISM, 1);
+ pipelineConfig.set(
+ PipelineOptions.PIPELINE_SCHEMA_CHANGE_BEHAVIOR, SchemaChangeBehavior.EVOLVE);
+ pipelineConfig.set(PipelineOptions.PIPELINE_TRANSFORM_ASYNC_EXECUTION_ENABLED, true);
+ pipelineConfig.set(PipelineOptions.PIPELINE_TRANSFORM_ASYNC_EXECUTION_TIMEOUT, timeout);
+ PipelineDef pipelineDef =
+ new PipelineDef(
+ sourceDef,
+ sinkDef,
+ Collections.emptyList(),
+ Collections.singletonList(transformDef),
+ Collections.singletonList(udfDef),
+ pipelineConfig);
+
+ assertThatThrownBy(() -> composer.compose(pipelineDef).execute())
+ .hasStackTraceContaining(expectedMessage);
+ }
+
@ParameterizedTest
@MethodSource("testParams")
void testTransformWithUdf(ValuesDataSink.SinkApi sinkApi, String language) throws Exception {
@@ -1095,4 +1237,148 @@ private static Stream testParams() {
arguments(ValuesDataSink.SinkApi.SINK_FUNCTION, "scala"),
arguments(ValuesDataSink.SinkApi.SINK_V2, "scala"));
}
+
+ private static List createAsyncTransformEvents() {
+ BinaryRecordDataGenerator initialSchemaGenerator =
+ new BinaryRecordDataGenerator(
+ ASYNC_TRANSFORM_SCHEMA.getColumnDataTypes().toArray(new DataType[0]));
+ BinaryRecordDataGenerator schemaWithRegionGenerator =
+ new BinaryRecordDataGenerator(
+ ASYNC_TRANSFORM_SCHEMA_WITH_REGION
+ .getColumnDataTypes()
+ .toArray(new DataType[0]));
+ List events = new ArrayList<>();
+ events.add(new CreateTableEvent(ASYNC_TRANSFORM_TABLE_ID, ASYNC_TRANSFORM_SCHEMA));
+ events.add(
+ DataChangeEvent.insertEvent(
+ ASYNC_TRANSFORM_TABLE_ID,
+ initialSchemaGenerator.generate(
+ new Object[] {1L, BinaryStringData.fromString("name-1")})));
+ events.add(
+ DataChangeEvent.insertEvent(
+ ASYNC_TRANSFORM_TABLE_ID,
+ initialSchemaGenerator.generate(
+ new Object[] {2L, BinaryStringData.fromString("name-2")})));
+ events.add(
+ new AddColumnEvent(
+ ASYNC_TRANSFORM_TABLE_ID,
+ Collections.singletonList(
+ AddColumnEvent.last(Column.physicalColumn("region_", STRING())))));
+ events.add(
+ DataChangeEvent.insertEvent(
+ ASYNC_TRANSFORM_TABLE_ID,
+ schemaWithRegionGenerator.generate(
+ new Object[] {
+ 3L,
+ BinaryStringData.fromString("name-3"),
+ BinaryStringData.fromString("region-3")
+ })));
+ return events;
+ }
+
+ private static List createSingleAsyncTransformRecord() {
+ BinaryRecordDataGenerator generator =
+ new BinaryRecordDataGenerator(
+ ASYNC_TRANSFORM_SCHEMA.getColumnDataTypes().toArray(new DataType[0]));
+ return Arrays.asList(
+ new CreateTableEvent(ASYNC_TRANSFORM_TABLE_ID, ASYNC_TRANSFORM_SCHEMA),
+ DataChangeEvent.insertEvent(
+ ASYNC_TRANSFORM_TABLE_ID,
+ generator.generate(
+ new Object[] {1L, BinaryStringData.fromString("name-1")})));
+ }
+
+ private static Stream asyncTransformFailureCases() {
+ return Stream.of(
+ arguments(
+ "UDF exception",
+ FailingUdf.class.getName(),
+ Duration.ofSeconds(30),
+ "expected async transform failure"),
+ arguments(
+ "timeout",
+ TimeoutUdf.class.getName(),
+ Duration.ofMillis(100),
+ "Async post-transform timed out for event"));
+ }
+
+ public static class CompletionOrderUdf implements UserDefinedFunction {
+
+ private transient CountDownLatch secondRecordCompleted;
+ private transient CountDownLatch firstRecordCompleted;
+ private transient AtomicInteger completionOrder;
+
+ @Override
+ public DataType getReturnType(UserDefinedFunctionContext context) {
+ return INT();
+ }
+
+ @Override
+ public void open(UserDefinedFunctionContext context) {
+ secondRecordCompleted = new CountDownLatch(1);
+ firstRecordCompleted = new CountDownLatch(1);
+ completionOrder = new AtomicInteger();
+ }
+
+ public Integer eval(Long value) {
+ if (value == 1L) {
+ await(secondRecordCompleted);
+ int order = completionOrder.incrementAndGet();
+ firstRecordCompleted.countDown();
+ return order;
+ }
+ if (value == 2L) {
+ int order = completionOrder.incrementAndGet();
+ secondRecordCompleted.countDown();
+ return order;
+ }
+ if (firstRecordCompleted.getCount() != 0) {
+ throw new IllegalStateException(
+ "Data after the schema change ran before preceding data completed.");
+ }
+ return completionOrder.incrementAndGet();
+ }
+
+ private void await(CountDownLatch latch) {
+ try {
+ if (!latch.await(10, TimeUnit.SECONDS)) {
+ throw new IllegalStateException(
+ "Later data was not executed concurrently with earlier data.");
+ }
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new RuntimeException("Interrupted while waiting for concurrent data.", e);
+ }
+ }
+ }
+
+ public static class FailingUdf implements UserDefinedFunction {
+
+ @Override
+ public DataType getReturnType(UserDefinedFunctionContext context) {
+ return STRING();
+ }
+
+ public String eval(String value) {
+ throw new IllegalStateException("expected async transform failure");
+ }
+ }
+
+ public static class TimeoutUdf implements UserDefinedFunction {
+
+ @Override
+ public DataType getReturnType(UserDefinedFunctionContext context) {
+ return STRING();
+ }
+
+ public String eval(String value) {
+ try {
+ Thread.sleep(Duration.ofSeconds(30).toMillis());
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new RuntimeException("Interrupted while waiting for timeout.", e);
+ }
+ return value;
+ }
+ }
}
diff --git a/flink-cdc-composer/src/test/java/org/apache/flink/cdc/composer/flink/translator/TransformTranslatorTest.java b/flink-cdc-composer/src/test/java/org/apache/flink/cdc/composer/flink/translator/TransformTranslatorTest.java
new file mode 100644
index 00000000000..12aa4c3ca81
--- /dev/null
+++ b/flink-cdc-composer/src/test/java/org/apache/flink/cdc/composer/flink/translator/TransformTranslatorTest.java
@@ -0,0 +1,79 @@
+/*
+ * 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.translator;
+
+import org.apache.flink.cdc.common.event.CreateTableEvent;
+import org.apache.flink.cdc.common.event.Event;
+import org.apache.flink.cdc.common.event.TableId;
+import org.apache.flink.cdc.common.pipeline.DecimalPrecisionMode;
+import org.apache.flink.cdc.common.schema.Schema;
+import org.apache.flink.cdc.common.source.SupportedMetadataColumn;
+import org.apache.flink.cdc.common.types.DataTypes;
+import org.apache.flink.cdc.composer.definition.TransformDef;
+import org.apache.flink.cdc.runtime.operators.transform.async.AsyncPostTransformOperatorFactory;
+import org.apache.flink.cdc.runtime.typeutils.EventTypeInfo;
+import org.apache.flink.streaming.api.datastream.DataStream;
+import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;
+import org.apache.flink.streaming.api.transformations.OneInputTransformation;
+
+import org.junit.jupiter.api.Test;
+
+import java.time.Duration;
+import java.util.Collections;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/** Tests for {@link TransformTranslator}. */
+class TransformTranslatorTest {
+
+ @Test
+ void testTranslateAsyncPostTransformUsesStateConsistentOperator() {
+ StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
+ TableId tableId = TableId.tableId("ns", "schema", "customers");
+ Schema schema = Schema.newBuilder().physicalColumn("id", DataTypes.INT()).build();
+ DataStream input =
+ env.fromCollection(
+ Collections.singletonList((Event) new CreateTableEvent(tableId, schema)),
+ new EventTypeInfo());
+ TransformDef transform =
+ new TransformDef(tableId.identifier(), "*", null, null, null, null, null, null);
+
+ DataStream result =
+ new TransformTranslator()
+ .translateAsyncPostTransform(
+ input,
+ Collections.singletonList(transform),
+ "UTC",
+ DecimalPrecisionMode.UP_TO_19,
+ Collections.emptyList(),
+ Collections.emptyList(),
+ new SupportedMetadataColumn[0],
+ new OperatorUidGenerator("test"),
+ Duration.ofSeconds(30),
+ 10,
+ 2,
+ env);
+
+ assertThat(result.getTransformation())
+ .isInstanceOfSatisfying(
+ OneInputTransformation.class,
+ transformation ->
+ assertThat(transformation.getOperatorFactory())
+ .isInstanceOf(AsyncPostTransformOperatorFactory.class));
+ }
+}
diff --git a/flink-cdc-e2e-tests/flink-cdc-pipeline-e2e-tests/src/test/java/org/apache/flink/cdc/pipeline/tests/TransformE2eITCase.java b/flink-cdc-e2e-tests/flink-cdc-pipeline-e2e-tests/src/test/java/org/apache/flink/cdc/pipeline/tests/TransformE2eITCase.java
index 4f9c7bd19af..3fe6e85c280 100644
--- a/flink-cdc-e2e-tests/flink-cdc-pipeline-e2e-tests/src/test/java/org/apache/flink/cdc/pipeline/tests/TransformE2eITCase.java
+++ b/flink-cdc-e2e-tests/flink-cdc-pipeline-e2e-tests/src/test/java/org/apache/flink/cdc/pipeline/tests/TransformE2eITCase.java
@@ -20,19 +20,23 @@
import org.apache.flink.api.common.JobID;
import org.apache.flink.cdc.common.data.DateData;
import org.apache.flink.cdc.common.data.TimeData;
+import org.apache.flink.cdc.common.test.utils.TestUtils;
import org.apache.flink.cdc.connectors.mysql.testutils.UniqueDatabase;
import org.apache.flink.cdc.pipeline.tests.utils.PipelineTestEnvironment;
import org.apache.flink.cdc.runtime.operators.transform.PostTransformOperator;
import org.apache.flink.cdc.runtime.operators.transform.PreTransformOperator;
+import org.assertj.core.api.Assumptions;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedClass;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
+import java.nio.file.Path;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.SQLException;
@@ -52,8 +56,11 @@
import static org.assertj.core.api.Assertions.assertThat;
/** E2e tests for the {@link PreTransformOperator} and {@link PostTransformOperator}. */
+@ParameterizedClass(name = "asyncTransform: {0}")
+@ValueSource(booleans = {false, true})
class TransformE2eITCase extends PipelineTestEnvironment {
private static final Logger LOG = LoggerFactory.getLogger(TransformE2eITCase.class);
+ private static final Duration ASYNC_RESTORE_TIMEOUT = Duration.ofMinutes(3);
protected final UniqueDatabase transformTestDatabase =
new UniqueDatabase(MYSQL, "transform_test", MYSQL_TEST_USER, MYSQL_TEST_PASSWORD);
@@ -64,6 +71,17 @@ class TransformE2eITCase extends PipelineTestEnvironment {
return String.format(s, databaseName, databaseName, databaseName);
};
+ private final boolean asyncTransform;
+
+ TransformE2eITCase(boolean asyncTransform) {
+ this.asyncTransform = asyncTransform;
+ }
+
+ private String withAsyncTransform(String pipelineJob) {
+ return pipelineJob
+ + String.format("%n transform.async-execution.enabled: %s", asyncTransform);
+ }
+
@BeforeEach
public void before() throws Exception {
super.before();
@@ -76,6 +94,107 @@ public void after() {
transformTestDatabase.dropDatabase();
}
+ @Test
+ void testAsyncTransformRestoreAfterInflightEvents() throws Exception {
+ Assumptions.assumeThat(asyncTransform).isTrue();
+
+ int slowRecordId = 4000;
+ int lastRecordId = 4007;
+ int slowRecordDelaySeconds = 15;
+ String databaseName = transformTestDatabase.getDatabaseName();
+ String pipelineJob =
+ String.format(
+ "source:\n"
+ + " type: mysql\n"
+ + " hostname: %s\n"
+ + " port: 3306\n"
+ + " username: %s\n"
+ + " password: %s\n"
+ + " scan.startup.mode: initial\n"
+ + " tables: %s.TABLEALPHA\n"
+ + " server-id: 5400-5404\n"
+ + " server-time-zone: UTC\n"
+ + "\n"
+ + "sink:\n"
+ + " type: values\n"
+ + "\n"
+ + "transform:\n"
+ + " - source-table: %s.TABLEALPHA\n"
+ + " projection: \\*, throttle(ID, %d, %d) AS THROTTLED\n"
+ + "\n"
+ + "pipeline:\n"
+ + " transform.async-execution.enabled: true\n"
+ + " transform.async-execution.timeout: 1m\n"
+ + " transform.async-execution.capacity: 16\n"
+ + " transform.async-execution.worker-threads: 4\n"
+ + " parallelism: 1\n"
+ + " user-defined-function:\n"
+ + " - name: throttle\n"
+ + " classpath: org.apache.flink.cdc.udf.examples.java.SkewedThrottlerFunctionClass\n",
+ INTER_CONTAINER_MYSQL_ALIAS,
+ MYSQL_TEST_USER,
+ MYSQL_TEST_PASSWORD,
+ databaseName,
+ databaseName,
+ slowRecordId,
+ slowRecordDelaySeconds);
+ Path udfJar = TestUtils.getResource("udf-examples.jar");
+
+ JobID jobId = submitPipelineJob(pipelineJob, udfJar);
+ waitUntilJobRunning(Duration.ofSeconds(30));
+ waitUntilStreamSplitReady(jobId, 1);
+
+ String mysqlJdbcUrl =
+ String.format(
+ "jdbc:mysql://%s:%s/%s",
+ MYSQL.getHost(), MYSQL.getDatabasePort(), databaseName);
+ int incrementalOutputOffset = taskManagerConsumer.toUtf8String().length();
+ addRegionColumnToTableAlpha(mysqlJdbcUrl);
+ insertTableAlphaRows(mysqlJdbcUrl, slowRecordId, lastRecordId);
+
+ waitUntilEventAppearsBeforeAnother(
+ incrementalOutputOffset,
+ ASYNC_RESTORE_TIMEOUT,
+ "SkewedThrottlerFunctionClass finished " + lastRecordId,
+ "SkewedThrottlerFunctionClass finished " + slowRecordId);
+
+ String savepointPath = stopJobWithSavepoint(jobId);
+ waitUntilSpecificEventsAfter(
+ incrementalOutputOffset,
+ ASYNC_RESTORE_TIMEOUT,
+ tableAlphaInsertEvent(databaseName, slowRecordId),
+ tableAlphaInsertEvent(databaseName, lastRecordId));
+
+ int restoredOutputOffset = taskManagerConsumer.toUtf8String().length();
+ JobID restoredJobId = submitPipelineJob(pipelineJob, savepointPath, false, udfJar);
+ waitUntilJobRunning(Duration.ofSeconds(30));
+
+ insertTableAlphaRows(mysqlJdbcUrl, 5000, 5001);
+ String restoredCreateTableEvent =
+ String.format(
+ "CreateTableEvent{tableId=%s.TABLEALPHA, schema=columns={`ID` INT NOT NULL,`VERSION` VARCHAR(17),`PRICEALPHA` INT,`AGEALPHA` INT,`NAMEALPHA` VARCHAR(128),`REGION` VARCHAR(17),`THROTTLED` STRING}, primaryKeys=ID, options=()}",
+ databaseName);
+ waitUntilSpecificEventsAfter(
+ restoredOutputOffset,
+ ASYNC_RESTORE_TIMEOUT,
+ restoredCreateTableEvent,
+ tableAlphaInsertEvent(databaseName, 5000),
+ tableAlphaInsertEvent(databaseName, 5001));
+
+ String restoredOutput =
+ taskManagerConsumer
+ .toUtf8String()
+ .substring(
+ Math.min(
+ restoredOutputOffset,
+ taskManagerConsumer.toUtf8String().length()));
+ assertThat(restoredOutput).containsOnlyOnce(restoredCreateTableEvent);
+ for (int id = slowRecordId; id <= lastRecordId; id++) {
+ assertThat(restoredOutput).doesNotContain(tableAlphaInsertEvent(databaseName, id));
+ }
+ cancelJob(restoredJobId);
+ }
+
@ParameterizedTest(name = "batchMode: {0}")
@ValueSource(booleans = {true, false})
void testHeteroSchemaTransform(boolean batchMode) throws Exception {
@@ -118,7 +237,7 @@ void testHeteroSchemaTransform(boolean batchMode) throws Exception {
transformTestDatabase.getDatabaseName(),
runtimeMode,
parallelism);
- JobID jobId = submitPipelineJob(pipelineJob);
+ JobID jobId = submitPipelineJob(withAsyncTransform(pipelineJob));
waitUntilJobRunning(Duration.ofSeconds(30));
LOG.info("Pipeline job is running");
@@ -202,7 +321,7 @@ void testMultipleTransformRule(boolean batchMode) throws Exception {
transformTestDatabase.getDatabaseName(),
runtimeMode,
testParallelism);
- JobID jobId = submitPipelineJob(pipelineJob);
+ JobID jobId = submitPipelineJob(withAsyncTransform(pipelineJob));
waitUntilJobRunning(Duration.ofSeconds(30));
LOG.info("Pipeline job is running");
@@ -293,7 +412,7 @@ void testAssortedSchemaTransform(boolean batchMode) throws Exception {
transformTestDatabase.getDatabaseName(),
runtimeMode,
parallelism);
- JobID jobId = submitPipelineJob(pipelineJob);
+ JobID jobId = submitPipelineJob(withAsyncTransform(pipelineJob));
waitUntilJobRunning(Duration.ofSeconds(30));
LOG.info("Pipeline job is running");
@@ -375,7 +494,7 @@ void testWildcardSchemaTransform(boolean batchMode) throws Exception {
transformTestDatabase.getDatabaseName(),
runtimeMode,
parallelism);
- JobID jobId = submitPipelineJob(pipelineJob);
+ JobID jobId = submitPipelineJob(withAsyncTransform(pipelineJob));
waitUntilJobRunning(Duration.ofSeconds(30));
LOG.info("Pipeline job is running");
@@ -465,7 +584,7 @@ void testWildcardSchemaTransformWithFilter(boolean batchMode) throws Exception {
transformTestDatabase.getDatabaseName(),
runtimeMode,
testParallelism);
- JobID jobId = submitPipelineJob(pipelineJob);
+ JobID jobId = submitPipelineJob(withAsyncTransform(pipelineJob));
waitUntilJobRunning(Duration.ofSeconds(30));
LOG.info("Pipeline job is running");
@@ -548,7 +667,7 @@ void testWildcardWithMetadataColumnTransform(boolean batchMode) throws Exception
transformTestDatabase.getDatabaseName(),
runtimeMode,
parallelism);
- JobID jobId = submitPipelineJob(pipelineJob);
+ JobID jobId = submitPipelineJob(withAsyncTransform(pipelineJob));
waitUntilJobRunning(Duration.ofSeconds(30));
LOG.info("Pipeline job is running");
@@ -634,7 +753,7 @@ void testMultipleHittingTable(boolean batchMode) throws Exception {
transformTestDatabase.getDatabaseName(),
runtimeMode,
testParallelism);
- JobID jobId = submitPipelineJob(pipelineJob);
+ JobID jobId = submitPipelineJob(withAsyncTransform(pipelineJob));
waitUntilJobRunning(Duration.ofSeconds(30));
LOG.info("Pipeline job is running");
@@ -715,7 +834,7 @@ void testMultipleTransformWithDiffRefColumn(boolean batchMode) throws Exception
transformTestDatabase.getDatabaseName(),
runtimeMode,
parallelism);
- JobID jobId = submitPipelineJob(pipelineJob);
+ JobID jobId = submitPipelineJob(withAsyncTransform(pipelineJob));
waitUntilJobRunning(Duration.ofSeconds(30));
LOG.info("Pipeline job is running");
@@ -795,7 +914,7 @@ void testTransformWithCast(boolean batchMode) throws Exception {
transformTestDatabase.getDatabaseName(),
runtimeMode,
parallelism);
- JobID jobId = submitPipelineJob(pipelineJob);
+ JobID jobId = submitPipelineJob(withAsyncTransform(pipelineJob));
waitUntilJobRunning(Duration.ofSeconds(30));
if (batchMode) {
@@ -877,7 +996,7 @@ void testTemporalFunctions(boolean batchMode) throws Exception {
transformTestDatabase.getDatabaseName(),
runtimeMode,
parallelism);
- JobID jobId = submitPipelineJob(pipelineJob);
+ JobID jobId = submitPipelineJob(withAsyncTransform(pipelineJob));
waitUntilJobRunning(Duration.ofSeconds(30));
LOG.info("Pipeline job is running");
@@ -912,7 +1031,7 @@ void testTransformWithSchemaEvolution() throws Exception {
transformTestDatabase.getDatabaseName(),
transformTestDatabase.getDatabaseName(),
parallelism);
- JobID jobId = submitPipelineJob(pipelineJob);
+ JobID jobId = submitPipelineJob(withAsyncTransform(pipelineJob));
waitUntilJobRunning(Duration.ofSeconds(30));
LOG.info("Pipeline job is running");
@@ -1007,7 +1126,7 @@ void testTransformWildcardPrefixWithSchemaEvolution() throws Exception {
transformTestDatabase.getDatabaseName(),
transformTestDatabase.getDatabaseName(),
parallelism);
- JobID jobId = submitPipelineJob(pipelineJob);
+ JobID jobId = submitPipelineJob(withAsyncTransform(pipelineJob));
waitUntilJobRunning(Duration.ofSeconds(30));
LOG.info("Pipeline job is running");
@@ -1110,7 +1229,7 @@ void testTransformWildcardSuffixWithSchemaEvolution() throws Exception {
transformTestDatabase.getDatabaseName(),
transformTestDatabase.getDatabaseName(),
parallelism);
- JobID jobId = submitPipelineJob(pipelineJob);
+ JobID jobId = submitPipelineJob(withAsyncTransform(pipelineJob));
waitUntilJobRunning(Duration.ofSeconds(30));
LOG.info("Pipeline job is running");
@@ -1243,7 +1362,7 @@ void testTransformWithUnicodeLiterals() throws Exception {
transformTestDatabase.getDatabaseName(),
projectionExpression,
parallelism);
- JobID jobId = submitPipelineJob(pipelineJob);
+ JobID jobId = submitPipelineJob(withAsyncTransform(pipelineJob));
waitUntilJobRunning(Duration.ofSeconds(30));
LOG.info("Pipeline job is running");
@@ -1320,6 +1439,97 @@ void testTransformWithUnicodeLiterals() throws Exception {
"DataChangeEvent{tableId=%s.TABLEALPHA, before=[], after=[Beginning, 3010, 10, 10, 97, Lemon, ascii test!?, 大五, 测试数据, ひびぴ, 죠주쥬, ÀÆÉ, ÓÔŐÖ, αβγδε, בבקשה, твой, ภาษาไทย, piedzimst brīvi], op=INSERT, meta=()}");
}
+ private static void addRegionColumnToTableAlpha(String mysqlJdbcUrl) throws SQLException {
+ try (Connection conn =
+ DriverManager.getConnection(
+ mysqlJdbcUrl, MYSQL_TEST_USER, MYSQL_TEST_PASSWORD);
+ Statement stat = conn.createStatement()) {
+ stat.execute("ALTER TABLE TABLEALPHA ADD COLUMN REGION VARCHAR(17);");
+ }
+ }
+
+ private static void insertTableAlphaRows(
+ String mysqlJdbcUrl, int startInclusive, int endInclusive) throws SQLException {
+ try (Connection conn =
+ DriverManager.getConnection(
+ mysqlJdbcUrl, MYSQL_TEST_USER, MYSQL_TEST_PASSWORD);
+ Statement stat = conn.createStatement()) {
+ for (int id = startInclusive; id <= endInclusive; id++) {
+ stat.execute(
+ String.format(
+ "INSERT INTO TABLEALPHA(ID, VERSION, PRICEALPHA, AGEALPHA, NAMEALPHA) VALUES (%d, '%d', %d, %d, 'Bulk%d')",
+ id, id, id, id % 100, id));
+ }
+ }
+ }
+
+ private static String tableAlphaInsertEvent(String databaseName, int id) {
+ return String.format(
+ "DataChangeEvent{tableId=%s.TABLEALPHA, before=[], after=[%d, %d, %d, %d, Bulk%d, null, throttled_%d], op=INSERT, meta=()}",
+ databaseName, id, id, id, id % 100, id, id);
+ }
+
+ private void waitUntilSpecificEventsAfter(int offset, Duration timeout, String... events)
+ throws Exception {
+ long deadline = System.currentTimeMillis() + timeout.toMillis();
+ while (System.currentTimeMillis() < deadline) {
+ String stdout = taskManagerConsumer.toUtf8String();
+ int searchOffset = Math.min(offset, stdout.length());
+ boolean matched = true;
+ for (String event : events) {
+ int eventOffset = stdout.indexOf(event, searchOffset);
+ if (eventOffset < 0) {
+ matched = false;
+ break;
+ }
+ searchOffset = eventOffset + event.length();
+ }
+ if (matched) {
+ return;
+ }
+ Thread.sleep(1000L);
+ }
+ throw new TimeoutException(
+ "Failed to get events after offset "
+ + offset
+ + ": "
+ + Arrays.toString(events)
+ + " from stdout: "
+ + taskManagerConsumer.toUtf8String());
+ }
+
+ private void waitUntilEventAppearsBeforeAnother(
+ int offset, Duration timeout, String expectedEvent, String unexpectedEarlierEvent)
+ throws Exception {
+ long deadline = System.currentTimeMillis() + timeout.toMillis();
+ while (System.currentTimeMillis() < deadline) {
+ String stdout = taskManagerConsumer.toUtf8String();
+ String outputAfterOffset = stdout.substring(Math.min(offset, stdout.length()));
+ int expectedOffset = outputAfterOffset.indexOf(expectedEvent);
+ int unexpectedOffset = outputAfterOffset.indexOf(unexpectedEarlierEvent);
+ if (unexpectedOffset >= 0
+ && (expectedOffset < 0 || unexpectedOffset < expectedOffset)) {
+ throw new AssertionError(
+ unexpectedEarlierEvent
+ + " appeared before "
+ + expectedEvent
+ + " in stdout: "
+ + stdout);
+ }
+ if (expectedOffset >= 0) {
+ return;
+ }
+ Thread.sleep(1000L);
+ }
+ throw new TimeoutException(
+ "Failed to get event after offset "
+ + offset
+ + ": "
+ + expectedEvent
+ + " from stdout: "
+ + taskManagerConsumer.toUtf8String());
+ }
+
private void validateEventsWithPattern(String... patterns) throws Exception {
for (String pattern : patterns) {
waitUntilSpecificEventWithPattern(
diff --git a/flink-cdc-flink1-compat/src/main/java/org/apache/flink/cdc/runtime/operators/AsyncWaitOperatorAdapter.java b/flink-cdc-flink1-compat/src/main/java/org/apache/flink/cdc/runtime/operators/AsyncWaitOperatorAdapter.java
new file mode 100644
index 00000000000..0b8dc06f6a1
--- /dev/null
+++ b/flink-cdc-flink1-compat/src/main/java/org/apache/flink/cdc/runtime/operators/AsyncWaitOperatorAdapter.java
@@ -0,0 +1,61 @@
+/*
+ * 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;
+
+import org.apache.flink.annotation.Internal;
+import org.apache.flink.api.common.operators.MailboxExecutor;
+import org.apache.flink.streaming.api.datastream.AsyncDataStream;
+import org.apache.flink.streaming.api.functions.async.AsyncFunction;
+import org.apache.flink.streaming.api.operators.StreamOperatorParameters;
+import org.apache.flink.streaming.api.operators.async.AsyncWaitOperator;
+import org.apache.flink.streaming.runtime.tasks.ProcessingTimeService;
+import org.apache.flink.streaming.util.retryable.AsyncRetryStrategies;
+
+/** Flink 1.20 adapter for an ordered async operator with state-consistent checkpoints. */
+@Internal
+public class AsyncWaitOperatorAdapter extends AsyncWaitOperator {
+
+ private static final long serialVersionUID = 1L;
+
+ @SuppressWarnings("unchecked")
+ public AsyncWaitOperatorAdapter(
+ StreamOperatorParameters parameters,
+ AsyncFunction asyncFunction,
+ long timeout,
+ int capacity,
+ ProcessingTimeService processingTimeService,
+ MailboxExecutor mailboxExecutor) {
+ super(
+ asyncFunction,
+ timeout,
+ capacity,
+ AsyncDataStream.OutputMode.ORDERED,
+ AsyncRetryStrategies.NO_RETRY_STRATEGY,
+ processingTimeService,
+ mailboxExecutor);
+ setup(parameters.getContainingTask(), parameters.getStreamConfig(), parameters.getOutput());
+ }
+
+ @Override
+ public void prepareSnapshotPreBarrier(long checkpointId) throws Exception {
+ // The async function owns schema state, so it must not advance beyond records retained in
+ // AsyncWaitOperator's recovery queue.
+ endInput();
+ super.prepareSnapshotPreBarrier(checkpointId);
+ }
+}
diff --git a/flink-cdc-flink2-compat/src/main/java/org/apache/flink/cdc/runtime/operators/AsyncWaitOperatorAdapter.java b/flink-cdc-flink2-compat/src/main/java/org/apache/flink/cdc/runtime/operators/AsyncWaitOperatorAdapter.java
new file mode 100644
index 00000000000..0e2a54e1f4f
--- /dev/null
+++ b/flink-cdc-flink2-compat/src/main/java/org/apache/flink/cdc/runtime/operators/AsyncWaitOperatorAdapter.java
@@ -0,0 +1,61 @@
+/*
+ * 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;
+
+import org.apache.flink.annotation.Internal;
+import org.apache.flink.api.common.operators.MailboxExecutor;
+import org.apache.flink.streaming.api.datastream.AsyncDataStream;
+import org.apache.flink.streaming.api.functions.async.AsyncFunction;
+import org.apache.flink.streaming.api.operators.StreamOperatorParameters;
+import org.apache.flink.streaming.api.operators.async.AsyncWaitOperator;
+import org.apache.flink.streaming.runtime.tasks.ProcessingTimeService;
+import org.apache.flink.streaming.util.retryable.AsyncRetryStrategies;
+
+/** Flink 2.2 adapter for an ordered async operator with state-consistent checkpoints. */
+@Internal
+public class AsyncWaitOperatorAdapter extends AsyncWaitOperator {
+
+ private static final long serialVersionUID = 1L;
+
+ @SuppressWarnings("unchecked")
+ public AsyncWaitOperatorAdapter(
+ StreamOperatorParameters parameters,
+ AsyncFunction asyncFunction,
+ long timeout,
+ int capacity,
+ ProcessingTimeService processingTimeService,
+ MailboxExecutor mailboxExecutor) {
+ super(
+ parameters,
+ asyncFunction,
+ timeout,
+ capacity,
+ AsyncDataStream.OutputMode.ORDERED,
+ AsyncRetryStrategies.NO_RETRY_STRATEGY,
+ processingTimeService,
+ mailboxExecutor);
+ }
+
+ @Override
+ public void prepareSnapshotPreBarrier(long checkpointId) throws Exception {
+ // The async function owns schema state, so it must not advance beyond records retained in
+ // AsyncWaitOperator's recovery queue.
+ endInput();
+ super.prepareSnapshotPreBarrier(checkpointId);
+ }
+}
diff --git a/flink-cdc-pipeline-udf-examples/src/main/java/org/apache/flink/cdc/udf/examples/java/SkewedThrottlerFunctionClass.java b/flink-cdc-pipeline-udf-examples/src/main/java/org/apache/flink/cdc/udf/examples/java/SkewedThrottlerFunctionClass.java
new file mode 100644
index 00000000000..034db744464
--- /dev/null
+++ b/flink-cdc-pipeline-udf-examples/src/main/java/org/apache/flink/cdc/udf/examples/java/SkewedThrottlerFunctionClass.java
@@ -0,0 +1,44 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.flink.cdc.udf.examples.java;
+
+import org.apache.flink.cdc.common.udf.UserDefinedFunction;
+
+/** A UDF that makes one record deliberately slower than later records. */
+public class SkewedThrottlerFunctionClass implements UserDefinedFunction {
+
+ public String eval(Object value, int slowValue, int slowSeconds) {
+ if (asLong(value) == slowValue) {
+ try {
+ Thread.sleep(slowSeconds * 1000L);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new RuntimeException("Interrupted while throttling a record.", e);
+ }
+ }
+ System.out.println("SkewedThrottlerFunctionClass finished " + value);
+ return "throttled_" + value;
+ }
+
+ private long asLong(Object value) {
+ if (value instanceof Number) {
+ return ((Number) value).longValue();
+ }
+ return Long.parseLong(String.valueOf(value));
+ }
+}
diff --git a/flink-cdc-runtime/src/main/java/org/apache/flink/cdc/runtime/operators/transform/async/AsyncPostTransformFunction.java b/flink-cdc-runtime/src/main/java/org/apache/flink/cdc/runtime/operators/transform/async/AsyncPostTransformFunction.java
new file mode 100644
index 00000000000..524ca68ea53
--- /dev/null
+++ b/flink-cdc-runtime/src/main/java/org/apache/flink/cdc/runtime/operators/transform/async/AsyncPostTransformFunction.java
@@ -0,0 +1,958 @@
+/*
+ * 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.transform.async;
+
+import org.apache.flink.api.common.functions.OpenContext;
+import org.apache.flink.api.common.state.ListState;
+import org.apache.flink.api.common.state.ListStateDescriptor;
+import org.apache.flink.api.java.tuple.Tuple2;
+import org.apache.flink.api.java.tuple.Tuple3;
+import org.apache.flink.cdc.common.configuration.Configuration;
+import org.apache.flink.cdc.common.converter.JavaObjectConverter;
+import org.apache.flink.cdc.common.data.RecordData;
+import org.apache.flink.cdc.common.data.binary.BinaryRecordData;
+import org.apache.flink.cdc.common.event.ChangeEvent;
+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.model.AiModelClient;
+import org.apache.flink.cdc.common.pipeline.DecimalPrecisionMode;
+import org.apache.flink.cdc.common.schema.Schema;
+import org.apache.flink.cdc.common.schema.Selectors;
+import org.apache.flink.cdc.common.udf.UserDefinedFunctionContext;
+import org.apache.flink.cdc.common.utils.Preconditions;
+import org.apache.flink.cdc.common.utils.SchemaUtils;
+import org.apache.flink.cdc.runtime.operators.transform.PostTransformChangeInfo;
+import org.apache.flink.cdc.runtime.operators.transform.PostTransformer;
+import org.apache.flink.cdc.runtime.operators.transform.ProjectionColumn;
+import org.apache.flink.cdc.runtime.operators.transform.TransformContext;
+import org.apache.flink.cdc.runtime.operators.transform.TransformExpressionCompiler;
+import org.apache.flink.cdc.runtime.operators.transform.TransformFilter;
+import org.apache.flink.cdc.runtime.operators.transform.TransformFilterProcessor;
+import org.apache.flink.cdc.runtime.operators.transform.TransformProjection;
+import org.apache.flink.cdc.runtime.operators.transform.TransformProjectionProcessor;
+import org.apache.flink.cdc.runtime.operators.transform.TransformRule;
+import org.apache.flink.cdc.runtime.operators.transform.UserDefinedFunctionDescriptor;
+import org.apache.flink.cdc.runtime.operators.transform.converter.PostTransformConverters;
+import org.apache.flink.cdc.runtime.operators.transform.exceptions.TransformException;
+import org.apache.flink.cdc.runtime.parser.TransformParser;
+import org.apache.flink.cdc.runtime.serializer.TableIdSerializer;
+import org.apache.flink.cdc.runtime.serializer.event.CreateTableEventSerializer;
+import org.apache.flink.cdc.runtime.serializer.schema.SchemaSerializer;
+import org.apache.flink.cdc.runtime.typeutils.BinaryInternalObjectConverter;
+import org.apache.flink.cdc.runtime.typeutils.BinaryRecordDataGenerator;
+import org.apache.flink.core.memory.DataInputViewStreamWrapper;
+import org.apache.flink.core.memory.DataOutputViewStreamWrapper;
+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.async.ResultFuture;
+import org.apache.flink.streaming.api.functions.async.RichAsyncFunction;
+import org.apache.flink.util.FlinkRuntimeException;
+
+import org.apache.flink.shaded.guava31.com.google.common.cache.CacheBuilder;
+import org.apache.flink.shaded.guava31.com.google.common.cache.CacheLoader;
+import org.apache.flink.shaded.guava31.com.google.common.cache.LoadingCache;
+import org.apache.flink.shaded.guava31.com.google.common.collect.HashBasedTable;
+import org.apache.flink.shaded.guava31.com.google.common.collect.Table;
+import org.apache.flink.shaded.guava31.com.google.common.util.concurrent.ThreadFactoryBuilder;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import javax.annotation.Nullable;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.DataInputStream;
+import java.io.DataOutputStream;
+import java.io.IOException;
+import java.io.Serializable;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Queue;
+import java.util.Set;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CompletionException;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ConcurrentLinkedQueue;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.TimeUnit;
+import java.util.stream.Collectors;
+
+import static org.apache.flink.cdc.common.utils.Preconditions.checkNotNull;
+
+/**
+ * An async post-transform function for ordered async execution.
+ *
+ *
{@link SchemaChangeEvent}s are handled as barriers. The function waits for all pending {@link
+ * DataChangeEvent} futures submitted before the schema change, applies the schema change, and only
+ * then allows following data changes to run against the updated schema.
+ */
+public class AsyncPostTransformFunction extends RichAsyncFunction
+ implements CheckpointedFunction, Serializable {
+
+ private static final long serialVersionUID = 1L;
+ private static final String TABLE_STATE_NAME = "async-post-transform-table-state";
+ private static final long EXECUTOR_SHUTDOWN_TIMEOUT_SECONDS = 30L;
+ private static final Logger LOG = LoggerFactory.getLogger(AsyncPostTransformFunction.class);
+
+ private static final int TABLE_STATE_VERSION = 2;
+
+ private final String timezone;
+ private final DecimalPrecisionMode decimalPrecisionMode;
+ private final List transformRules;
+ private final Map tableInfoMap;
+
+ // Tuple3 items are: function name, class path, and extra options.
+ private final List>> udfFunctions;
+
+ // Serializable AI model clients keyed by model name, e.g. myModel.
+ private final Map modelClients;
+
+ private transient List transformers;
+ private transient List udfDescriptors;
+ private transient List