This is an automated email from the ASF dual-hosted git repository.
yiguolei pushed a commit to branch branch-4.1
in repository https://gitbox.apache.org/repos/asf/doris.git
The following commit(s) were added to refs/heads/branch-4.1 by this push:
new 954bcd617a0 branch-4.1: [fix](streamingjob) Support precreated
streaming job targets (#67324)
954bcd617a0 is described below
commit 954bcd617a06e3563c83108200acf9f2bc798e42
Author: wudi <[email protected]>
AuthorDate: Mon Aug 31 19:20:22 2026 +0800
branch-4.1: [fix](streamingjob) Support precreated streaming job targets
(#67324)
### What problem does this PR solve?
Issue Number: None
Cherry-picked from: #66950
---
.../insert/streaming/StreamingInsertJob.java | 17 ++-
.../apache/doris/job/util/StreamingJobUtils.java | 40 ++++--
.../doris/job/util/StreamingJobUtilsTest.java | 48 ++++++++
...st_streaming_postgres_job_precreated_target.out | 4 +
...streaming_postgres_job_precreated_target.groovy | 134 +++++++++++++++++++++
5 files changed, 220 insertions(+), 23 deletions(-)
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/job/extensions/insert/streaming/StreamingInsertJob.java
b/fe/fe-core/src/main/java/org/apache/doris/job/extensions/insert/streaming/StreamingInsertJob.java
index 6b9c2234012..185aa720c4a 100644
---
a/fe/fe-core/src/main/java/org/apache/doris/job/extensions/insert/streaming/StreamingInsertJob.java
+++
b/fe/fe-core/src/main/java/org/apache/doris/job/extensions/insert/streaming/StreamingInsertJob.java
@@ -18,7 +18,6 @@
package org.apache.doris.job.extensions.insert.streaming;
import org.apache.doris.analysis.UserIdentity;
-import org.apache.doris.catalog.Database;
import org.apache.doris.catalog.Env;
import org.apache.doris.cloud.catalog.CloudEnv;
import org.apache.doris.cloud.proto.Cloud;
@@ -103,6 +102,7 @@ import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
+import java.util.Optional;
import java.util.TreeMap;
import java.util.concurrent.ConcurrentLinkedQueue;
import java.util.concurrent.atomic.AtomicLong;
@@ -301,18 +301,15 @@ public class StreamingInsertJob extends
AbstractJob<StreamingJobSchedulerTask, M
private List<String> createTableIfNotExists() throws Exception {
List<String> syncTbls = new ArrayList<>();
Map<String, String> effectiveSourceProperties =
buildConvertedSourceProperties(sourceProperties);
- // Key: source table name; Value: CreateTableCommand for the Doris
target table.
- // The two names differ when "table.<src>.target_table" is configured.
- LinkedHashMap<String, CreateTableCommand> createTblCmds =
+ // Key: source table name; Value: CREATE TABLE command, or empty if
the target already exists.
+ // The source and target table names differ when
"table.<src>.target_table" is configured.
+ LinkedHashMap<String, Optional<CreateTableCommand>> createTblCmds =
StreamingJobUtils.generateCreateTableCmds(targetDb,
dataSourceType, effectiveSourceProperties,
targetProperties);
- Database db =
Env.getCurrentEnv().getInternalCatalog().getDbNullable(targetDb);
- Preconditions.checkNotNull(db, "target database %s does not exist",
targetDb);
- for (Map.Entry<String, CreateTableCommand> entry :
createTblCmds.entrySet()) {
+ for (Map.Entry<String, Optional<CreateTableCommand>> entry :
createTblCmds.entrySet()) {
String srcTable = entry.getKey();
- CreateTableCommand createTblCmd = entry.getValue();
- if
(!db.isTableExist(createTblCmd.getCreateTableInfo().getTableName())) {
- createTblCmd.run(ConnectContext.get(), null);
+ if (entry.getValue().isPresent()) {
+ entry.getValue().get().run(ConnectContext.get(), null);
}
// Use the upstream table name so CDC monitors the correct source
table.
syncTbls.add(srcTable);
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/job/util/StreamingJobUtils.java
b/fe/fe-core/src/main/java/org/apache/doris/job/util/StreamingJobUtils.java
index 1ef6bf18093..0e2c6263eff 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/job/util/StreamingJobUtils.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/job/util/StreamingJobUtils.java
@@ -34,6 +34,7 @@ import org.apache.doris.common.util.SmallFileMgr.SmallFile;
import org.apache.doris.datasource.InternalCatalog;
import org.apache.doris.datasource.jdbc.client.JdbcClient;
import org.apache.doris.datasource.jdbc.client.JdbcClientConfig;
+import org.apache.doris.info.TableNameInfo;
import org.apache.doris.job.cdc.DataSourceConfigKeys;
import org.apache.doris.job.cdc.split.SnapshotSplit;
import org.apache.doris.job.common.DataSourceType;
@@ -358,15 +359,15 @@ public class StreamingJobUtils {
*
* <p>Returns a {@link LinkedHashMap} whose key is the <b>source</b>
(upstream) table name and
* whose value is the corresponding {@link CreateTableCommand} that
creates the Doris target
- * table (which may have a different name when {@code
table.<src>.target_table} is configured).
- * Callers must use the map key as the upstream source table identifier
for CDC monitoring and
- * the {@link CreateTableCommand} value for the actual DDL execution.
+ * table (which may have a different name when {@code
table.<src>.target_table} is configured),
+ * or empty when the target table already exists. Callers must use the map
key as the upstream
+ * source table identifier for CDC monitoring.
*/
- public static LinkedHashMap<String, CreateTableCommand>
generateCreateTableCmds(String targetDb,
+ public static LinkedHashMap<String, Optional<CreateTableCommand>>
generateCreateTableCmds(String targetDb,
DataSourceType sourceType,
Map<String, String> properties, Map<String, String>
targetProperties)
throws JobException {
- LinkedHashMap<String, CreateTableCommand> createtblCmds = new
LinkedHashMap<>();
+ LinkedHashMap<String, Optional<CreateTableCommand>> createtblCmds =
new LinkedHashMap<>();
String includeTables =
properties.get(DataSourceConfigKeys.INCLUDE_TABLES);
String excludeTables =
properties.get(DataSourceConfigKeys.EXCLUDE_TABLES);
List<String> includeTablesList = new ArrayList<>();
@@ -384,6 +385,8 @@ public class StreamingJobUtils {
if (tablesNameList.isEmpty()) {
throw new JobException("No tables found in database " + database);
}
+ Database targetDatabase =
Env.getCurrentEnv().getInternalCatalog().getDbNullable(targetDb);
+ Preconditions.checkNotNull(targetDatabase, "target database %s does
not exist", targetDb);
Map<String, String> tableCreateProperties =
getTableCreateProperties(targetProperties);
List<String> noPrimaryKeyTables = new ArrayList<>();
@@ -403,21 +406,33 @@ public class StreamingJobUtils {
}
List<String> primaryKeys = jdbcClient.getPrimaryKeys(database,
table);
- List<Column> columns = getColumns(jdbcClient, database, table,
primaryKeys);
if (primaryKeys.isEmpty()) {
noPrimaryKeyTables.add(table);
}
// Resolve target (Doris) table name; defaults to source table
name if not configured
- String targetTableName = properties.getOrDefault(
+ String targetTableName = new TableNameInfo(targetDb,
properties.getOrDefault(
DataSourceConfigKeys.TABLE + "." + table + "."
+ DataSourceConfigKeys.TABLE_TARGET_TABLE_SUFFIX,
- table).trim();
+ table).trim()).getTbl();
// Validate and apply exclude_columns for this table
Set<String> excludeColumns = parseExcludeColumns(properties,
table);
+ if (targetDatabase.isTableExist(targetTableName)) {
+ if (!excludeColumns.isEmpty()) {
+ Set<String> columnNames =
jdbcClient.getJdbcColumnsInfo(database, table).stream()
+ .map(field -> field.getColumnName())
+ .collect(Collectors.toSet());
+ validateExcludeColumns(excludeColumns, table, columnNames,
primaryKeys);
+ }
+ createtblCmds.put(table, Optional.empty());
+ continue;
+ }
+
+ List<Column> columns = getColumns(jdbcClient, database, table,
primaryKeys);
if (!excludeColumns.isEmpty()) {
- validateExcludeColumns(excludeColumns, table, columns,
primaryKeys);
+ Set<String> columnNames =
columns.stream().map(Column::getName).collect(Collectors.toSet());
+ validateExcludeColumns(excludeColumns, table, columnNames,
primaryKeys);
columns = columns.stream()
.filter(col -> !excludeColumns.contains(col.getName()))
.collect(Collectors.toList());
@@ -460,7 +475,7 @@ public class StreamingJobUtils {
);
CreateTableCommand createtblCmd = new
CreateTableCommand(Optional.empty(), createtblInfo);
// Key: source (PG/MySQL) table name; Value: command that creates
the Doris target table
- createtblCmds.put(table, createtblCmd);
+ createtblCmds.put(table, Optional.of(createtblCmd));
}
if (createtblCmds.isEmpty()) {
throw new JobException("Can not found match table in database " +
database);
@@ -619,10 +634,9 @@ public class StreamingJobUtils {
}
private static void validateExcludeColumns(Set<String> excludeColumns,
String tableName,
- List<Column> columns, List<String> primaryKeys) throws
JobException {
- Set<String> colNames =
columns.stream().map(Column::getName).collect(Collectors.toSet());
+ Set<String> columnNames, List<String> primaryKeys) throws
JobException {
for (String col : excludeColumns) {
- if (!colNames.contains(col)) {
+ if (!columnNames.contains(col)) {
throw new JobException(String.format(
"exclude_columns validation failed: column '%s' does
not exist in table '%s'",
col, tableName));
diff --git
a/fe/fe-core/src/test/java/org/apache/doris/job/util/StreamingJobUtilsTest.java
b/fe/fe-core/src/test/java/org/apache/doris/job/util/StreamingJobUtilsTest.java
index 93c19074cd1..5bf1855da2a 100644
---
a/fe/fe-core/src/test/java/org/apache/doris/job/util/StreamingJobUtilsTest.java
+++
b/fe/fe-core/src/test/java/org/apache/doris/job/util/StreamingJobUtilsTest.java
@@ -18,17 +18,24 @@
package org.apache.doris.job.util;
import org.apache.doris.catalog.Column;
+import org.apache.doris.catalog.Database;
+import org.apache.doris.catalog.Env;
+import org.apache.doris.catalog.KeysType;
+import org.apache.doris.catalog.OlapTable;
import org.apache.doris.catalog.PrimitiveType;
import org.apache.doris.catalog.ScalarType;
+import org.apache.doris.datasource.InternalCatalog;
import org.apache.doris.datasource.jdbc.client.JdbcClient;
import org.apache.doris.job.cdc.DataSourceConfigKeys;
import org.apache.doris.job.common.DataSourceType;
+import org.apache.doris.qe.GlobalVariable;
import org.junit.Assert;
import org.junit.Before;
import org.junit.Test;
import org.mockito.ArgumentMatchers;
import org.mockito.Mock;
+import org.mockito.MockedStatic;
import org.mockito.Mockito;
import org.mockito.MockitoAnnotations;
@@ -250,4 +257,45 @@ public class StreamingJobUtilsTest {
Assert.assertEquals("test_db",
StreamingJobUtils.getRemoteDbName(DataSourceType.OCEANBASE,
properties));
}
+
+ @Test
+ public void
testGenerateCreateTableCmdsFindsMixedCasePrecreatedTargetWhenStoredLowerCase()
throws Exception {
+ Map<String, String> properties = new HashMap<>();
+ properties.put(DataSourceConfigKeys.SCHEMA, "source_db");
+ properties.put(DataSourceConfigKeys.TABLE + ".source_table."
+ + DataSourceConfigKeys.TABLE_TARGET_TABLE_SUFFIX,
"MixedTarget");
+
+ Database targetDatabase = new Database(1L, "target_db");
+ targetDatabase.registerTable(new OlapTable(2L, "mixedtarget", new
ArrayList<>(), KeysType.UNIQUE_KEYS,
+ null, null));
+ Env env = Mockito.mock(Env.class);
+ InternalCatalog internalCatalog = Mockito.mock(InternalCatalog.class);
+ Mockito.when(env.getInternalCatalog()).thenReturn(internalCatalog);
+
Mockito.when(internalCatalog.getDbNullable("target_db")).thenReturn(targetDatabase);
+ Mockito.when(jdbcClient.getTablesNameList("source_db"))
+ .thenReturn(Arrays.asList("source_table"));
+ Mockito.when(jdbcClient.getPrimaryKeys("source_db", "source_table"))
+ .thenReturn(Arrays.asList("id"));
+ Mockito.when(jdbcClient.getColumnsFromJdbc("source_db",
"source_table"))
+ .thenReturn(Arrays.asList(
+ new Column("id",
ScalarType.createType(PrimitiveType.INT)),
+ new Column("unsupported_col", new
ScalarType(PrimitiveType.UNSUPPORTED))));
+
+ int originalLowerCaseTableNames = GlobalVariable.lowerCaseTableNames;
+ GlobalVariable.lowerCaseTableNames = 1;
+ try (MockedStatic<Env> mockedEnv = Mockito.mockStatic(Env.class);
+ MockedStatic<StreamingJobUtils> utils =
Mockito.mockStatic(StreamingJobUtils.class,
+ Mockito.CALLS_REAL_METHODS)) {
+ mockedEnv.when(Env::getCurrentEnv).thenReturn(env);
+ mockedEnv.when(Env::isStoredTableNamesLowerCase).thenReturn(true);
+ utils.when(() ->
StreamingJobUtils.getJdbcClient(DataSourceType.POSTGRES, properties))
+ .thenReturn(jdbcClient);
+
+ Assert.assertFalse(StreamingJobUtils.generateCreateTableCmds(
+ "target_db", DataSourceType.POSTGRES, properties, new
HashMap<>())
+ .get("source_table").isPresent());
+ } finally {
+ GlobalVariable.lowerCaseTableNames = originalLowerCaseTableNames;
+ }
+ }
}
diff --git
a/regression-test/data/job_p0/streaming_job/cdc/test_streaming_postgres_job_precreated_target.out
b/regression-test/data/job_p0/streaming_job/cdc/test_streaming_postgres_job_precreated_target.out
new file mode 100644
index 00000000000..45a44ef6913
--- /dev/null
+++
b/regression-test/data/job_p0/streaming_job/cdc/test_streaming_postgres_job_precreated_target.out
@@ -0,0 +1,4 @@
+-- This file is automatically generated. You should know what you did if you
want to edit this
+-- !select_precreated_target --
+1 ready
+
diff --git
a/regression-test/suites/job_p0/streaming_job/cdc/test_streaming_postgres_job_precreated_target.groovy
b/regression-test/suites/job_p0/streaming_job/cdc/test_streaming_postgres_job_precreated_target.groovy
new file mode 100644
index 00000000000..9e64974fc58
--- /dev/null
+++
b/regression-test/suites/job_p0/streaming_job/cdc/test_streaming_postgres_job_precreated_target.groovy
@@ -0,0 +1,134 @@
+// 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.
+
+import org.awaitility.Awaitility
+
+import static java.util.concurrent.TimeUnit.SECONDS
+
+suite("test_streaming_postgres_job_precreated_target",
+ "p0,external,pg,external_docker,external_docker_pg,nondatalake") {
+ def jobName = "test_streaming_postgres_job_precreated_target"
+ def currentDb = (sql "select database()")[0][0]
+ def pgDB = "postgres"
+ def pgSchema = "cdc_test"
+ def pgUser = "postgres"
+ def pgPassword = "123456"
+
+ sql """DROP JOB IF EXISTS where jobname = '${jobName}'"""
+ sql """DROP JOB IF EXISTS where jobname = '${jobName}_invalid_exclude'"""
+ sql """drop table if exists ${currentDb}.streaming_precreated_enum_target
force"""
+
+ String enabled = context.config.otherConfigs.get("enableJdbcTest")
+ if (enabled != null && enabled.equalsIgnoreCase("true")) {
+ String pgPort = context.config.otherConfigs.get("pg_14_port")
+ String externalEnvIp = context.config.otherConfigs.get("externalEnvIp")
+ String s3Endpoint = getS3Endpoint()
+ String bucket = getS3BucketName()
+ String driverUrl =
"https://${bucket}.${s3Endpoint}/regression/jdbc_driver/postgresql-42.5.0.jar"
+
+ connect("${pgUser}", "${pgPassword}",
"jdbc:postgresql://${externalEnvIp}:${pgPort}/${pgDB}") {
+ sql """DROP TABLE IF EXISTS
${pgDB}.${pgSchema}.streaming_precreated_enum_source"""
+ sql """DROP TYPE IF EXISTS
${pgSchema}.streaming_precreated_target_enum"""
+ sql """CREATE TYPE ${pgSchema}.streaming_precreated_target_enum AS
ENUM ('ready', 'done')"""
+ sql """
+ CREATE TABLE
${pgDB}.${pgSchema}.streaming_precreated_enum_source (
+ id INTEGER PRIMARY KEY,
+ searchable ${pgSchema}.streaming_precreated_target_enum
+ )
+ """
+ sql """
+ INSERT INTO
${pgDB}.${pgSchema}.streaming_precreated_enum_source
+ VALUES (1, 'ready')
+ """
+ }
+
+ sql """
+ CREATE TABLE ${currentDb}.streaming_precreated_enum_target (
+ id INT NOT NULL,
+ searchable STRING NULL
+ ) ENGINE=OLAP
+ UNIQUE KEY(id)
+ DISTRIBUTED BY HASH(id) BUCKETS AUTO
+ PROPERTIES ("replication_num" = "1")
+ """
+
+ sql """
+ CREATE JOB ${jobName}
+ ON STREAMING
+ FROM POSTGRES (
+ "jdbc_url" =
"jdbc:postgresql://${externalEnvIp}:${pgPort}/${pgDB}",
+ "driver_url" = "${driverUrl}",
+ "driver_class" = "org.postgresql.Driver",
+ "user" = "${pgUser}",
+ "password" = "${pgPassword}",
+ "database" = "${pgDB}",
+ "schema" = "${pgSchema}",
+ "include_tables" = "streaming_precreated_enum_source",
+ "offset" = "initial",
+ "table.streaming_precreated_enum_source.target_table" =
"streaming_precreated_enum_target"
+ )
+ TO DATABASE ${currentDb} (
+ "table.create.properties.replication_num" = "1"
+ )
+ """
+
+ try {
+ Awaitility.await().atMost(300, SECONDS).pollInterval(2,
SECONDS).until {
+ def rows = sql """
+ SELECT id, searchable
+ FROM ${currentDb}.streaming_precreated_enum_target
+ WHERE id = 1
+ """
+ rows.size() == 1 && rows[0][0].toString() == "1" && rows[0][1]
== "ready"
+ }
+ } catch (Exception ex) {
+ log.info("show job: " + (sql """select * from
jobs("type"="insert") where Name='${jobName}'"""))
+ log.info("show task: " + (sql """select * from
tasks("type"="insert") where JobName='${jobName}'"""))
+ throw ex
+ }
+
+ order_qt_select_precreated_target """
+ SELECT id, searchable FROM
${currentDb}.streaming_precreated_enum_target
+ """
+
+ sql """DROP JOB IF EXISTS where jobname = '${jobName}'"""
+
+ test {
+ sql """
+ CREATE JOB ${jobName}_invalid_exclude
+ ON STREAMING
+ FROM POSTGRES (
+ "jdbc_url" =
"jdbc:postgresql://${externalEnvIp}:${pgPort}/${pgDB}",
+ "driver_url" = "${driverUrl}",
+ "driver_class" = "org.postgresql.Driver",
+ "user" = "${pgUser}",
+ "password" = "${pgPassword}",
+ "database" = "${pgDB}",
+ "schema" = "${pgSchema}",
+ "include_tables" = "streaming_precreated_enum_source",
+ "offset" = "initial",
+ "table.streaming_precreated_enum_source.target_table" =
"streaming_precreated_enum_target",
+ "table.streaming_precreated_enum_source.exclude_columns" =
"missing_searchable"
+ )
+ TO DATABASE ${currentDb} (
+ "table.create.properties.replication_num" = "1"
+ )
+ """
+ exception "exclude_columns validation failed: column
'missing_searchable' does not exist"
+ }
+ }
+}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]