diff --git a/.github/workflows/mr-ci.yaml b/.github/workflows/mr-ci.yaml index b66c9e405..102d9cf15 100644 --- a/.github/workflows/mr-ci.yaml +++ b/.github/workflows/mr-ci.yaml @@ -44,7 +44,7 @@ jobs: TAG_NAME: ${{ steps.set-output.outputs.TAG_NAME }} steps: - name: Checkout Tapdata Opensource - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: repository: 'tapdata/tapdata' token: ${{ secrets.TAPDATA_ENT_CICD_TOKEN }} @@ -73,33 +73,12 @@ jobs: echo "::set-output name=TAG_NAME::${TAG_NAME}" Sync-Code-to-Office: - runs-on: ubuntu-latest - needs: - - Get-Stable-Branch - steps: - - name: Checkout Tapdata Connectors Code - uses: actions/checkout@v3 - with: - repository: 'tapdata/tapdata-connectors' - ref: ${{ needs.Get-Stable-Branch.outputs.CONNECTORS_BRANCH }} - token: ${{ secrets.TAPDATA_ENT_CICD_TOKEN }} - path: tapdata-connectors - fetch-depth: 0 - - name: Checkout Tapdata Application - uses: actions/checkout@v2 - with: - repository: 'tapdata/tapdata-application' - ref: main - token: ${{ secrets.TAPDATA_ENT_CICD_TOKEN }} - path: tapdata-application - fetch-depth: 0 - - name: Push Tapdata Connectors to Gogs - uses: nick-fields/retry@v2 - with: - timeout_minutes: 10 - max_attempts: 3 - command: | - bash tapdata-application/build/upgrade.sh --upgrade-code=true --upgrade-code-path=tapdata-connectors --gogs-project-name=tapdata-connectors + needs: Get-Stable-Branch + uses: tapdata/tapdata-application/.github/workflows/sync-code-to-office.yaml@main + secrets: inherit + with: + tapdata-connectors: ${{ needs.Get-Current-Branch.outputs.branch }} + gitee-token-user: "${{ vars.GITEE_TOKEN_USER }}" Scan-Connectors: runs-on: ubuntu-latest @@ -124,7 +103,7 @@ jobs: wait_workflow: true - name: Checkout Tapdata-Application if: ${{ always() && steps.sonar.outcome == 'failure' }} - uses: actions/checkout@v2 + uses: actions/checkout@v4 with: repository: 'tapdata/tapdata-application' ref: "main" diff --git a/README.md b/README.md index 311711c06..26478b64f 100644 --- a/README.md +++ b/README.md @@ -1,39 +1,209 @@ -# tapdata-connectors -Connectors For Tapdata +# TapData Connectors -## Register Connector Help +[![JDK](https://img.shields.io/badge/JDK-17+-green.svg)](https://openjdk.org/) +[![Maven](https://img.shields.io/badge/Maven-3.6+-blue.svg)](https://maven.apache.org/) -### Use **pdk** command +TapData Connectors is an open-source project for developing and building custom data connectors, designed for the [TapData data synchronization platform](https://github.com/tapdata/tapdata). Through this project, developers can easily build custom data connectors and register them with the TapData platform, enabling rapid integration of various heterogeneous data sources. -```shell - java -jar pdk.jar register -a ${access_code} -t ${tm_url} [-ak ${accessKey} [-sk ${secretKey}]] [-r ${oem_type}] [-f ${filter_type}] [-h] [-X] [-l] +## Why Choose TapData Connectors? + +- **Rich Connector Ecosystem**: Supports mainstream databases, cloud services, SaaS applications, and more +- **Rapid Development**: Based on the PDK framework, new connectors can be developed in just a few hours +- **Enterprise Features**: Supports incremental synchronization, resume from breakpoint, and more +- **Cross-Platform Support**: Compatible with Windows, Linux, macOS, and other mainstream operating systems +- **High Performance**: Optimized data transmission engine supporting real-time synchronization of large data volumes + +## 🚀 Quick Start + +```bash +# 1️⃣ Clone the project +git clone https://github.com/tapdata/tapdata-connectors.git && cd tapdata-connectors + +# 2️⃣ Build MySQL connector (example) +mvn clean install -pl connectors/mysql-connector -am -DskipTests + +# 3️⃣ Register with TapData platform +java -jar pdk-deploy.jar register \ + -a YOUR_ACCESS_CODE \ + -t http://localhost:3030 \ + connectors/mysql-connector/target/mysql-connector-*.jar +``` + +✅ **Done!** Now you can use this connector in the TapData platform. + +## Project Structure + +```bash +tapdata-connectors/ +├── connectors # Various Java connectors +├── connectors-common # Common dependencies and Debezium aggregation modules +├── connectors-javascript # JavaScript connectors and core +├── connectors-unpackage # Unpackaged special connectors +├── connectors-tdd # Development/test-driven demonstrations +├── file-storages # File storage connectors +├── tapdata-cli # CLI tools +``` + +## Development Environment Setup + +### System Requirements +- **JDK**: 17 or higher +- **Maven**: 3.6+ +- **Operating System**: Windows / Linux / macOS + +### Environment Configuration + +#### Linux +```bash +# Ubuntu/Debian +sudo apt-get install openjdk-17-jdk maven + +# CentOS/RHEL +sudo yum install java-17-openjdk-devel maven +``` + +#### macOS +```bash +# Auto-detect and set JDK 17 +export JAVA_HOME=$(/usr/libexec/java_home -v17 2>/dev/null || echo "/usr/lib/jvm/java-17-openjdk") +export PATH=$JAVA_HOME/bin:$PATH +java -version +``` + +#### Windows +1. Download and install [JDK 17](https://adoptium.net/) +2. Download and extract [Maven](https://maven.apache.org/download.cgi) +3. Configure environment variables `JAVA_HOME` and `MAVEN_HOME` + +## Build Connectors + +```bash +mvn clean install -DskipTests \ + -pl 'module-name' + -am +``` + +**Optional Parameters**: + +- `-DskipTests`: Skip tests, recommended during compilation to improve build speed +- `-pl`: Build only selected modules; to exclude certain modules, use exclusion syntax in `-pl`, e.g., `!module-name1,!module-name2` +- `-am`: Automatically build dependencies of selected modules + +After compilation, you can find the compiled connector JAR files in the corresponding module's `target/*.jar`. Then follow the instructions below to register them with the TapData platform. + + +## Register Connector to TapData Platform + +```bash +java -jar pdk-deploy.jar register -a ${access_code} -t ${tm_url} \ + [-ak ${accessKey} [-sk ${secretKey}]] [-r ${oem_type}] [-f ${filter_type}] [-l] [-h] [-X] \ + /path/to/your-connector.jar ``` -Arguments: - - -l (--latest): Whether replace the latest version - - -a (--auth): Provide auth token to register - - -ak (--accessKey): Provide auth accessKey when register connector to cloud - - -sk (--secretKey): Provide auth secretKey when register connector to cloud - - -t (--tm): Tapdata TM url, where you want register connector to - - -r (--replace): Replace Config file name, value is oem type - - -f (--filter): The list of the Authentication types should not be skipped. If value is empty, all connectors will register all connectors. if it contains multiple, please separate them with commas - - -h (--help): TapData cli help - -A. Tip: - -You must save pdk file in file system, if pdk file path is: /build/pdk -if the connector which will be register, path is /connectors/dist/demo-connector.jar +**Parameter Description**: -B. Full command such as: +- `-a` (`--auth`): The `access_code` in TapData. Log in to the TapData management platform, click your username in the upper right corner, select **Personal Settings** to view it +- `-t` (`--tm`): TapData management platform login address, e.g., `http://localhost:3030` +- `-f` (`--filter`): Register only connectors of specified authentication types, multiple values separated by commas +- `-l` (`--latest`): Replace with the latest version +- `-h` (`--help`): View command help information - (1) when you execute command in a java run environment(linux/windows/macOS) +The `pdk-deploy.jar` tool can be found in your TapData deployment environment's `tapdata/apps/lib` directory. -```shell -java -jar /build/pdk register -a ${access_code} -ak ${access-key} -sk ${secret-key} -r ${oem-type} -f ${need-register-connector-type} -t http://${tm-server} /connectors/dist/demo-connector.jar +**Execution example**: + +```bash +java -jar pdk-deploy.jar register \ + -a 3324***********8d4562f \ + -t http://127.0.0.1:3030 \ + connectors/starrocks-connector/target/starrocks-connector-1.0-SNAPSHOT.jar ``` -(2) when you execute command in simple environment(linux/macOS) +When the connector is successfully registered, it will prompt "Completed". Then you can log in to the TapData management page and use this connector when creating connections. + + +## FAQ + +Q: Getting protoc compilation errors when building on macOS? + +A: For Apple Silicon (macOS ARM), it's recommended to exclude `debezium-connector-postgres` and `debezium-connector-highgo` to avoid local binary compatibility issues (see module list in `connectors-common/debezium-bucket/pom.xml:71-81`). + +Build command example: + +```bash +mvn clean install -DskipTests \ + -pl '!connectors-common/debezium-bucket/debezium-connector-postgres,!connectors-common/debezium-bucket/debezium-connector-highgo' + -am +``` + +## How to Contribute + +We warmly welcome more contributors to help improve and expand connectors! **Every contribution matters**, no matter how big or small! + +### Contribution Methods + +| Contribution Type | Description | Difficulty | +|---------|------|------| +| **Report Bugs** | Found a bug? Feel free to submit an [Issue](https://github.com/tapdata/tapdata-connectors/issues) | ⭐ | +| **Improve Documentation** | Fix typos, add examples, optimize descriptions | ⭐ | +| **New Features** | Develop new connectors or enhance existing functionality | ⭐⭐⭐ | +| **Code Optimization** | Performance optimization, code refactoring | ⭐⭐ | +| **Test Cases** | Add test cases to improve code quality | ⭐⭐ | + +### PR Template Suggestions +- **Title**: Clear and concise, e.g., `Add MongoDB connector support` +- **Description**: Explain the background, scope of changes, and test results +- **Checklist**: + - [ ] Code passes local testing + - [ ] Documentation has been updated + - [ ] Follows code standards + + +## Contact Us + +Encountered issues? Want to connect? We provide multiple support channels! + +### Instant Communication + + + + + + +
+ +Slack + + + +Twitter + + + +GitHub Issues + +
+ +### Technical Community +

+ +WeChat Group + +

+ +## Reference Documentation + +- [TapData Community Edition Open Source Repository](https://github.com/tapdata/tapdata) +- [TapData Online Documentation](https://docs.tapdata.io/) + +## 🎉 Acknowledgments + +Thank you to all developers, users, and supporters who have contributed to TapData Connectors. It's with your support that this project continues to grow and thrive. + +If this project has been helpful to you, please give us a Star! + +**Contributors** -```shell -/build/pdk register -a ${access_code} -ak ${access-key} -sk ${secret-key} -r ${oem-type} -f ${need-register-connector-type} -t http://${tm-server} /connectors/dist/demo-connector.jar -``` \ No newline at end of file + + Contributors + \ No newline at end of file diff --git a/README.zh-CN.md b/README.zh-CN.md index 09ba55efa..2eb915ff5 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -1,40 +1,209 @@ -# tapdata-connectors +# TapData Connectors -Tapdata连接器 +[![JDK](https://img.shields.io/badge/JDK-17+-green.svg)](https://openjdk.org/) +[![Maven](https://img.shields.io/badge/Maven-3.6+-blue.svg)](https://maven.apache.org/) -## 如何注册数据源 +TapData Connectors 是用于开发和构建自定义数据连接器的开源项目,可用于 [TapData 数据同步平台](https://github.com/tapdata/tapdata)。通过本项目,开发者可以轻松构建自定义的数据连接器,并注册到 TapData 平台中,从而实现多种异构数据源的快速集成。 -### 使用 **pdk** 命令 +## 为什么选择 TapData Connectors? -```shell - java -jar pdk.jar register -a ${access_code} -t ${tm_url} [-ak ${accessKey} [-sk ${secretKey}]] [-r ${oem_type}] [-f ${filter_type}] [-h] [-X] [-l] +- **丰富的连接器生态**:支持主流数据库、云服务、SaaS 应用等 +- **快速开发**:基于 PDK 框架,几小时即可开发新连接器 +- **企业级特性**:支持增量同步、断点续传等 +- **跨平台支持**:支持 Windows、Linux、macOS 等主流操作系统 +- **高性能**:优化的数据传输引擎,支持大数据量实时同步 + +## 🚀 快速上手 + +```bash +# 1️⃣ 克隆项目 +git clone https://github.com/tapdata/tapdata-connectors.git && cd tapdata-connectors + +# 2️⃣ 构建 MySQL 连接器(示例) +mvn clean install -pl connectors/mysql-connector -am -DskipTests + +# 3️⃣ 注册到 TapData 平台 +java -jar pdk-deploy.jar register \ + -a YOUR_ACCESS_CODE \ + -t http://localhost:3030 \ + connectors/mysql-connector/target/mysql-connector-*.jar +``` + +✅ **完成!** 现在你可以在 TapData 平台中使用这个连接器了。 + +## 项目结构 + +```bash +tapdata-connectors/ +├── connectors # 各类 Java 连接器 +├── connectors-common # 通用依赖与 Debezium 聚合模块 +├── connectors-javascript # JavaScript 连接器与核心 +├── connectors-unpackage # 未打包的特殊连接器 +├── connectors-tdd # 开发/测试驱动演示 +├── file-storages # 文件存储连接器 +├── tapdata-cli # CLI 工具 +``` + +## 开发环境搭建 + +### 系统要求 +- **JDK**:17 或更高版本 +- **Maven**:3.6+ +- **操作系统**:Windows / Linux / macOS + +### 环境配置 + +#### Linux +```bash +# Ubuntu/Debian +sudo apt-get install openjdk-17-jdk maven + +# CentOS/RHEL +sudo yum install java-17-openjdk-devel maven +``` + +#### macOS +```bash +# 自动检测并设置 JDK 17 +export JAVA_HOME=$(/usr/libexec/java_home -v17 2>/dev/null || echo "/usr/lib/jvm/java-17-openjdk") +export PATH=$JAVA_HOME/bin:$PATH +java -version +``` + +#### Windows +1. 下载 [JDK 17](https://adoptium.net/) 并安装 +2. 下载 [Maven](https://maven.apache.org/download.cgi) 并解压 +3. 配置环境变量 `JAVA_HOME` 和 `MAVEN_HOME` + +## 编译连接器 + +```bash +mvn clean install -DskipTests \ + -pl 'module-name' + -am +``` + +**可选参数说明**: + +- `-DskipTests`:跳过测试,推荐在编译时使用,以提高编译速度。 +- `-pl`:仅构建选择的模块;如需排除某些模块,可在 `-pl` 中使用排除语法,例如 `!module-name1,!module-name2`。 +- `-am`:自动构建被选模块的依赖。 + +编译执行完毕后,可在对应模块的 `target/*.jar` 下找到编译后的连接器 Jar 文件。随后,可跟随下述介绍,将其注册到 TapData 平台。 + + +## 注册连接器到 TapData 平台 + +```bash +java -jar pdk-deploy.jar register -a ${access_code} -t ${tm_url} \ + [-ak ${accessKey} [-sk ${secretKey}]] [-r ${oem_type}] [-f ${filter_type}] [-l] [-h] [-X] \ + /path/to/your-connector.jar ``` -参数列表: - - -l (--latest): 是否需要覆盖最新版本 - - -a (--auth): 提供access_code - - -ak (--accessKey): 提供用户 accessKey 用于注册数据源到云版环境 - - -sk (--secretKey): 提供用户 secretKey 用于注册数据源到云版环境 - - -t (--tm): Tapdata TM URL, 这是您需要注册数据源的环境 - - -r (--replace): 如果需要向OEM环境注册数据源,请填写OEM类型参数 - - -f (--filter): 不应跳过数据源类型的列表。如果值为空,则所有连接器都将注册所有连接器。如果包含多个,请用逗号分隔 - - -h (--help): 命令帮助 - -A. Tip: - -您必须在文件系统中保存pdk文件, 如果pdk文件路径为:/build/pdk -如果要注册的连接器, 文件路径为 /connectors/dist/demo-connector.jar +**参数说明**: -B. 完整的命令如下: +- `-a`(`--auth`):TapData 中的 `access_code`(访问码),可登录至 TapData 管理平台,点击右上角用户名,选择**个人设置**,即可查看。 +- `-t`(`--tm`):TapData 管理平台登录地址,例如 `http://localhost:3030`。 +- `-f`(`--filter`):仅注册指定认证类型的连接器,多个值用逗号分隔。 +- `-l`(`--latest`):替换为最新版本。 +- `-h` (`--help`):查看命令帮助信息。 - (1) 当您在java运行环境(linux/windows/macOS)中执行命令时 +其中,`pdk-deploy.jar` 工具,可在您的 TapData 部署环境中的 `tapdata/apps/lib` 目录中找到。 -```shell -java -jar /build/pdk register -a ${access_code} -ak ${access-key} -sk ${secret-key} -r ${oem-type} -f ${need-register-connector-type} -t http://${tm-server} /connectors/dist/demo-connector.jar +**执行示例**: + +```bash +java -jar pdk-deploy.jar register \ + -a 3324***********8d4562f \ + -t http://127.0.0.1:3030 \ + connectors/starrocks-connector/target/starrocks-connector-1.0-SNAPSHOT.jar ``` -(2) 当您在简单环境(linux/macOS)中执行命令时 +连接器成功注册后会提示 “Completed”,随后,可登录至 TapData 管理页面,在创建连接时使用该连接器。 + + +## 常见问题 + +Q:在 macOS 平台上编译时,提示 protoc 编译错误? + +A:Apple Silicon(macOS ARM)建议排除 `debezium-connector-postgres` 与 `debezium-connector-highgo`,以避免本地二进制兼容性问题(见 `connectors-common/debezium-bucket/pom.xml:71-81` 的模块列表)。 + +编译命令示例: + +```bash +mvn clean install -DskipTests \ + -pl '!connectors-common/debezium-bucket/debezium-connector-postgres,!connectors-common/debezium-bucket/debezium-connector-highgo' + -am +``` + +## 如何贡献 + +我们非常欢迎更多贡献者来帮助改进与扩展连接器!**每一个贡献都很重要**,无论大小! + +### 贡献方式 + +| 贡献类型 | 说明 | 难度 | +|---------|------|------| +| **报告 Bug** | 发现 Bug?欢迎提交 [Issue](https://github.com/tapdata/tapdata-connectors/issues) | ⭐ | +| **改进文档** | 修复错别字、补充示例、优化说明 | ⭐ | +| **新功能** | 开发新连接器或增强现有功能 | ⭐⭐⭐ | +| **代码优化** | 性能优化、代码重构 | ⭐⭐ | +| **测试用例** | 补充测试用例,提高代码质量 | ⭐⭐ | + +### PR 模板建议 +- **标题**:简洁明了,如 `Add MongoDB connector support` +- **描述**:说明变更背景、影响范围、测试结果 +- **检查清单**: + - [ ] 代码通过本地测试 + - [ ] 文档已更新 + - [ ] 遵循代码规范 + + +## 联系我们 + +遇到问题?想要交流?我们提供多种支持渠道! + +### 即时交流 + + + + + + +
+ +Slack + + + +Twitter + + + +GitHub Issues + +
+ +### 技术交流群 +

+ +微信群 + +

+ +## 参考文档 + +- [TapData 社区版开源仓库](https://github.com/tapdata/tapdata) +- [TapData 在线文档](https://docs.tapdata.net/) + +## 🎉 致谢 + +感谢所有为 TapData Connectors 做出贡献的开发者、用户和支持者。正是有了你们的支持,这个项目才能不断发展壮大。 + +如果这个项目对你有帮助,欢迎 Star 支持我们! + +**Contributors** -```shell -/build/pdk register -a ${access_code} -ak ${access-key} -sk ${secret-key} -r ${oem-type} -f ${need-register-connector-type} -t http://${tm-server} /connectors/dist/demo-connector.jar -``` \ No newline at end of file + + 贡献者 + diff --git a/connectors-common/connector-core/src/main/java/io/tapdata/kit/DbKit.java b/connectors-common/connector-core/src/main/java/io/tapdata/kit/DbKit.java index 1745f4826..3b3fbc5ee 100644 --- a/connectors-common/connector-core/src/main/java/io/tapdata/kit/DbKit.java +++ b/connectors-common/connector-core/src/main/java/io/tapdata/kit/DbKit.java @@ -96,6 +96,16 @@ public static List getColumnTypesFromResultSet(ResultSet resultSet) thro return columnTypeNames; } + public static List getColumnTypeNumbersFromResultSet(ResultSet resultSet) throws SQLException { + //get all column typeNames + List columnTypeNumbers = new ArrayList<>(); + ResultSetMetaData resultSetMetaData = resultSet.getMetaData(); + for (int i = 1; i <= resultSetMetaData.getColumnCount(); i++) { + columnTypeNumbers.add(resultSetMetaData.getColumnType(i)); + } + return columnTypeNumbers; + } + public static List getDataArrayByColumnName(ResultSet resultSet, String columnName) throws SQLException { List list = TapSimplify.list(); while (resultSet.next()) { diff --git a/connectors-common/connector-core/src/main/java/io/tapdata/kit/StringKit.java b/connectors-common/connector-core/src/main/java/io/tapdata/kit/StringKit.java index 8d02d4054..44f831892 100644 --- a/connectors-common/connector-core/src/main/java/io/tapdata/kit/StringKit.java +++ b/connectors-common/connector-core/src/main/java/io/tapdata/kit/StringKit.java @@ -386,4 +386,8 @@ public static String escapeRegex(String input) { Matcher matcher = REGEX_SPECIAL_CHARS.matcher(input); return matcher.replaceAll("\\\\$0"); } + + public static String removeSqlNote(String sql) { + return sql.replaceAll("(?s)/\\*.*?\\*/|--.*?\n", "").trim(); + } } diff --git a/connectors-common/connector-core/src/main/java/io/tapdata/util/NetUtil.java b/connectors-common/connector-core/src/main/java/io/tapdata/util/NetUtil.java index 4ce6e795b..443f3c661 100644 --- a/connectors-common/connector-core/src/main/java/io/tapdata/util/NetUtil.java +++ b/connectors-common/connector-core/src/main/java/io/tapdata/util/NetUtil.java @@ -3,13 +3,8 @@ import io.tapdata.kit.EmptyKit; import java.io.IOException; -import java.net.InetAddress; -import java.net.InetSocketAddress; -import java.net.Socket; -import java.net.SocketAddress; -import java.net.UnixDomainSocketAddress; -import java.nio.channels.SocketChannel; -import java.nio.file.Path; +import java.net.*; +import java.util.Enumeration; /** * @author samuel @@ -34,14 +29,10 @@ public static void validateHostPortWithSocket(String host, int port, int timeout if (port < 0 || port >= 65536) throw new IllegalArgumentException("Port must greater than 0 and smaller then 65536"); timeoutMs = timeoutMs <= 1000 ? DEFAULT_SOCKET_TIMEOUT_MS : timeoutMs; - try { - Socket s = new Socket(); - if(port == 0) { - return; - } else { + try (Socket s = new Socket()) { + if (port > 0) { s.connect(new InetSocketAddress(host, port), timeoutMs); } - s.close(); } catch (IOException e) { throw new IOException("Unable connect to " + host + ":" + port + ", reason: " + e.getMessage(), e); } @@ -50,4 +41,27 @@ public static void validateHostPortWithSocket(String host, int port, int timeout public static void validateHostPortWithSocket(String host, int port) throws IOException, IllegalArgumentException { validateHostPortWithSocket(host, port, DEFAULT_SOCKET_TIMEOUT_MS); } + + public static String getLocalIP() { + try { + Enumeration interfaces = NetworkInterface.getNetworkInterfaces(); + while (interfaces.hasMoreElements()) { + NetworkInterface ni = interfaces.nextElement(); + // 跳过回环接口、未启用或虚拟网卡 + if (ni.isLoopback() || !ni.isUp() || ni.isVirtual()) continue; + + Enumeration addresses = ni.getInetAddresses(); + while (addresses.hasMoreElements()) { + InetAddress addr = addresses.nextElement(); + // 只取 IPv4 地址,跳过 IPv6 + if (addr instanceof Inet4Address && !addr.isLoopbackAddress()) { + return addr.getHostAddress(); + } + } + } + return InetAddress.getLocalHost().getHostAddress(); + } catch (Exception e) { + return "127.0.0.1"; + } + } } diff --git a/connectors-common/connector-core/src/main/java/io/tapdata/write/FileLogger.java b/connectors-common/connector-core/src/main/java/io/tapdata/write/FileLogger.java new file mode 100644 index 000000000..18e34388d --- /dev/null +++ b/connectors-common/connector-core/src/main/java/io/tapdata/write/FileLogger.java @@ -0,0 +1,748 @@ +package io.tapdata.write; + +import io.tapdata.entity.logger.TapLogger; + +import java.io.*; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.StandardOpenOption; +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.concurrent.*; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicLong; +import java.util.stream.Stream; +import java.util.zip.GZIPOutputStream; + +/** + * High-performance file logger for string messages with multi-threaded write support. + * Uses a lock-free queue and dedicated writer thread for optimal performance. + * Automatically adds timestamps to each log line. + * + *

Example usage: + *

+ * // Initialize in connector's onStart method
+ * FileLogger logger = FileLogger.builder()
+ *     .logDirectory("logs/connector")
+ *     .logFilePrefix("my-connector")
+ *     .build();
+ *
+ * // Write logs from any thread
+ * logger.write("Processing record: " + recordId);
+ * logger.write("Batch completed, count: " + count);
+ *
+ * // Close in connector's onStop method
+ * logger.close();
+ * 
+ * + * @author TapData + */ +public class FileLogger implements AutoCloseable { + private static final String TAG = FileLogger.class.getSimpleName(); + + // Configuration constants + private static final int DEFAULT_QUEUE_CAPACITY = 100000; + private static final int DEFAULT_BATCH_SIZE = 1000; + private static final int DEFAULT_FLUSH_INTERVAL_MS = 1000; + private static final int DEFAULT_MAX_FILE_SIZE_MB = 100; + private static final String LOG_FILE_EXTENSION = ".log"; + private static final String COMPRESSED_EXTENSION = ".gz"; + private static final String DATE_PATTERN = "yyyy-MM-dd HH:mm:ss.SSS"; + private static final long DEFAULT_COMPRESS_INTERVAL_MS = 24 * 60 * 60 * 1000L; // 24 hours + private static final int DEFAULT_RETAIN_DAYS = 7; // Keep compressed files for 7 days + + // Instance fields + private final String logDirectory; + private final String logFilePrefix; + private final int queueCapacity; + private final int batchSize; + private final int flushIntervalMs; + private final long maxFileSizeBytes; + private final boolean autoTimestamp; + private final boolean enableCompression; + private final long compressIntervalMs; + private final int retainDays; + + // Runtime state + private final BlockingQueue logQueue; + private final AtomicBoolean running; + private final AtomicBoolean closed; + private final AtomicLong totalLinesLogged; + private final AtomicLong totalLinesDropped; + private final AtomicLong totalFilesCompressed; + private final Thread writerThread; + private final ScheduledExecutorService compressExecutor; + private final ThreadLocal dateFormat; + + // File handling + private volatile BufferedWriter currentWriter; + private volatile Path currentLogFile; + private volatile long currentFileSize; + private volatile int fileSequence; + + /** + * Log entry wrapper for queue + */ + private static class LogEntry { + final String message; + final long timestamp; + + LogEntry(String message) { + this.message = message; + this.timestamp = System.currentTimeMillis(); + } + } + + /** + * Builder for FileLogger + */ + public static class Builder { + private String logDirectory = "logs"; + private String logFilePrefix = "app"; + private int queueCapacity = DEFAULT_QUEUE_CAPACITY; + private int batchSize = DEFAULT_BATCH_SIZE; + private int flushIntervalMs = DEFAULT_FLUSH_INTERVAL_MS; + private int maxFileSizeMB = DEFAULT_MAX_FILE_SIZE_MB; + private boolean autoTimestamp = true; + private boolean enableCompression = false; + private long compressIntervalMs = DEFAULT_COMPRESS_INTERVAL_MS; + private int retainDays = DEFAULT_RETAIN_DAYS; + + public Builder logDirectory(String logDirectory) { + this.logDirectory = logDirectory; + return this; + } + + public Builder logFilePrefix(String logFilePrefix) { + this.logFilePrefix = logFilePrefix; + return this; + } + + public Builder queueCapacity(int queueCapacity) { + this.queueCapacity = queueCapacity; + return this; + } + + public Builder batchSize(int batchSize) { + this.batchSize = batchSize; + return this; + } + + public Builder flushIntervalMs(int flushIntervalMs) { + this.flushIntervalMs = flushIntervalMs; + return this; + } + + public Builder maxFileSizeMB(int maxFileSizeMB) { + this.maxFileSizeMB = maxFileSizeMB; + return this; + } + + public Builder autoTimestamp(boolean autoTimestamp) { + this.autoTimestamp = autoTimestamp; + return this; + } + + /** + * Enable automatic compression of old log files + * @param enable true to enable compression + * @return this builder + */ + public Builder enableCompression(boolean enable) { + this.enableCompression = enable; + return this; + } + + /** + * Set compression interval in milliseconds + * @param intervalMs interval in milliseconds (default: 24 hours) + * @return this builder + */ + public Builder compressIntervalMs(long intervalMs) { + this.compressIntervalMs = intervalMs; + return this; + } + + /** + * Set compression interval in hours + * @param hours interval in hours + * @return this builder + */ + public Builder compressIntervalHours(int hours) { + this.compressIntervalMs = hours * 60 * 60 * 1000L; + return this; + } + + /** + * Set how many days to retain compressed files + * @param days number of days to retain (default: 7) + * @return this builder + */ + public Builder retainDays(int days) { + this.retainDays = days; + return this; + } + + public FileLogger build() throws IOException { + return new FileLogger(this); + } + } + + /** + * Create a new builder + */ + public static Builder builder() { + return new Builder(); + } + + /** + * Private constructor - use Builder + */ + private FileLogger(Builder builder) throws IOException { + this.logDirectory = builder.logDirectory; + this.logFilePrefix = builder.logFilePrefix; + this.queueCapacity = builder.queueCapacity; + this.batchSize = builder.batchSize; + this.flushIntervalMs = builder.flushIntervalMs; + this.maxFileSizeBytes = builder.maxFileSizeMB * 1024L * 1024L; + this.autoTimestamp = builder.autoTimestamp; + this.enableCompression = builder.enableCompression; + this.compressIntervalMs = builder.compressIntervalMs; + this.retainDays = builder.retainDays; + + this.logQueue = new LinkedBlockingQueue<>(queueCapacity); + this.running = new AtomicBoolean(false); + this.closed = new AtomicBoolean(false); + this.totalLinesLogged = new AtomicLong(0); + this.totalLinesDropped = new AtomicLong(0); + this.totalFilesCompressed = new AtomicLong(0); + this.fileSequence = 0; + + this.dateFormat = ThreadLocal.withInitial(() -> new SimpleDateFormat(DATE_PATTERN)); + + // Create log directory + Path logDir = Paths.get(logDirectory); + if (!Files.exists(logDir)) { + Files.createDirectories(logDir); + } + + // Initialize first log file + rotateLogFile(); + + // Start writer thread + this.writerThread = new Thread(this::writerLoop, "FileLogger-Writer-" + logFilePrefix); + this.writerThread.setDaemon(true); + this.running.set(true); + this.writerThread.start(); + + // Start compression scheduler if enabled + if (enableCompression) { + this.compressExecutor = Executors.newSingleThreadScheduledExecutor(r -> { + Thread t = new Thread(r, "FileLogger-Compressor-" + logFilePrefix); + t.setDaemon(true); + return t; + }); + this.compressExecutor.scheduleAtFixedRate( + this::compressOldLogs, + compressIntervalMs, + compressIntervalMs, + TimeUnit.MILLISECONDS + ); + TapLogger.info(TAG, "FileLogger compression enabled: interval={}ms, retainDays={}", + compressIntervalMs, retainDays); + } else { + this.compressExecutor = null; + } + + TapLogger.info(TAG, "FileLogger initialized: directory={}, prefix={}, queueCapacity={}, batchSize={}, flushIntervalMs={}, maxFileSizeMB={}, compression={}", + logDirectory, logFilePrefix, queueCapacity, batchSize, flushIntervalMs, builder.maxFileSizeMB, enableCompression); + } + + /** + * Write a log message (blocking until queued, guaranteed no data loss) + * + * @param message the message to log + * @return true if message was queued, false if logger is closed or message is null + */ + public boolean write(String message) { + if (closed.get()) { + return false; + } + + if (message == null) { + return false; + } + + try { + // Use put() instead of offer() to block until space is available + // This guarantees no data loss + logQueue.put(new LogEntry(message)); + return true; + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + TapLogger.warn(TAG, "Interrupted while writing log message"); + return false; + } + } + + /** + * Write a log message (blocking with timeout, guaranteed no data loss unless timeout) + * + * @param message the message to log + * @param timeoutMs timeout in milliseconds (0 means wait forever) + * @return true if message was queued, false if timeout, logger is closed, or message is null + */ + public boolean write(String message, long timeoutMs) { + if (closed.get()) { + return false; + } + + if (message == null) { + return false; + } + + try { + if (timeoutMs <= 0) { + // Wait forever - guaranteed no data loss + logQueue.put(new LogEntry(message)); + return true; + } else { + // Wait with timeout + boolean offered = logQueue.offer(new LogEntry(message), timeoutMs, TimeUnit.MILLISECONDS); + if (!offered) { + totalLinesDropped.incrementAndGet(); + TapLogger.warn(TAG, "Failed to write log message within {}ms, message dropped", timeoutMs); + } + return offered; + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + TapLogger.warn(TAG, "Interrupted while writing log message"); + return false; + } + } + + /** + * Write a formatted log message (blocking until queued, guaranteed no data loss) + * + * @param format format string + * @param args arguments + * @return true if message was queued, false if logger is closed or format error + */ + public boolean writef(String format, Object... args) { + try { + String message = String.format(format, args); + return write(message); + } catch (Exception e) { + TapLogger.warn(TAG, "Error formatting log message: {}", e.getMessage()); + return false; + } + } + + /** + * Writer thread main loop + */ + private void writerLoop() { + TapLogger.info(TAG, "FileLogger writer thread started"); + + long lastFlushTime = System.currentTimeMillis(); + int batchCount = 0; + + try { + while (running.get() || !logQueue.isEmpty()) { + try { + // Poll with timeout to allow periodic flushing + LogEntry entry = logQueue.poll(flushIntervalMs, TimeUnit.MILLISECONDS); + + if (entry != null) { + writeLogEntry(entry); + batchCount++; + totalLinesLogged.incrementAndGet(); + } + + // Flush conditions: batch size reached or flush interval elapsed + long now = System.currentTimeMillis(); + boolean shouldFlush = batchCount >= batchSize || + (now - lastFlushTime >= flushIntervalMs && batchCount > 0); + + if (shouldFlush) { + flush(); + lastFlushTime = now; + batchCount = 0; + } + + // Check if file rotation is needed + if (currentFileSize >= maxFileSizeBytes) { + rotateLogFile(); + } + + } catch (InterruptedException e) { + if (!running.get()) { + break; + } + Thread.currentThread().interrupt(); + } catch (Exception e) { + TapLogger.error(TAG, "Error in writer loop: {}", e.getMessage(), e); + } + } + + // Final flush + flush(); + + } catch (Exception e) { + TapLogger.error(TAG, "Fatal error in writer thread: {}", e.getMessage(), e); + } finally { + TapLogger.info(TAG, "FileLogger writer thread stopped. Total lines logged: {}, dropped: {}", + totalLinesLogged.get(), totalLinesDropped.get()); + } + } + + /** + * Write a single log entry to file + */ + private void writeLogEntry(LogEntry entry) throws IOException { + if (currentWriter == null) { + return; + } + + StringBuilder line = new StringBuilder(); + + // Add timestamp if enabled + if (autoTimestamp) { + String timestamp = dateFormat.get().format(new Date(entry.timestamp)); + line.append(timestamp).append(" | "); + } + + // Add message + line.append(entry.message); + + // Write line + String lineStr = line.toString(); + currentWriter.write(lineStr); + currentWriter.newLine(); + + // Update file size (approximate) + currentFileSize += lineStr.getBytes(StandardCharsets.UTF_8).length + 1; // +1 for newline + } + + /** + * Flush buffered data to disk + */ + private void flush() { + try { + if (currentWriter != null) { + currentWriter.flush(); + } + } catch (IOException e) { + TapLogger.error(TAG, "Error flushing log file: {}", e.getMessage(), e); + } + } + + /** + * Force flush all buffered data to disk + */ + public void forceFlush() { + flush(); + } + + /** + * Compress old log files and delete expired compressed files + * This method is called periodically by the compression scheduler + */ + private void compressOldLogs() { + compressOldLogs(false); + } + + /** + * Compress old log files and delete expired compressed files + * @param forceAll if true, compress all non-current log files regardless of age + */ + private void compressOldLogs(boolean forceAll) { + try { + Path logDir = Paths.get(logDirectory); + if (!Files.exists(logDir)) { + return; + } + + long now = System.currentTimeMillis(); + long compressThreshold = now - compressIntervalMs; + long deleteThreshold = now - (retainDays * 24L * 60L * 60L * 1000L); + + try (Stream files = Files.list(logDir)) { + files.filter(path -> { + String fileName = path.getFileName().toString(); + // Only process log files with our prefix + return fileName.startsWith(logFilePrefix) && + fileName.endsWith(LOG_FILE_EXTENSION) && + !path.equals(currentLogFile); // Don't compress current file + }) + .forEach(logFile -> { + try { + long lastModified = Files.getLastModifiedTime(logFile).toMillis(); + + // Compress old log files + if (forceAll || lastModified < compressThreshold) { + compressLogFile(logFile); + } + } catch (Exception e) { + TapLogger.error(TAG, "Error processing log file {}: {}", + logFile.getFileName(), e.getMessage(), e); + } + }); + } + + // Delete expired compressed files + try (Stream files = Files.list(logDir)) { + files.filter(path -> { + String fileName = path.getFileName().toString(); + return fileName.startsWith(logFilePrefix) && + fileName.endsWith(COMPRESSED_EXTENSION); + }) + .forEach(compressedFile -> { + try { + long lastModified = Files.getLastModifiedTime(compressedFile).toMillis(); + + if (lastModified < deleteThreshold) { + Files.delete(compressedFile); + TapLogger.info(TAG, "Deleted expired compressed file: {}", + compressedFile.getFileName()); + } + } catch (Exception e) { + TapLogger.error(TAG, "Error deleting compressed file {}: {}", + compressedFile.getFileName(), e.getMessage(), e); + } + }); + } + + } catch (Exception e) { + TapLogger.error(TAG, "Error in compressOldLogs: {}", e.getMessage(), e); + } + } + + /** + * Compress a single log file using GZIP + */ + private void compressLogFile(Path logFile) { + Path compressedFile = Paths.get(logFile.toString() + COMPRESSED_EXTENSION); + + try { + // Skip if already compressed + if (Files.exists(compressedFile)) { + TapLogger.debug(TAG, "Compressed file already exists: {}", compressedFile.getFileName()); + return; + } + + long originalSize = Files.size(logFile); + + // Compress the file + try (InputStream in = Files.newInputStream(logFile); + OutputStream out = Files.newOutputStream(compressedFile); + GZIPOutputStream gzipOut = new GZIPOutputStream(out)) { + + byte[] buffer = new byte[8192]; + int len; + while ((len = in.read(buffer)) > 0) { + gzipOut.write(buffer, 0, len); + } + } + + long compressedSize = Files.size(compressedFile); + double ratio = (1.0 - (double) compressedSize / originalSize) * 100; + + // Delete original file after successful compression + Files.delete(logFile); + + totalFilesCompressed.incrementAndGet(); + + TapLogger.info(TAG, "Compressed log file: {} -> {} (saved {:.1f}%, {} -> {} bytes)", + logFile.getFileName(), compressedFile.getFileName(), + String.format("%.1f", ratio), originalSize, compressedSize); + + } catch (Exception e) { + TapLogger.error(TAG, "Error compressing log file {}: {}", + logFile.getFileName(), e.getMessage(), e); + + // Clean up partial compressed file on error + try { + if (Files.exists(compressedFile)) { + Files.delete(compressedFile); + } + } catch (IOException cleanupError) { + TapLogger.error(TAG, "Error cleaning up partial compressed file: {}", + cleanupError.getMessage()); + } + } + } + + /** + * Manually trigger compression of old log files + * This can be called by users to compress logs on demand + * Only compresses files older than the compression interval + */ + public void compressNow() { + compressNow(false); + } + + /** + * Manually trigger compression of log files + * @param forceAll if true, compress all non-current log files regardless of age + */ + public void compressNow(boolean forceAll) { + if (!enableCompression) { + TapLogger.warn(TAG, "Compression is not enabled"); + return; + } + + TapLogger.info(TAG, "Manual compression triggered (forceAll={})", forceAll); + compressOldLogs(forceAll); + } + + /** + * Rotate to a new log file + */ + private void rotateLogFile() throws IOException { + // Close current writer + if (currentWriter != null) { + try { + currentWriter.flush(); + currentWriter.close(); + } catch (IOException e) { + TapLogger.error(TAG, "Error closing current log file: {}", e.getMessage(), e); + } + } + + // Generate new file name + SimpleDateFormat fileNameFormat = new SimpleDateFormat("yyyyMMdd-HHmmss"); + String timestamp = fileNameFormat.format(new Date()); + String fileName = String.format("%s-%s-%04d%s", logFilePrefix, timestamp, fileSequence++, LOG_FILE_EXTENSION); + + currentLogFile = Paths.get(logDirectory, fileName); + currentFileSize = 0; + + // Create new writer + currentWriter = Files.newBufferedWriter( + currentLogFile, + StandardCharsets.UTF_8, + StandardOpenOption.CREATE, + StandardOpenOption.APPEND + ); + + TapLogger.info(TAG, "Rotated to new log file: {}", currentLogFile); + } + + /** + * Get current statistics + */ + public LoggerStats getStats() { + return new LoggerStats( + totalLinesLogged.get(), + totalLinesDropped.get(), + totalFilesCompressed.get(), + logQueue.size(), + currentFileSize, + currentLogFile != null ? currentLogFile.toString() : null + ); + } + + /** + * Statistics holder + */ + public static class LoggerStats { + private final long totalLinesLogged; + private final long totalLinesDropped; + private final long totalFilesCompressed; + private final int queueSize; + private final long currentFileSize; + private final String currentLogFile; + + public LoggerStats(long totalLinesLogged, long totalLinesDropped, long totalFilesCompressed, + int queueSize, long currentFileSize, String currentLogFile) { + this.totalLinesLogged = totalLinesLogged; + this.totalLinesDropped = totalLinesDropped; + this.totalFilesCompressed = totalFilesCompressed; + this.queueSize = queueSize; + this.currentFileSize = currentFileSize; + this.currentLogFile = currentLogFile; + } + + public long getTotalLinesLogged() { + return totalLinesLogged; + } + + public long getTotalLinesDropped() { + return totalLinesDropped; + } + + public long getTotalFilesCompressed() { + return totalFilesCompressed; + } + + public int getQueueSize() { + return queueSize; + } + + public long getCurrentFileSize() { + return currentFileSize; + } + + public String getCurrentLogFile() { + return currentLogFile; + } + + @Override + public String toString() { + return String.format("LoggerStats{logged=%d, dropped=%d, compressed=%d, queueSize=%d, fileSize=%d, file=%s}", + totalLinesLogged, totalLinesDropped, totalFilesCompressed, queueSize, currentFileSize, currentLogFile); + } + } + + /** + * Close the logger and wait for all pending logs to be written + */ + @Override + public void close() { + if (closed.compareAndSet(false, true)) { + TapLogger.info(TAG, "Closing FileLogger..."); + + // Stop the compression executor if enabled + if (compressExecutor != null) { + try { + compressExecutor.shutdown(); + if (!compressExecutor.awaitTermination(5, TimeUnit.SECONDS)) { + compressExecutor.shutdownNow(); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + compressExecutor.shutdownNow(); + TapLogger.warn(TAG, "Interrupted while waiting for compression executor to finish"); + } + } + + // Stop the writer thread + running.set(false); + + // Wait for writer thread to finish + try { + writerThread.join(10000); // Wait up to 10 seconds + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + TapLogger.warn(TAG, "Interrupted while waiting for writer thread to finish"); + } + + // Close the current writer + if (currentWriter != null) { + try { + currentWriter.flush(); + currentWriter.close(); + } catch (IOException e) { + TapLogger.error(TAG, "Error closing log file: {}", e.getMessage(), e); + } + } + + LoggerStats stats = getStats(); + TapLogger.info(TAG, "FileLogger closed. Final stats: {}", stats); + } + } +} diff --git a/connectors-common/connector-core/src/test/java/io/tapdata/base/StringKitTest.java b/connectors-common/connector-core/src/test/java/io/tapdata/base/StringKitTest.java index 6bddbbed1..c716fadf8 100644 --- a/connectors-common/connector-core/src/test/java/io/tapdata/base/StringKitTest.java +++ b/connectors-common/connector-core/src/test/java/io/tapdata/base/StringKitTest.java @@ -16,4 +16,14 @@ void testRemoveParentheses() { result = StringKit.removeParentheses("TIMESTAMP(6) WITH TIME ZONE"); assertEquals("TIMESTAMP WITH TIME ZONE", result); } + + @Test + void testRemoveSqlNote() { + String result = StringKit.removeSqlNote("-----注释\n" + + "alter table\n" + + " ----加注释\n" + + " xxx add /*又来注释*/ aaa int;\n"); + assertEquals("alter table\n" + + " xxx add aaa int;", result); + } } diff --git a/connectors-common/connector-core/src/test/java/io/tapdata/write/FileLoggerCompressionTest.java b/connectors-common/connector-core/src/test/java/io/tapdata/write/FileLoggerCompressionTest.java new file mode 100644 index 000000000..e614abb83 --- /dev/null +++ b/connectors-common/connector-core/src/test/java/io/tapdata/write/FileLoggerCompressionTest.java @@ -0,0 +1,196 @@ +package io.tapdata.write; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.stream.Collectors; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Test for FileLogger compression functionality + */ +public class FileLoggerCompressionTest { + + @TempDir + Path tempDir; + + private FileLogger logger; + + @AfterEach + public void tearDown() { + if (logger != null) { + logger.close(); + } + } + + @Test + public void testCompressionEnabled() throws IOException, InterruptedException { + // Create logger with compression enabled (compress every 2 seconds for testing) + logger = FileLogger.builder() + .logDirectory(tempDir.toString()) + .logFilePrefix("test") + .maxFileSizeMB(1) + .enableCompression(true) + .compressIntervalMs(2000) // 2 seconds for testing + .retainDays(1) + .build(); + + // Write some logs + for (int i = 0; i < 100; i++) { + logger.write("Test log message " + i); + } + + // Force flush and close to ensure file is written + logger.forceFlush(); + Thread.sleep(500); + + // Close the logger to release the file + logger.close(); + + // Get the log file path after closing + List logFiles = Files.list(tempDir) + .filter(p -> p.getFileName().toString().endsWith(".log")) + .collect(Collectors.toList()); + + assertTrue(logFiles.size() > 0, "Should have at least one log file"); + Path logFile = logFiles.get(0); + long originalSize = Files.size(logFile); + System.out.println("Original log file: " + logFile.getFileName() + ", size: " + originalSize); + + // Create a new logger to trigger compression + logger = FileLogger.builder() + .logDirectory(tempDir.toString()) + .logFilePrefix("test") + .enableCompression(true) + .compressIntervalMs(2000) + .retainDays(1) + .build(); + + // Manually trigger compression (force all files) + logger.compressNow(true); + + // Wait for compression to complete + Thread.sleep(1000); + + // Check that compressed file exists + List compressedFiles = Files.list(tempDir) + .filter(p -> p.getFileName().toString().endsWith(".log.gz")) + .collect(Collectors.toList()); + + assertTrue(compressedFiles.size() > 0, "Should have at least one compressed file"); + + // Check that original log file was deleted + assertFalse(Files.exists(logFile), "Original log file should be deleted after compression"); + + // Check compressed file size + Path compressedFile = compressedFiles.get(0); + long compressedSize = Files.size(compressedFile); + + System.out.println("Compressed file: " + compressedFile.getFileName() + ", size: " + compressedSize); + System.out.println("Compression ratio: " + (100.0 - (double) compressedSize / originalSize * 100) + "%"); + + // Compressed file should be smaller + assertTrue(compressedSize < originalSize, "Compressed file should be smaller than original"); + + // Check stats + FileLogger.LoggerStats stats = logger.getStats(); + assertTrue(stats.getTotalFilesCompressed() > 0, "Should have compressed at least one file"); + + System.out.println("Stats: " + stats); + } + + @Test + public void testCompressionDisabled() throws IOException, InterruptedException { + // Create logger without compression + logger = FileLogger.builder() + .logDirectory(tempDir.toString()) + .logFilePrefix("test-no-compress") + .maxFileSizeMB(1) + .enableCompression(false) // Compression disabled + .build(); + + // Write some logs + for (int i = 0; i < 100; i++) { + logger.write("Test log message " + i); + } + + logger.forceFlush(); + Thread.sleep(500); + + // Check that log file exists + List logFiles = Files.list(tempDir) + .filter(p -> p.getFileName().toString().startsWith("test-no-compress")) + .filter(p -> p.getFileName().toString().endsWith(".log")) + .collect(Collectors.toList()); + + assertTrue(logFiles.size() > 0, "Should have at least one log file"); + + // Wait a bit + Thread.sleep(2000); + + // Check that no compressed files exist + List compressedFiles = Files.list(tempDir) + .filter(p -> p.getFileName().toString().startsWith("test-no-compress")) + .filter(p -> p.getFileName().toString().endsWith(".log.gz")) + .collect(Collectors.toList()); + + assertEquals(0, compressedFiles.size(), "Should have no compressed files when compression is disabled"); + + // Check stats + FileLogger.LoggerStats stats = logger.getStats(); + assertEquals(0, stats.getTotalFilesCompressed(), "Should have compressed zero files"); + } + + @Test + public void testManualCompression() throws IOException, InterruptedException { + // Create logger with compression enabled + logger = FileLogger.builder() + .logDirectory(tempDir.toString()) + .logFilePrefix("test-manual") + .maxFileSizeMB(1) + .enableCompression(true) + .compressIntervalMs(60000) // 1 minute (won't trigger automatically in test) + .build(); + + // Write logs and rotate file + for (int i = 0; i < 100; i++) { + logger.write("Test log message " + i); + } + + logger.forceFlush(); + Thread.sleep(500); + + // Close to release the file + logger.close(); + + // Create new logger + logger = FileLogger.builder() + .logDirectory(tempDir.toString()) + .logFilePrefix("test-manual") + .enableCompression(true) + .compressIntervalMs(60000) + .build(); + + // Manually trigger compression (force all files) + logger.compressNow(true); + Thread.sleep(1000); + + // Check that compressed file exists + List compressedFiles = Files.list(tempDir) + .filter(p -> p.getFileName().toString().startsWith("test-manual")) + .filter(p -> p.getFileName().toString().endsWith(".log.gz")) + .collect(Collectors.toList()); + + assertTrue(compressedFiles.size() > 0, "Manual compression should create compressed files"); + + System.out.println("Manual compression created " + compressedFiles.size() + " compressed file(s)"); + } +} + diff --git a/connectors-common/debezium-bucket/debezium-connector-mysql/src/main/java/io/debezium/connector/mysql/RowDeserializers.java b/connectors-common/debezium-bucket/debezium-connector-mysql/src/main/java/io/debezium/connector/mysql/RowDeserializers.java index 9725bcd55..bef582636 100644 --- a/connectors-common/debezium-bucket/debezium-connector-mysql/src/main/java/io/debezium/connector/mysql/RowDeserializers.java +++ b/connectors-common/debezium-bucket/debezium-connector-mysql/src/main/java/io/debezium/connector/mysql/RowDeserializers.java @@ -322,33 +322,34 @@ protected static Serializable deserializeTimeV2(int meta, ByteArrayInputStream i * (3 bytes in total) * * + fractional-seconds storage (size depends on meta) + * * The fractional part: + * read 1 byte, if meta is 1 or 2 + * read 2 bytes, if meta is 3 or 4 + * read 3 bytes, if meta is 5 or 6 */ - long time = bigEndianLong(inputStream.read(3), 0, 3); - boolean is_negative = bitSlice(time, 0, 1, 24) == 0; - int hours = bitSlice(time, 2, 10, 24); - int minutes = bitSlice(time, 12, 6, 24); - int seconds = bitSlice(time, 18, 6, 24); - int nanoSeconds; - if (is_negative) { // mysql binary arithmetic for negative encoded values - hours = ~hours & MASK_10_BITS; - hours = hours & ~(1 << 10); // unset sign bit - minutes = ~minutes & MASK_6_BITS; - minutes = minutes & ~(1 << 6); // unset sign bit - seconds = ~seconds & MASK_6_BITS; - seconds = seconds & ~(1 << 6); // unset sign bit - nanoSeconds = deserializeFractionalSecondsInNanosNegative(meta, inputStream); - if (nanoSeconds == 0 && seconds < 59) { // weird java Duration behavior - ++seconds; - } - hours = -hours; - minutes = -minutes; - seconds = -seconds; - nanoSeconds = -nanoSeconds; - } - else { - nanoSeconds = deserializeFractionalSecondsInNanos(meta, inputStream); - } - return Duration.ofHours(hours).plusMinutes(minutes).plusSeconds(seconds).plusNanos(nanoSeconds); + int fractionBytes = (meta + 1) / 2; + int payloadBytes = 3 + fractionBytes; + int payloadBits = payloadBytes * 8; + long time = bigEndianLong(inputStream.read(payloadBytes), 0, payloadBytes); + boolean is_negative = bitSlice(time, 0, 1, payloadBits) == 0; + + if (is_negative) { + /* + * Negative numbers are stored in two's complement form. + * To get the positive value of a negative number in two's complement form, + * we should invert the bits of the number and add 1 to the result. + * Then we can take the number from the corresponding bits on the final result. + */ + time = ~time + 1; + } + + int hours = bitSlice(time, 2, 10, payloadBits); + int minutes = bitSlice(time, 12, 6, payloadBits); + int seconds = bitSlice(time, 18, 6, payloadBits); + int fraction = bitSlice(time, 24, fractionBytes * 8, payloadBits); + long nanoSeconds = (long) (fraction / (0.0000001 * Math.pow(100, fractionBytes - 1))); + final Duration duration = Duration.ofHours(hours).plusMinutes(minutes).plusSeconds(seconds).plusNanos(nanoSeconds); + return is_negative && !duration.isNegative() ? duration.negated() : duration; } /** diff --git a/connectors-common/debezium-bucket/debezium-connector-postgres/src/main/java/io/debezium/connector/postgresql/connection/PostgresReplicationConnection.java b/connectors-common/debezium-bucket/debezium-connector-postgres/src/main/java/io/debezium/connector/postgresql/connection/PostgresReplicationConnection.java index bb201e76d..0952921e8 100644 --- a/connectors-common/debezium-bucket/debezium-connector-postgres/src/main/java/io/debezium/connector/postgresql/connection/PostgresReplicationConnection.java +++ b/connectors-common/debezium-bucket/debezium-connector-postgres/src/main/java/io/debezium/connector/postgresql/connection/PostgresReplicationConnection.java @@ -171,7 +171,15 @@ protected void initPublication() { Set tablesToCapture = determineCapturedTables(); Set existsTables = new HashSet<>(); String catalog = connection().getCatalog(); - try (ResultSet resultSet = stmt.executeQuery(String.format("select * from pg_publication_tables where pubname='%s'", publicationName))) { + try (ResultSet resultSet = stmt.executeQuery(String.format("select schemaname, tablename from pg_publication_tables pt join pg_publication pp\n" + + " on pt.pubname = pp.pubname\n" + + "where pt.pubname='%s' and pp.pubviaroot='false'\n" + + "union all\n" + + "select schemaname, pc.relname from pg_publication_tables pt join pg_publication pp\n" + + " on pt.pubname = pp.pubname join pg_class pc on pc.oid in (SELECT inhrelid\n" + + " FROM pg_inherits\n" + + " WHERE inhparent = (pt.schemaname||'.'||pt.tablename)::regclass)\n" + + "where pt.pubname='%s' and pp.pubviaroot='true'", publicationName, publicationName))) { while (resultSet.next()) { existsTables.add(new TableId(catalog, resultSet.getString("schemaname"), resultSet.getString("tablename"))); } diff --git a/connectors-common/debezium-bucket/debezium-ddl-parser/src/main/antlr4/io/debezium/ddl/parser/mysql/generated/MySqlParser.g4 b/connectors-common/debezium-bucket/debezium-ddl-parser/src/main/antlr4/io/debezium/ddl/parser/mysql/generated/MySqlParser.g4 index 61385fc2a..836dbd9d5 100644 --- a/connectors-common/debezium-bucket/debezium-ddl-parser/src/main/antlr4/io/debezium/ddl/parser/mysql/generated/MySqlParser.g4 +++ b/connectors-common/debezium-bucket/debezium-ddl-parser/src/main/antlr4/io/debezium/ddl/parser/mysql/generated/MySqlParser.g4 @@ -460,7 +460,7 @@ partitionDefinitions partitionFunctionDefinition : LINEAR? HASH '(' expression ')' #partitionFunctionHash | LINEAR? KEY (ALGORITHM '=' algType=('1' | '2'))? - '(' uidList ')' #partitionFunctionKey + '(' uidList? ')' #partitionFunctionKey | RANGE ( '(' expression ')' | COLUMNS '(' uidList ')' ) #partitionFunctionRange | LIST ( '(' expression ')' | COLUMNS '(' uidList ')' ) #partitionFunctionList ; @@ -468,7 +468,7 @@ partitionFunctionDefinition subpartitionFunctionDefinition : LINEAR? HASH '(' expression ')' #subPartitionFunctionHash | LINEAR? KEY (ALGORITHM '=' algType=('1' | '2'))? - '(' uidList ')' #subPartitionFunctionKey + '(' uidList? ')' #subPartitionFunctionKey ; partitionDefinition @@ -1954,7 +1954,7 @@ fullColumnName ; indexColumnName - : (uid | STRING_LITERAL) ('(' decimalLiteral ')')? sortType=(ASC | DESC)? + : ('(' expression ')' | uid | STRING_LITERAL) ('(' decimalLiteral ')')? sortType=(ASC | DESC)? ; userName diff --git a/connectors-common/debezium-bucket/debezium-embedded/src/main/java/io/debezium/embedded/EmbeddedEngine.java b/connectors-common/debezium-bucket/debezium-embedded/src/main/java/io/debezium/embedded/EmbeddedEngine.java index e6b44ed15..09669f55c 100644 --- a/connectors-common/debezium-bucket/debezium-embedded/src/main/java/io/debezium/embedded/EmbeddedEngine.java +++ b/connectors-common/debezium-bucket/debezium-embedded/src/main/java/io/debezium/embedded/EmbeddedEngine.java @@ -758,7 +758,6 @@ public Map configs() { }; task.initialize(taskContext); task.start(taskConfigs.get(0)); - connectorCallback.ifPresent(DebeziumEngine.ConnectorCallback::taskStarted); } catch (Throwable t) { // Clean-up allocated resources @@ -779,6 +778,8 @@ public Map configs() { recordsSinceLastCommit = 0; Throwable handlerError = null; + boolean firstStart = true; + int count = 0; try { timeOfLastCommitMillis = clock.currentTimeInMillis(); RecordCommitter committer = buildRecordCommitter(offsetWriter, task, commitTimeout); @@ -787,6 +788,12 @@ public Map configs() { try { LOGGER.debug("Embedded engine is polling task for records on thread {}", runningThread.get()); changeRecords = task.poll(); // blocks until there are values ... + if(firstStart && (!changeRecords.isEmpty() || count >= 5)){ + connectorCallback.ifPresent(DebeziumEngine.ConnectorCallback::taskStarted); + firstStart = false; + }else if(firstStart){ + count++; + } LOGGER.debug("Embedded engine returned from polling task for records"); } catch (InterruptedException e) { diff --git a/connectors-common/js-connector-core-plus/pom.xml b/connectors-common/js-connector-core-plus/pom.xml index 50a00e1b6..ad4eaee26 100644 --- a/connectors-common/js-connector-core-plus/pom.xml +++ b/connectors-common/js-connector-core-plus/pom.xml @@ -14,7 +14,7 @@ 8 - 2.0.0-SNAPSHOT + 2.0.5-SNAPSHOT diff --git a/connectors-common/js-connector-core/pom.xml b/connectors-common/js-connector-core/pom.xml index 23685cad2..667dfb3ea 100644 --- a/connectors-common/js-connector-core/pom.xml +++ b/connectors-common/js-connector-core/pom.xml @@ -14,7 +14,7 @@ 8 - 2.0.0-SNAPSHOT + 2.0.5-SNAPSHOT diff --git a/connectors-common/kafka-core/src/main/java/io/tapdata/connector/kafka/KafkaService.java b/connectors-common/kafka-core/src/main/java/io/tapdata/connector/kafka/KafkaService.java index ab1295802..cae68a58f 100644 --- a/connectors-common/kafka-core/src/main/java/io/tapdata/connector/kafka/KafkaService.java +++ b/connectors-common/kafka-core/src/main/java/io/tapdata/connector/kafka/KafkaService.java @@ -80,9 +80,7 @@ public KafkaService(KafkaConfig mqConfig, Log tapLogger) { try { kafkaProducer = new KafkaProducer<>(producerConfiguration.build()); } catch (Exception e) { - e.printStackTrace(); - tapLogger.error("Kafka producer error: " + ErrorKit.getLastCause(e).getMessage(), e); - throw new RuntimeException(e); + tapLogger.warn("Kafka producer error: " + ErrorKit.getLastCause(e).getMessage(), e); } } @@ -259,7 +257,7 @@ protected void submitPageTables(int tableSize, Consumer> consumer messageBody = (Map) messageBody.get("data"); } } catch (Exception e) { - tapLogger.warn("topic[{}] value [{}] can not parse to json, ignore...", record.topic(), new String(record.value())); + tapLogger.warn("topic[{}] value [{}] can not parse to json, ignore...", record.topic(), String.valueOf(record.value())); TapTable tapTable = new TapTable(record.topic()); tableList.add(tapTable); topics.remove(record.topic()); diff --git a/connectors-common/mysql-core/pom.xml b/connectors-common/mysql-core/pom.xml index 82d6621ee..6f05b0578 100644 --- a/connectors-common/mysql-core/pom.xml +++ b/connectors-common/mysql-core/pom.xml @@ -33,7 +33,7 @@ 8 8.0.33 1.5.4.Final - 2.0.0-SNAPSHOT + 2.0.5-SNAPSHOT 1.0-SNAPSHOT @@ -123,6 +123,12 @@ fastjson 1.2.83 + + io.tapdata + tapdata-common + 0.2.23-SNAPSHOT + provided + org.mockito mockito-core @@ -146,6 +152,12 @@ 5.0.4.RELEASE test + + io.tapdata + cdc-core + 1.0-SNAPSHOT + compile + diff --git a/connectors-common/mysql-core/src/main/java/io/tapdata/connector/mysql/MysqlConnectionTest.java b/connectors-common/mysql-core/src/main/java/io/tapdata/connector/mysql/MysqlConnectionTest.java index bacb226a3..a7f2a80d5 100644 --- a/connectors-common/mysql-core/src/main/java/io/tapdata/connector/mysql/MysqlConnectionTest.java +++ b/connectors-common/mysql-core/src/main/java/io/tapdata/connector/mysql/MysqlConnectionTest.java @@ -162,7 +162,7 @@ public boolean testWriteOrReadPrivilege(String grantSql, List tableList, } public boolean testWriteOrReadPrivilegeV2(String grantSql, List tableList, String databaseName, String mark) { - Pattern pattern = Pattern.compile(("GRANT (.*) ON (.*)\\.\\* TO (.*)@`%`")); + Pattern pattern = Pattern.compile(("GRANT (.*) ON (.*)\\.\\* TO (.*)@[`']%[`']")); Matcher matcher = pattern.matcher(grantSql); if (matcher.find()) { String privilege = matcher.group(1); diff --git a/connectors-common/mysql-core/src/main/java/io/tapdata/connector/mysql/MysqlJdbcContextV2.java b/connectors-common/mysql-core/src/main/java/io/tapdata/connector/mysql/MysqlJdbcContextV2.java index c58e8bf45..d642b97d2 100644 --- a/connectors-common/mysql-core/src/main/java/io/tapdata/connector/mysql/MysqlJdbcContextV2.java +++ b/connectors-common/mysql-core/src/main/java/io/tapdata/connector/mysql/MysqlJdbcContextV2.java @@ -17,6 +17,7 @@ import java.text.DecimalFormat; import java.time.ZoneId; import java.util.*; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicReference; import java.util.stream.Collectors; @@ -167,6 +168,20 @@ public Map querySlaveStatus() throws Throwable { return hostPortAndStatus; } + public boolean queryIsPrimary() { + AtomicBoolean atomicBoolean = new AtomicBoolean(false); + try { + normalQuery("select count(*) from performance_schema.replication_group_members where MEMBER_ROLE='PRIMARY' and MEMBER_ID=@@server_uuid;", rs -> { + if (rs.next()) { + atomicBoolean.set(rs.getInt(1) > 0); + } + }); + } catch (SQLException e) { + TapLogger.warn(TAG, "Query is primary failed, error: " + e.getMessage(), e); + } + return atomicBoolean.get(); + } + private boolean isLowVersion() throws SQLException { boolean lowVersion = true; try ( diff --git a/connectors-common/mysql-core/src/main/java/io/tapdata/connector/mysql/MysqlReader.java b/connectors-common/mysql-core/src/main/java/io/tapdata/connector/mysql/MysqlReader.java index 14e661a73..9920df29b 100644 --- a/connectors-common/mysql-core/src/main/java/io/tapdata/connector/mysql/MysqlReader.java +++ b/connectors-common/mysql-core/src/main/java/io/tapdata/connector/mysql/MysqlReader.java @@ -10,6 +10,9 @@ import io.tapdata.common.ddl.type.DDLParserType; import io.tapdata.common.ddl.wrapper.DDLWrapperConfig; import io.tapdata.common.exception.ExceptionCollector; +import io.tapdata.connector.mysql.accept.MysqlAbstractAcceptor; +import io.tapdata.connector.mysql.accept.MysqlBatchAcceptor; +import io.tapdata.connector.mysql.accept.MysqlOneByOneAcceptor; import io.tapdata.connector.mysql.config.MysqlConfig; import io.tapdata.connector.mysql.constant.DeployModeEnum; import io.tapdata.connector.mysql.entity.MysqlBinlogPosition; @@ -44,9 +47,10 @@ import io.tapdata.kit.ErrorKit; import io.tapdata.kit.StringKit; import io.tapdata.pdk.apis.consumer.StreamReadConsumer; +import io.tapdata.pdk.apis.consumer.StreamReadOneByOneConsumer; +import io.tapdata.pdk.apis.consumer.TapStreamReadConsumer; import io.tapdata.pdk.apis.context.TapConnectorContext; import io.tapdata.pdk.apis.entity.TapAdvanceFilter; -import org.apache.commons.collections4.CollectionUtils; import org.apache.commons.lang3.StringUtils; import org.apache.kafka.connect.data.Field; import org.apache.kafka.connect.data.Schema; @@ -88,7 +92,7 @@ public class MysqlReader implements Closeable { private final Supplier isAlive; protected final MysqlJdbcContextV2 mysqlJdbcContext; private EmbeddedEngine embeddedEngine; - protected StreamReadConsumer streamReadConsumer; + protected MysqlAbstractAcceptor streamReadConsumer; private LinkedBlockingQueue eventQueue; private ScheduledExecutorService mysqlSchemaHistoryMonitor; protected KVReadOnlyMap tapTableMap; @@ -368,8 +372,21 @@ public void taskStarted() { } } + protected MysqlAbstractAcceptor createAcceptor(int batchSize, TapStreamReadConsumer consumer) { + if (consumer instanceof StreamReadOneByOneConsumer) { + return new MysqlOneByOneAcceptor() + .setConsumer((StreamReadOneByOneConsumer) consumer); + } else if (consumer instanceof StreamReadConsumer) { + return new MysqlBatchAcceptor() + .setConsumer((StreamReadConsumer) consumer) + .setBatchSize(batchSize); + } else { + throw new IllegalArgumentException("Unsupported consumer type: " + consumer.getClass().getName()); + } + } + public void readBinlog(TapConnectorContext tapConnectorContext, List tables, - Object offset, int batchSize, DDLParserType ddlParserType, StreamReadConsumer consumer, HashMap contextMapForMasterSlave) throws Throwable { + Object offset, int batchSize, DDLParserType ddlParserType, TapStreamReadConsumer consumer, HashMap contextMapForMasterSlave) throws Throwable { MysqlUtil.buildMasterNode(mysqlConfig, contextMapForMasterSlave); try { initDebeziumServerName(tapConnectorContext); @@ -414,7 +431,7 @@ public void readBinlog(TapConnectorContext tapConnectorContext, List tab offsetStr = jsonParser.toJson(mysqlStreamOffset); } tapLogger.info("Starting mysql cdc, server name: " + serverName); - this.streamReadConsumer = consumer; + this.streamReadConsumer = createAcceptor(batchSize, consumer); LockManager.mysqlSchemaHistoryTransferManager.computeIfAbsent(serverName, key -> { this.schemaHistoryTransfer = new MysqlSchemaHistoryTransfer(); return this.schemaHistoryTransfer; @@ -512,8 +529,14 @@ public void taskStarted() { streamReadConsumer.streamReadStarted(); } }) - .using((numberOfMessagesSinceLastCommit, timeSinceLastCommit) -> - numberOfMessagesSinceLastCommit >= batchSize || timeSinceLastCommit.getSeconds() >= 5) + .using((numberOfMessagesSinceLastCommit, timeSinceLastCommit) -> { + int size = Math.min(Math.max(1, streamReadConsumer.getBatchSize()), 10000); + //超时时间最小1秒,最大5秒 + int timeout = Math.min(Math.max(1, size / 100), 5); + return numberOfMessagesSinceLastCommit >= size || timeSinceLastCommit.getSeconds() >= timeout; + }) +// .using((numberOfMessagesSinceLastCommit, timeSinceLastCommit) -> +// numberOfMessagesSinceLastCommit >= batchSize || timeSinceLastCommit.getSeconds() >= 5) .using((result, message, throwable) -> { tapConnectorContext.configContext(); if (result) { @@ -654,32 +677,25 @@ private void consumeRecords(List sourceRecords, DebeziumEngine.Rec if (null != throwableAtomicReference.get()) { throw new RuntimeException(throwableAtomicReference.get()); } - List mysqlStreamEvents = new ArrayList<>(); - for (SourceRecord record : sourceRecords) { + for (int i = 0; i < sourceRecords.size(); i++) { + SourceRecord record = sourceRecords.get(i); + boolean lastOne = (i == sourceRecords.size() - 1); if (null == record || null == record.value()) continue; Schema valueSchema = record.valueSchema(); if (null != valueSchema.field("op")) { - MysqlStreamEvent mysqlStreamEvent = wrapDML(record); - Optional.ofNullable(mysqlStreamEvent).ifPresent(mysqlStreamEvents::add); + MysqlStreamEvent mysqlStreamEvent = wrapDML(record, lastOne); + Optional.ofNullable(mysqlStreamEvent).ifPresent(streamReadConsumer::accept); } else if (null != valueSchema.field("ddl")) { - mysqlStreamEvents.addAll(Objects.requireNonNull(wrapDDL(record))); + wrapDDLWithConsumer(record, lastOne, streamReadConsumer::accept); } else if ("io.debezium.connector.common.Heartbeat".equals(valueSchema.name())) { Optional.ofNullable((Struct) record.value()) .map(value -> value.getInt64("ts_ms")) .map(TapSimplify::heartbeatEvent) - .map(heartbeatEvent -> new MysqlStreamEvent(heartbeatEvent, getMysqlStreamOffset(record))) - .ifPresent(mysqlStreamEvents::add); + .map(heartbeatEvent -> new MysqlStreamEvent(heartbeatEvent, lastOne ? getMysqlStreamOffset(record) : null)) + .ifPresent(streamReadConsumer::accept); } } - if (CollectionUtils.isNotEmpty(mysqlStreamEvents)) { - List tapEvents = new ArrayList<>(); - MysqlStreamOffset mysqlStreamOffset = null; - for (MysqlStreamEvent mysqlStreamEvent : mysqlStreamEvents) { - tapEvents.add(mysqlStreamEvent.getTapEvent()); - mysqlStreamOffset = mysqlStreamEvent.getMysqlStreamOffset(); - } - streamReadConsumer.accept(tapEvents, mysqlStreamOffset); - } + streamReadConsumer.complete(); } protected void sourceRecordConsumer(SourceRecord record) { @@ -688,31 +704,26 @@ protected void sourceRecordConsumer(SourceRecord record) { } if (null == record || null == record.value()) return; Schema valueSchema = record.valueSchema(); - List mysqlStreamEvents = new ArrayList<>(); if (null != valueSchema.field("op")) { MysqlStreamEvent mysqlStreamEvent = wrapDML(record); - Optional.ofNullable(mysqlStreamEvent).ifPresent(mysqlStreamEvents::add); + Optional.ofNullable(mysqlStreamEvent).ifPresent(streamReadConsumer::accept); } else if (null != valueSchema.field("ddl")) { - mysqlStreamEvents = wrapDDL(record); + wrapDDLWithConsumer(record, false, streamReadConsumer::accept); } else if ("io.debezium.connector.common.Heartbeat".equals(valueSchema.name())) { Optional.ofNullable((Struct) record.value()) .map(value -> value.getInt64("ts_ms")) .map(TapSimplify::heartbeatEvent) .map(heartbeatEvent -> new MysqlStreamEvent(heartbeatEvent, getMysqlStreamOffset(record))) - .ifPresent(mysqlStreamEvents::add); - } - if (CollectionUtils.isNotEmpty(mysqlStreamEvents)) { - List tapEvents = new ArrayList<>(); - MysqlStreamOffset mysqlStreamOffset = null; - for (MysqlStreamEvent mysqlStreamEvent : mysqlStreamEvents) { - tapEvents.add(mysqlStreamEvent.getTapEvent()); - mysqlStreamOffset = mysqlStreamEvent.getMysqlStreamOffset(); - } - streamReadConsumer.accept(tapEvents, mysqlStreamOffset); + .ifPresent(streamReadConsumer::accept); } + streamReadConsumer.complete(); } protected MysqlStreamEvent wrapDML(SourceRecord record) { + return wrapDML(record, false); + } + + protected MysqlStreamEvent wrapDML(SourceRecord record, boolean lastOne) { TapRecordEvent tapRecordEvent = null; Schema valueSchema = record.valueSchema(); Struct value = (Struct) record.value(); @@ -830,7 +841,10 @@ protected MysqlStreamEvent wrapDML(SourceRecord record) { tapRecordEvent.setTableId(table); tapRecordEvent.setReferenceTime(eventTime); tapRecordEvent.setExactlyOnceId(getExactlyOnceId(record)); - return wrapOffsetEvent(tapRecordEvent, record); + if (lastOne) { + return wrapOffsetEvent(tapRecordEvent, record); + } + return new MysqlStreamEvent(tapRecordEvent, null); } protected MysqlStreamEvent wrapOffsetEvent(TapEvent tapEvent, SourceRecord sourceRecord) { @@ -838,17 +852,16 @@ protected MysqlStreamEvent wrapOffsetEvent(TapEvent tapEvent, SourceRecord sourc return new MysqlStreamEvent(tapEvent, mysqlStreamOffset); } - protected List wrapDDL(SourceRecord record) { - List mysqlStreamEvents = new ArrayList<>(); + protected void wrapDDLWithConsumer(SourceRecord record, boolean lastOne, Consumer consumer) { Object value = record.value(); if (!(value instanceof Struct)) { - return null; + return; } Struct structValue = (Struct) value; Struct source = structValue.getStruct("source"); Long eventTime = source.getInt64("ts_ms"); String ddlStr = structValue.getString(SOURCE_RECORD_DDL_KEY); - MysqlStreamOffset mysqlStreamOffset = getMysqlStreamOffset(record); + MysqlStreamOffset mysqlStreamOffset = lastOne ? getMysqlStreamOffset(record) : null; if (StringUtils.isNotBlank(ddlStr)) { try { DDLFactory.ddlToTapDDLEvent( @@ -862,7 +875,7 @@ protected List wrapDDL(SourceRecord record) { tapDDLEvent.setReferenceTime(eventTime); tapDDLEvent.setOriginDDL(ddlStr); tapDDLEvent.setExactlyOnceId(getExactlyOnceId(record)); - mysqlStreamEvents.add(mysqlStreamEvent); + consumer.accept(mysqlStreamEvent); tapLogger.info("Read DDL: " + ddlStr + ", about to be packaged as some event(s)"); } ); @@ -873,10 +886,17 @@ protected List wrapDDL(SourceRecord record) { tapDDLEvent.setReferenceTime(eventTime); tapDDLEvent.setOriginDDL(ddlStr); tapDDLEvent.setExactlyOnceId(getExactlyOnceId(record)); - mysqlStreamEvents.add(mysqlStreamEvent); -// throw new RuntimeException("Handle ddl failed: " + ddlStr + ", error: " + e.getMessage(), e); + consumer.accept(mysqlStreamEvent); } } + } + + /** + * @deprecated + * */ + protected List wrapDDL(SourceRecord record) { + List mysqlStreamEvents = new ArrayList<>(); + wrapDDLWithConsumer(record, false, mysqlStreamEvents::add); return mysqlStreamEvents; } diff --git a/connectors-common/mysql-core/src/main/java/io/tapdata/connector/mysql/MysqlReaderV2.java b/connectors-common/mysql-core/src/main/java/io/tapdata/connector/mysql/MysqlReaderV2.java new file mode 100644 index 000000000..66178be33 --- /dev/null +++ b/connectors-common/mysql-core/src/main/java/io/tapdata/connector/mysql/MysqlReaderV2.java @@ -0,0 +1,536 @@ +package io.tapdata.connector.mysql; + +import com.github.shyiko.mysql.binlog.BinaryLogClient; +import com.github.shyiko.mysql.binlog.event.*; +import com.github.shyiko.mysql.binlog.event.deserialization.EventDeserializer; +import io.tapdata.common.concurrent.ConcurrentProcessor; +import io.tapdata.common.concurrent.TapExecutors; +import io.tapdata.common.ddl.DDLFactory; +import io.tapdata.common.ddl.ccj.CCJBaseDDLWrapper; +import io.tapdata.common.ddl.type.DDLParserType; +import io.tapdata.common.ddl.wrapper.DDLWrapperConfig; +import io.tapdata.connector.mysql.config.MysqlConfig; +import io.tapdata.connector.mysql.entity.MysqlBinlogPosition; +import io.tapdata.connector.mysql.util.MySQLJsonParser; +import io.tapdata.entity.event.TapEvent; +import io.tapdata.entity.event.ddl.TapDDLEvent; +import io.tapdata.entity.event.ddl.TapDDLUnknownEvent; +import io.tapdata.entity.event.dml.TapDeleteRecordEvent; +import io.tapdata.entity.event.dml.TapInsertRecordEvent; +import io.tapdata.entity.event.dml.TapUpdateRecordEvent; +import io.tapdata.entity.logger.Log; +import io.tapdata.entity.schema.TapTable; +import io.tapdata.entity.utils.cache.KVReadOnlyMap; +import io.tapdata.kit.EmptyKit; +import io.tapdata.kit.StringKit; +import io.tapdata.pdk.apis.consumer.StreamReadConsumer; + +import java.io.Serializable; +import java.math.BigDecimal; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Supplier; +import java.util.stream.Collectors; + +import static io.tapdata.base.ConnectorBase.list; + +public class MysqlReaderV2 { + + private final MysqlConfig mysqlConfig; + private final Log tapLogger; + private List tableList; + private KVReadOnlyMap tableMap; + private Object offsetState; + private int recordSize; + private StreamReadConsumer consumer; + private final AtomicReference exception = new AtomicReference<>(); + private final Map tableMapEventByTableId = new ConcurrentHashMap<>(); + private final Map> dataTypeMap = new ConcurrentHashMap<>(); + private final Map> enumDataTypeMap = new ConcurrentHashMap<>(); + private final TimeZone timeZone; + private final DDLWrapperConfig DDL_WRAPPER_CONFIG = CCJBaseDDLWrapper.CCJDDLWrapperConfig.create().split("`"); + private final DDLParserType ddlParserType = DDLParserType.MYSQL_CCJ_SQL_PARSER; + + public MysqlReaderV2(MysqlJdbcContextV2 mysqlJdbcContext, Log tapLogger, TimeZone timeZone) { + this.tapLogger = tapLogger; + mysqlConfig = (MysqlConfig) mysqlJdbcContext.getConfig(); + this.timeZone = timeZone; + } + + public void init(List tableList, KVReadOnlyMap tableMap, Object offsetState, int recordSize, StreamReadConsumer consumer) throws Throwable { + this.tableList = tableList; + this.tableMap = tableMap; + this.offsetState = offsetState; + this.recordSize = recordSize; + this.consumer = consumer; + } + + public void startMiner(Supplier isAlive) throws Throwable { + BinaryLogClient client = new BinaryLogClient(mysqlConfig.getHost(), mysqlConfig.getPort(), mysqlConfig.getUser(), mysqlConfig.getPassword()); + AtomicReference> events = new AtomicReference<>(list()); + AtomicReference currentBinlogFile = new AtomicReference<>(); + + try (ConcurrentProcessor concurrentProcessor = TapExecutors.createSimple(8, 32, "MysqlReader-Processor")) { + client.setServerId(randomServerId()); + EventDeserializer eventDeserializer = new EventDeserializer(); + eventDeserializer.setCompatibilityMode( + EventDeserializer.CompatibilityMode.DATE_AND_TIME_AS_LONG_MICRO + ); + client.setEventDeserializer(eventDeserializer); + // 设置起始位置 + if (offsetState instanceof MysqlBinlogPosition) { + MysqlBinlogPosition position = (MysqlBinlogPosition) offsetState; + if (EmptyKit.isNotEmpty(position.getFilename())) { + client.setBinlogFilename(position.getFilename()); + client.setBinlogPosition(position.getPosition()); + currentBinlogFile.set(position.getFilename()); + tapLogger.info("Starting from binlog position: {}/{}", position.getFilename(), position.getPosition()); + } + } + + // 注册事件监听器 + client.registerEventListener(event -> { + // 更新当前 binlog 位置 + EventType eventType = event.getHeader().getEventType(); + if (eventType == EventType.ROTATE) { + RotateEventData rotateEventData = (RotateEventData) EventDeserializer.EventDataWrapper.internal(event.getData()); + currentBinlogFile.set(rotateEventData.getBinlogFilename()); + tapLogger.info("Binlog rotated to: {}/{}", rotateEventData.getBinlogFilename(), rotateEventData.getBinlogPosition()); + } else if (eventType == EventType.TABLE_MAP) { + handleTableMapEvent(event); + } else if (eventType == EventType.QUERY) { + concurrentProcessor.runAsyncWithBlocking(new ScanEvent(event, currentBinlogFile.get()), this::emit); + return; + } + + // 异步处理事件 + concurrentProcessor.runAsync(new ScanEvent(event, currentBinlogFile.get()), this::emit); + }); + + consumer.streamReadStarted(); + Thread t = new Thread(() -> { + try { + client.connect(); + } catch (Exception e) { + tapLogger.warn("Error connecting to MySQL: {}", e.getMessage(), e); + exception.set(e); + } + }); + t.setName("MysqlReader-Connector"); + t.start(); + MysqlBinlogPosition lastOffset = null; + while (isAlive.get()) { + if (EmptyKit.isNotNull(exception.get())) { + throw exception.get(); + } + OffsetEvent offsetEvent = concurrentProcessor.get(2, TimeUnit.SECONDS); + if (EmptyKit.isNotNull(offsetEvent)) { + events.get().addAll(offsetEvent.getTapEvents()); + lastOffset = offsetEvent.getMysqlBinlogPosition(); + if (events.get().size() >= recordSize) { + consumer.accept(events.get(), lastOffset); + events.set(new ArrayList<>()); + } + } else { + if (!events.get().isEmpty()) { + consumer.accept(events.get(), lastOffset); + events.set(list()); + } + } + } + } finally { + consumer.streamReadEnded(); + client.disconnect(); + } + } + + private OffsetEvent emit(ScanEvent scanEvent) { + try { + Event event = scanEvent.getEvent(); + EventHeader header = event.getHeader(); + EventType eventType = header.getEventType(); + + // 处理 TABLE_MAP 事件,建立 tableId 到表信息的映射 + if (eventType == EventType.TABLE_MAP) { + return null; + } + + // 处理 ROTATE 事件,更新 binlog 文件名 + if (eventType == EventType.ROTATE) { + return null; + } + + if (eventType == EventType.QUERY) { + QueryEventData queryEventData = event.getData(); + long eventTime = header.getTimestamp(); + String ddl = StringKit.removeSqlNote(queryEventData.getSql()); + OffsetEvent offsetEvent = new OffsetEvent(); + List ddlEvents = new ArrayList<>(); + try { + DDLFactory.ddlToTapDDLEvent( + ddlParserType, + ddl, + DDL_WRAPPER_CONFIG, + tableMap, + tapDDLEvent -> { + tapDDLEvent.setTime(System.currentTimeMillis()); + tapDDLEvent.setReferenceTime(eventTime); + tapDDLEvent.setOriginDDL(ddl); + ddlEvents.add(tapDDLEvent); + tapLogger.info("Read DDL: " + ddl + ", about to be packaged as some event(s)"); + } + ); + } catch (Throwable e) { + TapDDLEvent tapDDLEvent = new TapDDLUnknownEvent(); + tapDDLEvent.setTime(System.currentTimeMillis()); + tapDDLEvent.setReferenceTime(eventTime); + tapDDLEvent.setOriginDDL(ddl); + ddlEvents.add(tapDDLEvent); + } + offsetEvent.setTapEvent(ddlEvents); + offsetEvent.setMysqlBinlogPosition(extractBinlogPosition(event, scanEvent.getFileName())); + ddlEvents.forEach(e -> ddlFlush(((TapDDLEvent) e).getTableId())); + return offsetEvent; + } + + // 处理数据变更事件 + List tapEvents; + MysqlBinlogPosition position; + + switch (eventType) { + case EXT_WRITE_ROWS: + case WRITE_ROWS: + tapEvents = handleInsertEvent(event); + break; + case EXT_UPDATE_ROWS: + case UPDATE_ROWS: + tapEvents = handleUpdateEvent(event); + break; + case EXT_DELETE_ROWS: + case DELETE_ROWS: + tapEvents = handleDeleteEvent(event); + break; + default: + return null; + } + + // 如果成功解析出 TapEvent,创建 OffsetEvent + if (tapEvents != null) { + position = extractBinlogPosition(event, scanEvent.getFileName()); + return new OffsetEvent(tapEvents, position); + } + + return null; + } catch (Exception e) { + tapLogger.error("Error emitting event: {}, error: {}", scanEvent, e.getMessage(), e); + return null; + } + } + + /** + * 处理 TABLE_MAP 事件 + */ + private void handleTableMapEvent(Event event) { + TableMapEventData tableMapEventData = (TableMapEventData) EventDeserializer.EventDataWrapper.internal(event.getData()); + long tableId = tableMapEventData.getTableId(); + String database = tableMapEventData.getDatabase(); + String table = tableMapEventData.getTable(); + + // 保存映射关系 + tableMapEventByTableId.put(tableId, tableMapEventData); + ddlFlush(table); + tapLogger.debug("Table map event: tableId={}, database={}, table={}", tableId, database, table); + } + + private void ddlFlush(String table) { + if (EmptyKit.isBlank(table)) { + return; + } + LinkedHashMap dataTypes = tableMap.get(table).getNameFieldMap().entrySet().stream() + .collect(Collectors.toMap(Map.Entry::getKey, e -> StringKit.removeParentheses(e.getValue().getDataType()), + (existing, replacement) -> existing, LinkedHashMap::new)); + dataTypeMap.put(table, dataTypes); + Map enumMap = tableMap.get(table).getNameFieldMap().entrySet().stream().filter(v -> v.getValue().getDataType().startsWith("enum")) + .collect(Collectors.toMap(Map.Entry::getKey, e -> { + String enumType = e.getValue().getDataType(); + Object[] enumValues = enumType.substring("enum(".length(), enumType.length() - 1).split(","); + for (int i = 0; i < enumValues.length; i++) { + String element = ((String) enumValues[i]).trim(); + if (element.startsWith("'")) { + enumValues[i] = StringKit.removeHeadTail(element, "'", null); + } else { + enumValues[i] = new BigDecimal(element); + } + } + return enumValues; + })); + enumDataTypeMap.put(table, enumMap); + } + + /** + * 处理 INSERT 事件 + */ + private List handleInsertEvent(Event event) { + WriteRowsEventData eventData = (WriteRowsEventData) EventDeserializer.EventDataWrapper.internal(event.getData()); + long tableId = eventData.getTableId(); + String tableName = getTableName(tableId); + + if (tableName == null || !isTableInList(tableName)) { + return null; + } + + TapTable tapTable = tableMap.get(tableName); + if (tapTable == null) { + tapLogger.warn("Table {} not found in tableMap", tableName); + return null; + } + + List rows = eventData.getRows(); + if (rows == null || rows.isEmpty()) { + return null; + } + List tapEvents = new ArrayList<>(); + for (Serializable[] row : rows) { + Map after = convertRowToMap(row, dataTypeMap.get(tableName), enumDataTypeMap.get(tableName)); + TapInsertRecordEvent insertEvent = new TapInsertRecordEvent(); + insertEvent.init(); + insertEvent.table(tableName); + insertEvent.after(after); + insertEvent.setReferenceTime(event.getHeader().getTimestamp()); + tapEvents.add(insertEvent); + } + + return tapEvents; + } + + /** + * 处理 UPDATE 事件 + */ + private List handleUpdateEvent(Event event) { + UpdateRowsEventData eventData = (UpdateRowsEventData) EventDeserializer.EventDataWrapper.internal(event.getData()); + long tableId = eventData.getTableId(); + String tableName = getTableName(tableId); + + if (tableName == null || !isTableInList(tableName)) { + return null; + } + + TapTable tapTable = tableMap.get(tableName); + if (tapTable == null) { + tapLogger.warn("Table {} not found in tableMap", tableName); + return null; + } + + List> rows = eventData.getRows(); + if (rows == null || rows.isEmpty()) { + return null; + } + List tapEvents = new ArrayList<>(); + for (Map.Entry row : rows) { + Map before = convertRowToMap(row.getKey(), dataTypeMap.get(tableName), enumDataTypeMap.get(tableName)); + Map after = convertRowToMap(row.getValue(), dataTypeMap.get(tableName), enumDataTypeMap.get(tableName)); + + TapUpdateRecordEvent updateEvent = new TapUpdateRecordEvent(); + updateEvent.init(); + updateEvent.table(tableName); + updateEvent.before(before); + updateEvent.after(after); + updateEvent.setReferenceTime(event.getHeader().getTimestamp()); + tapEvents.add(updateEvent); + } + + return tapEvents; + } + + /** + * 处理 DELETE 事件 + */ + private List handleDeleteEvent(Event event) { + DeleteRowsEventData eventData = (DeleteRowsEventData) EventDeserializer.EventDataWrapper.internal(event.getData()); + long tableId = eventData.getTableId(); + String tableName = getTableName(tableId); + + if (tableName == null || !isTableInList(tableName)) { + return null; + } + + TapTable tapTable = tableMap.get(tableName); + if (tapTable == null) { + tapLogger.warn("Table {} not found in tableMap", tableName); + return null; + } + + List rows = eventData.getRows(); + if (rows == null || rows.isEmpty()) { + return null; + } + List tapEvents = new ArrayList<>(); + for (Serializable[] row : rows) { + Map before = convertRowToMap(row, dataTypeMap.get(tableName), enumDataTypeMap.get(tableName)); + + TapDeleteRecordEvent deleteEvent = new TapDeleteRecordEvent(); + deleteEvent.init(); + deleteEvent.table(tableName); + deleteEvent.before(before); + deleteEvent.setReferenceTime(event.getHeader().getTimestamp()); + tapEvents.add(deleteEvent); + } + + return tapEvents; + } + + /** + * 将行数据数组转换为 Map + */ + private Map convertRowToMap(Serializable[] row, LinkedHashMap dataTypes, Map enumMap) { + Map result = new LinkedHashMap<>(); + + if (row == null || EmptyKit.isEmpty(dataTypes)) { + return result; + } + + // 获取字段名列表(按顺序) + List fieldNames = new ArrayList<>(dataTypes.keySet()); + + // 将数组值映射到字段名 + for (int i = 0; i < row.length && i < fieldNames.size(); i++) { + String fieldName = fieldNames.get(i); + Object value = row[i]; + result.put(fieldName, filterValue(value, dataTypes.get(fieldName), enumMap.get(fieldName))); + } + + return result; + } + + private Object filterValue(Object value, String dataType, Object[] enumValues) { + if (value == null) { + return null; + } + switch (dataType) { + case "time": + case "date": { + if (value instanceof Long) { + return Instant.ofEpochSecond(((Long) value) / 1000000, (((Long) value) % 1000000) * 1000); + } + } + case "datetime": { + if (value instanceof Long) { + return Instant.ofEpochSecond(((Long) value) / 1000000 - mysqlConfig.getZoneOffsetHour() * 60 * 60, ((Long) value % 1000000) * 1000); + } + } + case "timestamp": { + if (value instanceof Long) { + return Instant.ofEpochSecond(((Long) value) / 1000000 + timeZone.getRawOffset() / 1000, ((Long) value % 1000000) * 1000).atZone(ZoneOffset.UTC); + } + } + case "bit": + return ((BitSet) value).get(0); + case "binary": + case "varbinary": + return String.valueOf(value).getBytes(); + case "json": + return MySQLJsonParser.parseMySQLJsonBinary((byte[]) value); + case "tinytext": + case "mediumtext": + case "text": + case "longtext": + return new String((byte[]) value); + case "enum": + return enumValues[(int) value - 1]; + } + return value; + } + + /** + * 获取表名 + */ + private String getTableName(long tableId) { + return tableMapEventByTableId.get(tableId).getTable(); + } + + /** + * 检查表是否在监听列表中 + */ + private boolean isTableInList(String tableName) { + if (tableList == null || tableList.isEmpty()) { + return true; // 如果没有指定表列表,则监听所有表 + } + + // 支持 database.table 格式 + for (String table : tableList) { + if (tableName.equals(table) || tableName.endsWith("." + table)) { + return true; + } + } + + return false; + } + + /** + * 提取 binlog 位置信息 + */ + private MysqlBinlogPosition extractBinlogPosition(Event event, String fileName) { + EventHeaderV4 header = event.getHeader(); + long position = header.getNextPosition(); + return new MysqlBinlogPosition(fileName, position); + } + + public int randomServerId() { + int lowestServerId = 5400; + int highestServerId = Integer.MAX_VALUE; + return lowestServerId + new Random().nextInt(highestServerId - lowestServerId); + } + + static class ScanEvent { + + private final Event event; + private final String fileName; + + public ScanEvent(Event event, String fileName) { + this.event = event; + this.fileName = fileName; + } + + public Event getEvent() { + return event; + } + + public String getFileName() { + return fileName; + } + } + + static class OffsetEvent { + + private List tapEvents; + private MysqlBinlogPosition mysqlBinlogPosition; + + public OffsetEvent() { + } + + public OffsetEvent(List tapEvents, MysqlBinlogPosition mysqlBinlogPosition) { + this.tapEvents = tapEvents; + this.mysqlBinlogPosition = mysqlBinlogPosition; + } + + public List getTapEvents() { + return tapEvents; + } + + public MysqlBinlogPosition getMysqlBinlogPosition() { + return mysqlBinlogPosition; + } + + public void setTapEvent(List tapEvents) { + this.tapEvents = tapEvents; + } + + public void setMysqlBinlogPosition(MysqlBinlogPosition mysqlBinlogPosition) { + this.mysqlBinlogPosition = mysqlBinlogPosition; + } + } +} diff --git a/connectors-common/mysql-core/src/main/java/io/tapdata/connector/mysql/accept/MysqlAbstractAcceptor.java b/connectors-common/mysql-core/src/main/java/io/tapdata/connector/mysql/accept/MysqlAbstractAcceptor.java new file mode 100644 index 000000000..f79d7e594 --- /dev/null +++ b/connectors-common/mysql-core/src/main/java/io/tapdata/connector/mysql/accept/MysqlAbstractAcceptor.java @@ -0,0 +1,27 @@ +package io.tapdata.connector.mysql.accept; + +import io.tapdata.cdc.Acceptor; +import io.tapdata.connector.mysql.entity.MysqlStreamEvent; +import io.tapdata.pdk.apis.consumer.TapStreamReadConsumer; + +/** + * @author Gavin'Xiao + * @author Gavin'Xiao + * @version v1.0 2025/12/16 09:43 Create + * @description + */ +public abstract class MysqlAbstractAcceptor> implements Acceptor { + protected Object offset; + + public void updateOffset(Object offset) { + this.offset = offset; + } + + public Object getOffset() { + return offset; + } + + public void complete() { + //do nothing + } +} diff --git a/connectors-common/mysql-core/src/main/java/io/tapdata/connector/mysql/accept/MysqlBatchAcceptor.java b/connectors-common/mysql-core/src/main/java/io/tapdata/connector/mysql/accept/MysqlBatchAcceptor.java new file mode 100644 index 000000000..f78d95180 --- /dev/null +++ b/connectors-common/mysql-core/src/main/java/io/tapdata/connector/mysql/accept/MysqlBatchAcceptor.java @@ -0,0 +1,83 @@ +package io.tapdata.connector.mysql.accept; + +import io.tapdata.connector.mysql.entity.MysqlStreamEvent; +import io.tapdata.entity.event.TapEvent; +import io.tapdata.pdk.apis.consumer.StreamReadConsumer; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +/** + * @author Gavin'Xiao + * @author Gavin'Xiao + * @version v1.0 2025/12/16 09:21 Create + * @description + */ +public class MysqlBatchAcceptor extends MysqlAbstractAcceptor { + StreamReadConsumer consumer; + int batchSize; + long batchSizeTimeout; + List events = new ArrayList<>(); + + @Override + public void accept(MysqlStreamEvent e) { + if (null == e) { + return; + } + Optional.ofNullable(e.getMysqlStreamOffset()).ifPresent(this::updateOffset); + events.add(e.getTapEvent()); + } + + @Override + public void complete() { + if (events.isEmpty()) { + return; + } + consumer.accept(events, getOffset()); + events = new ArrayList<>(); + } + + @Override + public void accept(List e, Object offset) { + consumer.accept(e, offset); + } + + @Override + public MysqlBatchAcceptor setConsumer(StreamReadConsumer consumer) { + this.consumer = consumer; + return this; + } + + @Override + public MysqlBatchAcceptor setBatchSize(int size) { + this.batchSize = size; + return this; + } + + @Override + public MysqlBatchAcceptor setBatchSizeTimeout(long ms) { + this.batchSizeTimeout = ms; + return this; + } + + @Override + public void streamReadStarted() { + this.consumer.streamReadStarted(); + } + + @Override + public void streamReadEnded() { + this.consumer.streamReadEnded(); + } + + @Override + public StreamReadConsumer getConsumer() { + return this.consumer; + } + + @Override + public int getBatchSize() { + return this.batchSize; + } +} diff --git a/connectors-common/mysql-core/src/main/java/io/tapdata/connector/mysql/accept/MysqlOneByOneAcceptor.java b/connectors-common/mysql-core/src/main/java/io/tapdata/connector/mysql/accept/MysqlOneByOneAcceptor.java new file mode 100644 index 000000000..8e653b33c --- /dev/null +++ b/connectors-common/mysql-core/src/main/java/io/tapdata/connector/mysql/accept/MysqlOneByOneAcceptor.java @@ -0,0 +1,63 @@ +package io.tapdata.connector.mysql.accept; + +import io.tapdata.connector.mysql.entity.MysqlStreamEvent; +import io.tapdata.entity.event.TapEvent; +import io.tapdata.pdk.apis.consumer.StreamReadOneByOneConsumer; + +import java.util.List; + +/** + * @author Gavin'Xiao + * @author Gavin'Xiao + * @version v1.0 2025/12/16 09:23 Create + * @description + */ +public class MysqlOneByOneAcceptor extends MysqlAbstractAcceptor { + StreamReadOneByOneConsumer consumer; + + @Override + public void accept(MysqlStreamEvent e) { + consumer.accept(e.getTapEvent(), e.getMysqlStreamOffset()); + } + + @Override + public void accept(List e, Object offset) { + consumer.accept(e, offset); + } + + @Override + public MysqlOneByOneAcceptor setConsumer(StreamReadOneByOneConsumer consumer) { + this.consumer = consumer; + return this; + } + + @Override + public MysqlOneByOneAcceptor setBatchSize(int size) { + return this; + } + + @Override + public MysqlOneByOneAcceptor setBatchSizeTimeout(long ms) { + return this; + } + + @Override + public void streamReadStarted() { + this.consumer.streamReadStarted(); + } + + @Override + public void streamReadEnded() { + this.consumer.streamReadEnded(); + } + + @Override + public int getBatchSize() { + return this.consumer.getBatchSize(); + } + + @Override + public StreamReadOneByOneConsumer getConsumer() { + return this.consumer; + } +} diff --git a/connectors-common/mysql-core/src/main/java/io/tapdata/connector/mysql/config/MysqlConfig.java b/connectors-common/mysql-core/src/main/java/io/tapdata/connector/mysql/config/MysqlConfig.java index 814c86c0a..87fe759fe 100644 --- a/connectors-common/mysql-core/src/main/java/io/tapdata/connector/mysql/config/MysqlConfig.java +++ b/connectors-common/mysql-core/src/main/java/io/tapdata/connector/mysql/config/MysqlConfig.java @@ -19,7 +19,7 @@ public MysqlConfig() { setMaxIndexNameLength(64); } - private static final Map DEFAULT_PROPERTIES = new HashMap() {{ + private static final Map DEFAULT_PROPERTIES = new HashMap() {{ put("rewriteBatchedStatements", "true"); put("useCursorFetch", "true"); put("useSSL", Boolean.FALSE.toString()); @@ -45,11 +45,11 @@ public MysqlConfig load(Map map) { config.setDeploymentMode(deploymentMode); } if (DeployModeEnum.fromString(deploymentMode) == DeployModeEnum.MASTER_SLAVE) { - ArrayList> masterSlaveAddress = config.getMasterSlaveAddress(); + ArrayList> masterSlaveAddress = config.getMasterSlaveAddress(); if (EmptyKit.isEmpty(masterSlaveAddress)) { throw new RuntimeException("host cannot be empty"); } - for (LinkedHashMap hostPort : masterSlaveAddress) { + for (LinkedHashMap hostPort : masterSlaveAddress) { if (EmptyKit.isEmpty(hostPort)) { continue; } else { @@ -76,7 +76,7 @@ public String getDatabaseUrl() { } StringBuilder sbURL = new StringBuilder("jdbc:").append(getDbType()).append("://").append(getHost()).append(":").append(getPort()).append("/").append(URLEncoder.encode(getDatabase())); - Map properties = new HashMap<>(); + Map properties = new HashMap<>(); if (StringUtils.isNotBlank(additionalString)) { String[] additionalStringSplit = additionalString.split("&"); for (String s : additionalStringSplit) { @@ -158,10 +158,11 @@ public void generateSSlFile() throws IOException, InterruptedException { } private String deploymentMode; - private ArrayList> masterSlaveAddress; - private ArrayList> availableMasterSlaveAddress; - private LinkedHashMap masterNode; + private ArrayList> masterSlaveAddress; + private ArrayList> availableMasterSlaveAddress; + private LinkedHashMap masterNode; private Integer maximumQueueSize = 800; + private Boolean highPerformance = false; public String getDeploymentMode() { return deploymentMode; @@ -171,27 +172,27 @@ public void setDeploymentMode(String deploymentMode) { this.deploymentMode = deploymentMode; } - public ArrayList> getMasterSlaveAddress() { + public ArrayList> getMasterSlaveAddress() { return masterSlaveAddress; } - public void setMasterSlaveAddress(ArrayList> masterSlaveAddress) { + public void setMasterSlaveAddress(ArrayList> masterSlaveAddress) { this.masterSlaveAddress = masterSlaveAddress; } - public ArrayList> getAvailableMasterSlaveAddress() { + public ArrayList> getAvailableMasterSlaveAddress() { return availableMasterSlaveAddress; } - public void setAvailableMasterSlaveAddress(ArrayList> availableMasterSlaveAddress) { + public void setAvailableMasterSlaveAddress(ArrayList> availableMasterSlaveAddress) { this.availableMasterSlaveAddress = availableMasterSlaveAddress; } - public LinkedHashMap getMasterNode() { + public LinkedHashMap getMasterNode() { return masterNode; } - public void setMasterNode(LinkedHashMap masterNode) { + public void setMasterNode(LinkedHashMap masterNode) { this.masterNode = masterNode; } @@ -203,4 +204,12 @@ public void setMaximumQueueSize(Integer maximumQueueSize) { this.maximumQueueSize = maximumQueueSize; } + public Boolean getHighPerformance() { + return highPerformance; + } + + public void setHighPerformance(Boolean highPerformance) { + this.highPerformance = highPerformance; + } + } diff --git a/connectors-common/mysql-core/src/main/java/io/tapdata/connector/mysql/ddl/sqlmaker/MysqlDDLSqlGenerator.java b/connectors-common/mysql-core/src/main/java/io/tapdata/connector/mysql/ddl/sqlmaker/MysqlDDLSqlGenerator.java index 18b3d2a0d..8f33bfd73 100644 --- a/connectors-common/mysql-core/src/main/java/io/tapdata/connector/mysql/ddl/sqlmaker/MysqlDDLSqlGenerator.java +++ b/connectors-common/mysql-core/src/main/java/io/tapdata/connector/mysql/ddl/sqlmaker/MysqlDDLSqlGenerator.java @@ -2,6 +2,7 @@ import io.tapdata.common.CommonDbConfig; import io.tapdata.common.ddl.DDLSqlGenerator; +import io.tapdata.connector.mysql.bean.MysqlColumn; import io.tapdata.connector.mysql.util.MysqlUtil; import io.tapdata.entity.event.ddl.entity.ValueChange; import io.tapdata.entity.event.ddl.table.TapAlterFieldAttributesEvent; @@ -12,8 +13,10 @@ import io.tapdata.entity.schema.TapField; import io.tapdata.entity.schema.TapTable; import io.tapdata.entity.utils.cache.KVReadOnlyMap; +import io.tapdata.kit.EmptyKit; import org.apache.commons.lang3.StringUtils; +import java.math.BigDecimal; import java.util.*; /** @@ -48,6 +51,18 @@ public List addColumn(CommonDbConfig config, TapNewFieldEvent tapNewFiel throw new RuntimeException("Append add column ddl sql failed, table name is blank"); } for (TapField newField : newFields) { + if (EmptyKit.isNotNull(newField.getDefaultValue())) { + String columnDefaultValue = newField.getDefaultValue().toString(); + Object defaultValueObj = columnDefaultValue; + String tapDefaultFunction = null; + if (EmptyKit.isNotNull(columnDefaultValue)) { + tapDefaultFunction = MysqlColumn.MysqlDefaultFunction.parseFunction(columnDefaultValue);; + if (columnDefaultValue.matches("-?\\d+(\\.\\d+)?")) { + defaultValueObj = new BigDecimal(columnDefaultValue); + } + } + newField.defaultValue(defaultValueObj).defaultFunction(tapDefaultFunction); + } StringBuilder sql = new StringBuilder(String.format(ALTER_TABLE_PREFIX, config.getDatabase(), tableId)).append(" add"); String fieldName = newField.getName(); if (StringUtils.isNotBlank(fieldName)) { @@ -74,9 +89,15 @@ public List addColumn(CommonDbConfig config, TapNewFieldEvent tapNewFiel sql.append(" not null"); } } - Object defaultValue = newField.getDefaultValue(); - if (null != defaultValue) { - sql.append(" default '").append(defaultValue).append("'"); + if (EmptyKit.isNotNull(newField.getDefaultValue())) { + sql.append(" default "); + if (EmptyKit.isNotNull(newField.getDefaultFunction())) { + sql.append(newField.getDefaultValue()); + } else if (newField.getDefaultValue() instanceof Number || Boolean.TRUE.equals(newField.getAutoInc())) { + sql.append(newField.getDefaultValue()); + } else { + sql.append("'").append(newField.getDefaultValue()).append("'"); + } } String comment = newField.getComment(); if (StringUtils.isNotBlank(comment)) { diff --git a/connectors-common/mysql-core/src/main/java/io/tapdata/connector/mysql/dml/MysqlWriteRecorder.java b/connectors-common/mysql-core/src/main/java/io/tapdata/connector/mysql/dml/MysqlWriteRecorder.java index f46a1723a..fb7ddbac8 100644 --- a/connectors-common/mysql-core/src/main/java/io/tapdata/connector/mysql/dml/MysqlWriteRecorder.java +++ b/connectors-common/mysql-core/src/main/java/io/tapdata/connector/mysql/dml/MysqlWriteRecorder.java @@ -155,4 +155,16 @@ private String castFloatAndQuoteEscape(String value) { } } + protected void setBeforeValue(boolean containsNull, Map before, int pos) throws SQLException { + if (!containsNull) { + for (String key : before.keySet()) { + setPrepareStatement(pos++, before, key); + } + } else { + for (String key : before.keySet()) { + setPrepareStatement(pos++, before, key); + } + } + } + } diff --git a/connectors-common/mysql-core/src/main/java/io/tapdata/connector/mysql/util/MySQLJsonParser.java b/connectors-common/mysql-core/src/main/java/io/tapdata/connector/mysql/util/MySQLJsonParser.java new file mode 100644 index 000000000..0e35c965b --- /dev/null +++ b/connectors-common/mysql-core/src/main/java/io/tapdata/connector/mysql/util/MySQLJsonParser.java @@ -0,0 +1,480 @@ +package io.tapdata.connector.mysql.util; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.*; + +import java.math.BigDecimal; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.charset.StandardCharsets; +import java.util.*; + +/** + * MySQL JSON 二进制格式解析器 + * + *

参考资料: + *

    + *
  • MySQL 源码:sql/json_binary.h, json_binary.cc
  • + *
  • MySQL 文档:https://dev.mysql.com/doc/internals/en/json-binary-encoding.html
  • + *
  • 日期时间格式:https://dev.mysql.com/doc/internals/en/date-and-time-data-type-representation.html
  • + *
+ * + * @author TapData + */ +public class MySQLJsonParser { + + private static final ObjectMapper objectMapper = new ObjectMapper(); + + /** + * 解析 MySQL 的二进制 JSON 格式 + * + * @param data 二进制数据 + * @return JSON 字符串,解析失败返回 null + */ + public static String parseMySQLJsonBinary(byte[] data) { + if (data == null || data.length < 1) { + return null; + } + + try { + // 检查是否是 MariaDB 格式的 JSON 字符串(第一个字节 > 0x0F) + if ((data[0] & 0xFF) > 0x0F) { + return new String(data, StandardCharsets.UTF_8); + } + + ByteBuffer buffer = ByteBuffer.wrap(data); + buffer.order(ByteOrder.LITTLE_ENDIAN); + + // 读取类型(第一个字节) + int type = buffer.get() & 0xFF; + + JsonNode jsonNode = parseJsonValue(buffer, type); + return jsonNode != null ? jsonNode.toString() : null; + + } catch (Exception e) { + System.err.println("Failed to parse MySQL JSON binary: " + e.getMessage()); + e.printStackTrace(); + return null; + } + } + + private static JsonNode parseJsonValue(ByteBuffer buffer, int type) { + switch (type) { + case 0x00: // JSONB_TYPE_SMALL_OBJECT + case 0x01: // JSONB_TYPE_LARGE_OBJECT + return parseJsonObject(buffer, type); + + case 0x02: // JSONB_TYPE_SMALL_ARRAY + case 0x03: // JSONB_TYPE_LARGE_ARRAY + return parseJsonArray(buffer, type); + + case 0x04: // JSONB_TYPE_LITERAL + return parseJsonLiteral(buffer); + + case 0x05: // JSONB_TYPE_INT16 + return parseJsonInt16(buffer); + + case 0x06: // JSONB_TYPE_UINT16 + return parseJsonUInt16(buffer); + + case 0x07: // JSONB_TYPE_INT32 + return parseJsonInt32(buffer); + + case 0x08: // JSONB_TYPE_UINT32 + return parseJsonUInt32(buffer); + + case 0x09: // JSONB_TYPE_INT64 + return parseJsonInt64(buffer); + + case 0x0A: // JSONB_TYPE_UINT64 + return parseJsonUInt64(buffer); + + case 0x0B: // JSONB_TYPE_DOUBLE + return parseJsonDouble(buffer); + + case 0x0C: // JSONB_TYPE_STRING + return parseJsonString(buffer); + + case 0x0D: // JSONB_TYPE_OPAQUE + return parseJsonOpaque(buffer); + + default: + throw new IllegalArgumentException("Unknown JSON type: " + type); + } + } + + private static JsonNode parseJsonObject(ByteBuffer buffer, int type) { + boolean isSmall = (type == 0x00); // SMALL_OBJECT + int startPosition = buffer.position(); + + // 读取元素数量和总字节数 + int elementCount = readOffsetOrSize(buffer, isSmall); + int bytes = readOffsetOrSize(buffer, isSmall); + + if (elementCount == 0) { + return objectMapper.createObjectNode(); + } + + Map map = new LinkedHashMap<>(); + + // 读取 key entries(offset + length) + int[] keyOffsets = new int[elementCount]; + int[] keyLengths = new int[elementCount]; + for (int i = 0; i < elementCount; i++) { + keyOffsets[i] = readOffsetOrSize(buffer, isSmall); + keyLengths[i] = buffer.getShort() & 0xFFFF; // key length 总是 2 字节 + } + + // 读取 value entries(type + offset) + int[] valueTypes = new int[elementCount]; + int[] valueOffsets = new int[elementCount]; + for (int i = 0; i < elementCount; i++) { + valueTypes[i] = buffer.get() & 0xFF; + valueOffsets[i] = readOffsetOrSize(buffer, isSmall); + } + + // 读取 keys 和 values + for (int i = 0; i < elementCount; i++) { + // 读取 key + buffer.position(startPosition + keyOffsets[i]); + byte[] keyBytes = new byte[keyLengths[i]]; + buffer.get(keyBytes); + String key = new String(keyBytes, StandardCharsets.UTF_8); + + // 读取 value + buffer.position(startPosition + valueOffsets[i]); + JsonNode value = parseJsonValue(buffer, valueTypes[i]); + map.put(key, value); + } + + // 移动到对象结束位置 + buffer.position(startPosition + bytes); + return objectMapper.valueToTree(map); + } + + private static JsonNode parseJsonArray(ByteBuffer buffer, int type) { + boolean isSmall = (type == 0x02); // SMALL_ARRAY + int startPosition = buffer.position(); + + // 读取元素数量和总字节数 + int elementCount = readOffsetOrSize(buffer, isSmall); + int bytes = readOffsetOrSize(buffer, isSmall); + + if (elementCount == 0) { + return objectMapper.createArrayNode(); + } + + ArrayNode arrayNode = objectMapper.createArrayNode(); + + // 读取元素的 type 和 offset + int[] elementTypes = new int[elementCount]; + int[] elementOffsets = new int[elementCount]; + for (int i = 0; i < elementCount; i++) { + elementTypes[i] = buffer.get() & 0xFF; + elementOffsets[i] = readOffsetOrSize(buffer, isSmall); + } + + // 读取每个元素的值 + for (int i = 0; i < elementCount; i++) { + buffer.position(startPosition + elementOffsets[i]); + JsonNode value = parseJsonValue(buffer, elementTypes[i]); + arrayNode.add(value); + } + + // 移动到数组结束位置 + buffer.position(startPosition + bytes); + return arrayNode; + } + + private static JsonNode parseJsonLiteral(ByteBuffer buffer) { + int literalType = buffer.get() & 0xFF; + switch (literalType) { + case 0x00: // JSONB_LITERAL_NULL + return NullNode.getInstance(); + case 0x01: // JSONB_LITERAL_TRUE + return BooleanNode.TRUE; + case 0x02: // JSONB_LITERAL_FALSE + return BooleanNode.FALSE; + default: + throw new IllegalArgumentException("Unknown literal type: " + literalType); + } + } + + private static JsonNode parseJsonInt16(ByteBuffer buffer) { + return new IntNode(buffer.getShort()); + } + + private static JsonNode parseJsonUInt16(ByteBuffer buffer) { + return new IntNode(buffer.getShort() & 0xFFFF); + } + + private static JsonNode parseJsonInt32(ByteBuffer buffer) { + return new IntNode(buffer.getInt()); + } + + private static JsonNode parseJsonUInt32(ByteBuffer buffer) { + return new LongNode(buffer.getInt() & 0xFFFFFFFFL); + } + + private static JsonNode parseJsonInt64(ByteBuffer buffer) { + return new LongNode(buffer.getLong()); + } + + private static JsonNode parseJsonUInt64(ByteBuffer buffer) { + // 注意:UInt64 可能溢出,这里用 BigInteger 更安全 + long value = buffer.getLong(); + return new LongNode(value); + } + + private static JsonNode parseJsonDouble(ByteBuffer buffer) { + return new DoubleNode(buffer.getDouble()); + } + + private static JsonNode parseJsonString(ByteBuffer buffer) { + String str = readLengthPrefixedString(buffer); + return new TextNode(str); + } + + /** + * 解析 Opaque 类型(包括 DATE, TIME, DATETIME, DECIMAL 等) + * 参考:https://github.com/mysql/mysql-server/blob/5.7/sql/json_binary.cc + */ + private static JsonNode parseJsonOpaque(ByteBuffer buffer) { + int opaqueType = buffer.get() & 0xFF; + int length = (int) readVariableLength(buffer); + + byte[] opaqueData = new byte[length]; + buffer.get(opaqueData); + + // 根据 MySQL ColumnType 处理不同类型 + // 参考:com.github.shyiko.mysql.binlog.event.deserialization.ColumnType + switch (opaqueType) { + case 0x0A: // DATE (ColumnType.DATE) + return parseOpaqueDate(opaqueData); + case 0x0B: // TIME (ColumnType.TIME) + case 0x13: // TIME_V2 (ColumnType.TIME_V2) + return parseOpaqueTime(opaqueData); + case 0x0C: // DATETIME (ColumnType.DATETIME) + case 0x12: // DATETIME_V2 (ColumnType.DATETIME_V2) + case 0x07: // TIMESTAMP (ColumnType.TIMESTAMP) + case 0x11: // TIMESTAMP_V2 (ColumnType.TIMESTAMP_V2) + return parseOpaqueDatetime(opaqueData); + case 0xF6: // NEWDECIMAL (ColumnType.NEWDECIMAL) + case 0x00: // DECIMAL (ColumnType.DECIMAL) + return parseOpaqueDecimal(opaqueData); + default: + // 其他类型返回 Base64 编码的字符串 + return new TextNode(Base64.getEncoder().encodeToString(opaqueData)); + } + } + + /** + * 解析 DATE 类型 + * 格式:8 字节,包含年月日和微秒 + * 参考:https://dev.mysql.com/doc/internals/en/date-and-time-data-type-representation.html + */ + private static JsonNode parseOpaqueDate(byte[] data) { + if (data.length < 8) { + return new TextNode("0000-00-00"); + } + + ByteBuffer buf = ByteBuffer.wrap(data).order(ByteOrder.LITTLE_ENDIAN); + long raw = buf.getLong(); + long value = raw >> 24; // 去掉低 24 位(微秒部分) + + // 解析日期部分(40 位) + int yearMonth = (int) ((value >> 22) & 0x1FFFF); // 17 bits + int year = yearMonth / 13; + int month = yearMonth % 13; + int day = (int) ((value >> 17) & 0x1F); // 5 bits + + return new TextNode(String.format("%04d-%02d-%02d", year, month, day)); + } + + /** + * 解析 TIME 类型 + * 格式:8 字节,包含时分秒和微秒 + */ + private static JsonNode parseOpaqueTime(byte[] data) { + if (data.length < 8) { + return new TextNode("00:00:00"); + } + + ByteBuffer buf = ByteBuffer.wrap(data).order(ByteOrder.LITTLE_ENDIAN); + long raw = buf.getLong(); + long value = raw >> 24; // 去掉低 24 位(微秒部分) + + // 检查符号位 + boolean negative = value < 0; + if (negative) { + value = -value; + } + + // 解析时间部分 + int hour = (int) ((value >> 12) & 0x3FF); // 10 bits + int minute = (int) ((value >> 6) & 0x3F); // 6 bits + int second = (int) (value & 0x3F); // 6 bits + + // 获取微秒部分 + int microseconds = (int) (raw & 0xFFFFFF); + + String timeStr = String.format("%s%02d:%02d:%02d", + negative ? "-" : "", hour, minute, second); + + if (microseconds > 0) { + timeStr += String.format(".%06d", microseconds); + } + + return new TextNode(timeStr); + } + + /** + * 解析 DATETIME/TIMESTAMP 类型 + * 格式:8 字节,包含年月日时分秒和微秒 + */ + private static JsonNode parseOpaqueDatetime(byte[] data) { + if (data.length < 8) { + return new TextNode("0000-00-00 00:00:00"); + } + + ByteBuffer buf = ByteBuffer.wrap(data).order(ByteOrder.LITTLE_ENDIAN); + long raw = buf.getLong(); + long value = raw >> 24; // 去掉低 24 位(微秒部分) + + // 解析日期时间部分(40 位) + int yearMonth = (int) ((value >> 22) & 0x1FFFF); // 17 bits + int year = yearMonth / 13; + int month = yearMonth % 13; + int day = (int) ((value >> 17) & 0x1F); // 5 bits + int hour = (int) ((value >> 12) & 0x1F); // 5 bits + int minute = (int) ((value >> 6) & 0x3F); // 6 bits + int second = (int) (value & 0x3F); // 6 bits + + // 获取微秒部分 + int microseconds = (int) (raw & 0xFFFFFF); + + String datetimeStr = String.format("%04d-%02d-%02d %02d:%02d:%02d", + year, month, day, hour, minute, second); + + if (microseconds > 0) { + datetimeStr += String.format(".%06d", microseconds); + } + + return new TextNode(datetimeStr); + } + + /** + * 解析 DECIMAL 类型 + * 格式:precision (1 byte) + scale (1 byte) + binary representation + */ + private static JsonNode parseOpaqueDecimal(byte[] data) { + if (data.length < 2) { + return new TextNode("0"); + } + + ByteBuffer buf = ByteBuffer.wrap(data).order(ByteOrder.LITTLE_ENDIAN); + int precision = buf.get() & 0xFF; + int scale = buf.get() & 0xFF; + + // 读取二进制表示 + byte[] decimalBytes = new byte[data.length - 2]; + buf.get(decimalBytes); + + try { + // 使用 MySQL 的 DECIMAL 解析逻辑 + BigDecimal decimal = parseDecimalBinary(precision, scale, decimalBytes); + return new DecimalNode(decimal); + } catch (Exception e) { + return new TextNode("0"); + } + } + + /** + * 解析 DECIMAL 的二进制表示 + * 参考:com.github.shyiko.mysql.binlog.event.deserialization.AbstractRowsEventDataDeserializer.asBigDecimal + */ + private static BigDecimal parseDecimalBinary(int precision, int scale, byte[] data) { + // MySQL DECIMAL 的二进制格式比较复杂,这里简化处理 + // 完整实现请参考 mysql-binlog-connector-java 的 AbstractRowsEventDataDeserializer.asBigDecimal + + // 简化版本:尝试将字节数组转换为数字 + boolean negative = (data[0] & 0x80) == 0; + data[0] ^= 0x80; // 翻转符号位 + + if (negative) { + for (int i = 0; i < data.length; i++) { + data[i] = (byte) ~data[i]; + } + } + + // 将字节数组转换为 BigInteger + java.math.BigInteger bigInt = new java.math.BigInteger(1, data); + if (negative) { + bigInt = bigInt.negate(); + } + + // 应用 scale + BigDecimal result = new BigDecimal(bigInt, scale); + return result; + } + + /** + * 读取偏移量或大小 + * @param buffer ByteBuffer + * @param isSmall 是否是小格式(2 字节),否则是大格式(4 字节) + * @return 偏移量或大小 + */ + private static int readOffsetOrSize(ByteBuffer buffer, boolean isSmall) { + if (isSmall) { + return buffer.getShort() & 0xFFFF; + } else { + return buffer.getInt(); + } + } + + /** + * 读取可变长度整数 + * 参考:MySQL 协议的 Length-Encoded Integer + * + * @param buffer ByteBuffer + * @return 长度值 + */ + private static long readVariableLength(ByteBuffer buffer) { + int firstByte = buffer.get() & 0xFF; + if (firstByte < 0xFB) { + // 1 字节长度 + return firstByte; + } else if (firstByte == 0xFC) { + // 2 字节长度 + return buffer.getShort() & 0xFFFF; + } else if (firstByte == 0xFD) { + // 3 字节长度(小端序) + int b1 = buffer.get() & 0xFF; + int b2 = buffer.get() & 0xFF; + int b3 = buffer.get() & 0xFF; + return b1 | (b2 << 8) | (b3 << 16); + } else if (firstByte == 0xFE) { + // 8 字节长度 + return buffer.getLong(); + } else { + throw new IllegalArgumentException("Invalid variable length prefix: 0x" + Integer.toHexString(firstByte)); + } + } + + /** + * 读取带长度前缀的字符串 + * + * @param buffer ByteBuffer + * @return 字符串 + */ + private static String readLengthPrefixedString(ByteBuffer buffer) { + long length = readVariableLength(buffer); + if (length == 0) { + return ""; + } + byte[] strBytes = new byte[(int) length]; + buffer.get(strBytes); + return new String(strBytes, StandardCharsets.UTF_8); + } +} \ No newline at end of file diff --git a/connectors-common/mysql-core/src/main/java/io/tapdata/connector/mysql/util/MysqlUtil.java b/connectors-common/mysql-core/src/main/java/io/tapdata/connector/mysql/util/MysqlUtil.java index 87e634fea..179c37267 100644 --- a/connectors-common/mysql-core/src/main/java/io/tapdata/connector/mysql/util/MysqlUtil.java +++ b/connectors-common/mysql-core/src/main/java/io/tapdata/connector/mysql/util/MysqlUtil.java @@ -26,294 +26,300 @@ **/ public class MysqlUtil extends JdbcUtil { - private static final DateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSSSSS"); - private static final DateTimeFormatter dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSSSSS"); - private final static String pdkId = "mysql"; + private static final DateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSSSSS"); + private static final DateTimeFormatter dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSSSSS"); + private final static String pdkId = "mysql"; - public static Integer getSubVersion(String version, int index) { - if (StringUtils.isBlank(version)) { - return null; - } + public static Integer getSubVersion(String version, int index) { + if (StringUtils.isBlank(version)) { + return null; + } - String[] split = version.split("\\."); - if (split.length <= 1) { - return null; - } + String[] split = version.split("\\."); + if (split.length <= 1) { + return null; + } - String str = split[index - 1]; - try { - return Integer.valueOf(str); - } catch (NumberFormatException e) { - throw new RuntimeException("Version string: " + str + ", is not a number"); - } - } + String str = split[index - 1]; + try { + return Integer.valueOf(str); + } catch (NumberFormatException e) { + throw new RuntimeException("Version string: " + str + ", is not a number"); + } + } - public static int randomServerId() { - int lowestServerId = 5400; - int highestServerId = Integer.MAX_VALUE; - return lowestServerId + new Random().nextInt(highestServerId - lowestServerId); - } + public static int randomServerId() { + int lowestServerId = 5400; + int highestServerId = Integer.MAX_VALUE; + return lowestServerId + new Random().nextInt(highestServerId - lowestServerId); + } - public static String fixDataType(String dataType, String version) { - if (StringUtils.isBlank(dataType)) { - return dataType; - } - // Fix datetime/timestamp when version<5.6 - dataType = fixDatetime(dataType, version); - // Fix json when version<5.7 - dataType = fixJson(dataType, version); - return dataType; - } + public static String fixDataType(String dataType, String version) { + if (StringUtils.isBlank(dataType)) { + return dataType; + } + // Fix datetime/timestamp when version<5.6 + dataType = fixDatetime(dataType, version); + // Fix json when version<5.7 + dataType = fixJson(dataType, version); + return dataType; + } - private static String fixJson(String dataType, String version) { - if (StringUtils.isBlank(version)) { - return dataType; - } - Integer firstVersion = getFirstVersion(version); - if (null == firstVersion) { - return dataType; - } - Integer secondVersion = getSecondVersion(version); - if (null == secondVersion) { - return dataType; - } - if (firstVersion.compareTo(5) <= 0 && secondVersion.compareTo(7) < 0) { - if (StringUtils.equalsIgnoreCase(dataType, "json")) { - dataType = "longtext"; - } - } - return dataType; - } + private static String fixJson(String dataType, String version) { + if (StringUtils.isBlank(version)) { + return dataType; + } + Integer firstVersion = getFirstVersion(version); + if (null == firstVersion) { + return dataType; + } + Integer secondVersion = getSecondVersion(version); + if (null == secondVersion) { + return dataType; + } + if (firstVersion.compareTo(5) <= 0 && secondVersion.compareTo(7) < 0) { + if (StringUtils.equalsIgnoreCase(dataType, "json")) { + dataType = "longtext"; + } + } + return dataType; + } - public static String fixDatetime(String dataType, String version) { - if (StringUtils.isBlank(version)) { - return dataType; - } - Integer firstVersion = getFirstVersion(version); - if (null == firstVersion) { - return dataType; - } - Integer secondVersion = getSecondVersion(version); - if (null == secondVersion) { - return dataType; - } - if (firstVersion.compareTo(5) <= 0 && secondVersion.compareTo(6) <= 0) { - Pattern pattern = Pattern.compile("(datetime|timestamp)\\(\\d+\\)", Pattern.CASE_INSENSITIVE); - if (pattern.matcher(dataType).matches()) { - dataType = dataType.replaceAll("\\(\\d+\\)", ""); - } - } - return dataType; - } + public static String fixDatetime(String dataType, String version) { + if (StringUtils.isBlank(version)) { + return dataType; + } + Integer firstVersion = getFirstVersion(version); + if (null == firstVersion) { + return dataType; + } + Integer secondVersion = getSecondVersion(version); + if (null == secondVersion) { + return dataType; + } + if (firstVersion.compareTo(5) <= 0 && secondVersion.compareTo(6) <= 0) { + Pattern pattern = Pattern.compile("(datetime|timestamp)\\(\\d+\\)", Pattern.CASE_INSENSITIVE); + if (pattern.matcher(dataType).matches()) { + dataType = dataType.replaceAll("\\(\\d+\\)", ""); + } + } + return dataType; + } - private static Integer getSecondVersion(String version) { - Integer secondVersion; - try { - secondVersion = getSubVersion(version, 2); - } catch (Exception e) { - throw new RuntimeException("Get second version number failed, version string: " + version + ", error: " + e.getMessage(), e); - } - return secondVersion; - } + private static Integer getSecondVersion(String version) { + Integer secondVersion; + try { + secondVersion = getSubVersion(version, 2); + } catch (Exception e) { + throw new RuntimeException("Get second version number failed, version string: " + version + ", error: " + e.getMessage(), e); + } + return secondVersion; + } - private static Integer getFirstVersion(String version) { - Integer firstVersion; - try { - firstVersion = getSubVersion(version, 1); - } catch (Exception e) { - throw new RuntimeException("Get first version number failed, version string: " + version + ", error: " + e.getMessage(), e); - } - return firstVersion; - } + private static Integer getFirstVersion(String version) { + Integer firstVersion; + try { + firstVersion = getSubVersion(version, 1); + } catch (Exception e) { + throw new RuntimeException("Get first version number failed, version string: " + version + ", error: " + e.getMessage(), e); + } + return firstVersion; + } - public static long convertTimestamp(long timestamp, TimeZone fromTimeZone, TimeZone toTimeZone) { - LocalDateTime dt = LocalDateTime.now(); - ZonedDateTime fromZonedDateTime = dt.atZone(fromTimeZone.toZoneId()); - ZonedDateTime toZonedDateTime = dt.atZone(toTimeZone.toZoneId()); - long diff = Duration.between(toZonedDateTime, fromZonedDateTime).toMillis(); - return timestamp + diff; - } + public static long convertTimestamp(long timestamp, TimeZone fromTimeZone, TimeZone toTimeZone) { + LocalDateTime dt = LocalDateTime.now(); + ZonedDateTime fromZonedDateTime = dt.atZone(fromTimeZone.toZoneId()); + ZonedDateTime toZonedDateTime = dt.atZone(toTimeZone.toZoneId()); + long diff = Duration.between(toZonedDateTime, fromZonedDateTime).toMillis(); + return timestamp + diff; + } - public static String convertTime(Object time){ - String str[] =((String)time).split(":"); - String timeTemp; - if(str.length==3){ - int hour = Math.abs(Integer.parseInt(str[0]))%24; - timeTemp = (hour < 10 ? ("0" + hour) : hour) + ":" +str[1] + ":"+str[2]; - return timeTemp; - } - return null; - } + public static String convertTime(Object time) { + String str[] = ((String) time).split(":"); + String timeTemp; + if (str.length == 3) { + int hour = Math.abs(Integer.parseInt(str[0])) % 24; + timeTemp = (hour < 10 ? ("0" + hour) : hour) + ":" + str[1] + ":" + str[2]; + return timeTemp; + } + return null; + } - public static String toHHmmss(long time) { - String timeTemp; - int hours = (int) (time % (1000 * 60 * 60 * 24) / (1000 * 60 * 60)); - int minutes = (int) (time % (1000 * 60 * 60) / (1000 * 60)); - int seconds = (int) (time % (1000 * 60) / 1000); - timeTemp = (hours < 10 ? ("0" + hours) : hours) + ":" + (minutes < 10 ? ("0" + minutes) : minutes) + ":" + (seconds < 10 ? ("0" + seconds) : seconds); - return timeTemp; - } + public static String toHHmmss(long time) { + String timeTemp; + int hours = (int) (time % (1000 * 60 * 60 * 24) / (1000 * 60 * 60)); + int minutes = (int) (time % (1000 * 60 * 60) / (1000 * 60)); + int seconds = (int) (time % (1000 * 60) / 1000); + timeTemp = (hours < 10 ? ("0" + hours) : hours) + ":" + (minutes < 10 ? ("0" + minutes) : minutes) + ":" + (seconds < 10 ? ("0" + seconds) : seconds); + return timeTemp; + } - public static String object2String(Object obj) { - String result; - if (null == obj) { - result = "null"; - } else if (obj instanceof String) { - result = "'" + ((String) obj).replaceAll("\\\\", "\\\\\\\\").replaceAll("'", "\\\\'").replaceAll("\\(", "\\\\(").replaceAll("\\)", "\\\\)") + "'"; - } else if (obj instanceof Number) { - result = obj.toString(); - } else if (obj instanceof Date) { - result = "'" + dateFormat.format(obj) + "'"; - } else if (obj instanceof Instant) { - result = "'" + LocalDateTime.ofInstant((Instant) obj, ZoneId.of("GMT")).format(dateTimeFormatter) + "'"; - } else if (obj instanceof byte[]) { - String hexString = HexConverter.convertToHexString((byte[]) obj); - return "X'" + hexString + "'"; - } else if (obj instanceof Boolean) { - if ("true".equalsIgnoreCase(obj.toString())) { - return "1"; - } - return "0"; - } else { - return "'" + obj + "'"; - } - return result; - } + public static String object2String(Object obj) { + String result; + if (null == obj) { + result = "null"; + } else if (obj instanceof String) { + result = "'" + ((String) obj).replaceAll("\\\\", "\\\\\\\\").replaceAll("'", "\\\\'").replaceAll("\\(", "\\\\(").replaceAll("\\)", "\\\\)") + "'"; + } else if (obj instanceof Number) { + result = obj.toString(); + } else if (obj instanceof Date) { + result = "'" + dateFormat.format(obj) + "'"; + } else if (obj instanceof Instant) { + result = "'" + LocalDateTime.ofInstant((Instant) obj, ZoneId.of("GMT")).format(dateTimeFormatter) + "'"; + } else if (obj instanceof byte[]) { + String hexString = HexConverter.convertToHexString((byte[]) obj); + return "X'" + hexString + "'"; + } else if (obj instanceof Boolean) { + if ("true".equalsIgnoreCase(obj.toString())) { + return "1"; + } + return "0"; + } else { + return "'" + obj + "'"; + } + return result; + } - public static void testHostPortForMasterSlave(MysqlConfig mysqlConfig) { - ArrayList> masterSlaveAddress = mysqlConfig.getMasterSlaveAddress(); - ArrayList> availableMasterSlaveAddress = Optional.ofNullable(mysqlConfig.getAvailableMasterSlaveAddress()).orElse(new ArrayList<>()); - for (LinkedHashMap hostPort : masterSlaveAddress) { + public static void testHostPortForMasterSlave(MysqlConfig mysqlConfig) { + ArrayList> masterSlaveAddress = mysqlConfig.getMasterSlaveAddress(); + ArrayList> availableMasterSlaveAddress = Optional.ofNullable(mysqlConfig.getAvailableMasterSlaveAddress()).orElse(new ArrayList<>()); + for (LinkedHashMap hostPort : masterSlaveAddress) { try { - if (EmptyKit.isEmpty(hostPort)){ - continue; - } - NetUtil.validateHostPortWithSocket(String.valueOf(hostPort.get("host")), hostPort.get("port")); - if (!availableMasterSlaveAddress.contains(hostPort)){ - availableMasterSlaveAddress.add(hostPort); - } + if (EmptyKit.isEmpty(hostPort)) { + continue; + } + NetUtil.validateHostPortWithSocket(String.valueOf(hostPort.get("host")), (Integer) (hostPort.get("port"))); + if (!availableMasterSlaveAddress.contains(hostPort)) { + availableMasterSlaveAddress.add(hostPort); + } } catch (IOException e) { - availableMasterSlaveAddress.remove(hostPort); + availableMasterSlaveAddress.remove(hostPort); } - } - mysqlConfig.setAvailableMasterSlaveAddress(availableMasterSlaveAddress); - } - public static void buildMasterNode(MysqlConfig mysqlConfig, java.util.HashMap contextMapForMasterSlave) { - if (null == mysqlConfig) return; - String deploymentMode = mysqlConfig.getDeploymentMode(); - if (DeployModeEnum.fromString(deploymentMode) == DeployModeEnum.MASTER_SLAVE) { - ArrayList> masterSlaveAddress = mysqlConfig.getMasterSlaveAddress(); - testHostPortForMasterSlave(mysqlConfig); - ArrayList> availableMasterSlaveAddress = mysqlConfig.getAvailableMasterSlaveAddress(); - if (EmptyKit.isEmpty(availableMasterSlaveAddress)){ - throw new TapPdkRetryableEx(pdkId, new RuntimeException("there is no available node")); - } - MysqlJdbcContextV2 mysqlJdbcContext; - HashSet> masterNode = new HashSet<>(availableMasterSlaveAddress); - Map masterHostPortAndStatus = null; - int count = 1; - boolean needQuerySlaveStatus = false; - if (availableMasterSlaveAddress.size() == 1) { - needQuerySlaveStatus = true; - } - while ((count < 3 && masterNode.size() != 1) || needQuerySlaveStatus) { - Iterator> iterator = masterNode.iterator(); - while (iterator.hasNext()){ - LinkedHashMap address = iterator.next(); - String host = String.valueOf(address.get("host")); - Integer port = address.get("port"); - mysqlConfig.setHost(host); - mysqlConfig.setPort(port); + } + mysqlConfig.setAvailableMasterSlaveAddress(availableMasterSlaveAddress); + } + + public static void buildMasterNode(MysqlConfig mysqlConfig, java.util.HashMap contextMapForMasterSlave) { + if (null == mysqlConfig) return; + String deploymentMode = mysqlConfig.getDeploymentMode(); + if (DeployModeEnum.fromString(deploymentMode) == DeployModeEnum.MASTER_SLAVE) { + LinkedHashMap primary = new LinkedHashMap<>(); + testHostPortForMasterSlave(mysqlConfig); + ArrayList> availableMasterSlaveAddress = mysqlConfig.getAvailableMasterSlaveAddress(); + if (EmptyKit.isEmpty(availableMasterSlaveAddress)) { + throw new TapPdkRetryableEx(pdkId, new RuntimeException("there is no available node")); + } + MysqlJdbcContextV2 mysqlJdbcContext; + HashSet> masterNode = new HashSet<>(availableMasterSlaveAddress); + Map masterHostPortAndStatus; + int count = 1; + boolean needQuerySlaveStatus = availableMasterSlaveAddress.size() == 1; + while ((count < 3 && masterNode.size() != 1) || needQuerySlaveStatus) { + Iterator> iterator = masterNode.iterator(); + while (iterator.hasNext()) { + LinkedHashMap address = iterator.next(); + String host = String.valueOf(address.get("host")); + Integer port = (Integer) (address.get("port")); + mysqlConfig.setHost(host); + mysqlConfig.setPort(port); try { - mysqlJdbcContext = contextMapForMasterSlave.get(host+":"+port); + mysqlJdbcContext = contextMapForMasterSlave.get(host + ":" + port); masterHostPortAndStatus = mysqlJdbcContext.querySlaveStatus(); - } catch (Throwable e) { + if (mysqlJdbcContext.queryIsPrimary()) { + primary.put("host", host); + primary.put("port", port); + } + } catch (Throwable e) { throw new RuntimeException(e); } if (EmptyKit.isEmpty(masterHostPortAndStatus)) { - continue; - } - String slaveIoRunning = (String) masterHostPortAndStatus.get("slaveIoRunning"); - String slaveSqlRunning = (String) masterHostPortAndStatus.get("slaveSqlRunning"); - if ("Yes".equalsIgnoreCase(slaveIoRunning) || "Yes".equalsIgnoreCase(slaveSqlRunning)) { - iterator.remove(); - } - } - count++; - needQuerySlaveStatus = false; - } - if (masterNode.size() < 1) { - throw new TapPdkRetryableEx(pdkId, new RuntimeException("master node is not assigned, please make sure host port is valid and slave status is right")); - } else if (masterNode.size() > 1) { - throw new TapPdkRetryableEx(pdkId, new RuntimeException("please make sure there is one master node at most")); - } else { - LinkedHashMap master = masterNode.stream().findFirst().get(); - mysqlConfig.setHost(String.valueOf(master.get("host"))); - mysqlConfig.setPort(master.get("port")); - mysqlConfig.setMasterNode(master); - } - } - } - public static ArrayList> compareMasterSlaveCurrentTime(MysqlConfig mysqlConfig, java.util.HashMap contextMapForMasterSlave){ - if (null == mysqlConfig) return null; - String deploymentMode = mysqlConfig.getDeploymentMode(); - if (DeployModeEnum.fromString(deploymentMode) == DeployModeEnum.MASTER_SLAVE) { - ArrayList> availableMasterSlaveAddress = mysqlConfig.getAvailableMasterSlaveAddress(); - if (EmptyKit.isEmpty(availableMasterSlaveAddress)) return null; - MysqlJdbcContextV2 mysqlJdbcContext; - ArrayList> timeList = new ArrayList(); - long start = System.currentTimeMillis(); - for (LinkedHashMap address : availableMasterSlaveAddress) { - String host = String.valueOf(address.get("host")); - Integer port = address.get("port"); - mysqlJdbcContext = contextMapForMasterSlave.get(host+":"+port); + continue; + } + String slaveIoRunning = (String) masterHostPortAndStatus.get("slaveIoRunning"); + String slaveSqlRunning = (String) masterHostPortAndStatus.get("slaveSqlRunning"); + if ("Yes".equalsIgnoreCase(slaveIoRunning) || "Yes".equalsIgnoreCase(slaveSqlRunning)) { + iterator.remove(); + } + } + count++; + needQuerySlaveStatus = false; + } + if (masterNode.isEmpty()) { + throw new TapPdkRetryableEx(pdkId, new RuntimeException("master node is not assigned, please make sure host port is valid and slave status is right")); + } else if (masterNode.size() > 1 && !primary.isEmpty()) { + mysqlConfig.setHost(String.valueOf(primary.get("host"))); + mysqlConfig.setPort((Integer) primary.get("port")); + mysqlConfig.setMasterNode(primary); + } else { + LinkedHashMap master = masterNode.stream().findFirst().get(); + mysqlConfig.setHost(String.valueOf(master.get("host"))); + mysqlConfig.setPort((Integer) master.get("port")); + mysqlConfig.setMasterNode(master); + } + } + } + + public static ArrayList> compareMasterSlaveCurrentTime(MysqlConfig mysqlConfig, java.util.HashMap contextMapForMasterSlave) { + if (null == mysqlConfig) return null; + String deploymentMode = mysqlConfig.getDeploymentMode(); + if (DeployModeEnum.fromString(deploymentMode) == DeployModeEnum.MASTER_SLAVE) { + ArrayList> availableMasterSlaveAddress = mysqlConfig.getAvailableMasterSlaveAddress(); + if (EmptyKit.isEmpty(availableMasterSlaveAddress)) return null; + MysqlJdbcContextV2 mysqlJdbcContext; + ArrayList> timeList = new ArrayList(); + long start = System.currentTimeMillis(); + for (LinkedHashMap address : availableMasterSlaveAddress) { + String host = String.valueOf(address.get("host")); + int port = (Integer) address.get("port"); + mysqlJdbcContext = contextMapForMasterSlave.get(host + ":" + port); try { - Timestamp timestamp = mysqlJdbcContext.queryCurrentTime(); - long end = System.currentTimeMillis(); - long interval = end - start; - long time = timestamp.getTime() - interval; - Map hostPortAndTime = new HashMap<>(); - hostPortAndTime.put("hostPort", host+":"+port); - hostPortAndTime.put("time", time); - timeList.add(hostPortAndTime); - } catch (SQLException e) { + Timestamp timestamp = mysqlJdbcContext.queryCurrentTime(); + long end = System.currentTimeMillis(); + long interval = end - start; + long time = timestamp.getTime() - interval; + Map hostPortAndTime = new HashMap<>(); + hostPortAndTime.put("hostPort", host + ":" + port); + hostPortAndTime.put("time", time); + timeList.add(hostPortAndTime); + } catch (SQLException e) { throw new RuntimeException(e); } } - ArrayList> inconsistent = new ArrayList<>(); - for (int i=0;i hostPortAndTime1 = timeList.get(i); - Map hostPortAndTime2 = timeList.get(j); - Object time1 = hostPortAndTime1.get("time"); - Object time2 = hostPortAndTime2.get("time"); - long abs = Math.abs((long) time1 - (long) time2); - if (abs > 1000){ - inconsistent.add(hostPortAndTime1); - inconsistent.add(hostPortAndTime2); - return inconsistent; - } - } - } - } - return null; - } - public static java.util.HashMap buildContextMapForMasterSlave(MysqlConfig mysqlConfig){ - if (null == mysqlConfig) return null; - java.util.HashMap contextMap = new java.util.HashMap<>(); - String deploymentMode = mysqlConfig.getDeploymentMode(); - if (DeployModeEnum.fromString(deploymentMode) == DeployModeEnum.MASTER_SLAVE) { - testHostPortForMasterSlave(mysqlConfig); - ArrayList> availableMasterSlaveAddress = mysqlConfig.getAvailableMasterSlaveAddress(); - if (EmptyKit.isEmpty(availableMasterSlaveAddress)) return contextMap; - for (LinkedHashMap address : availableMasterSlaveAddress) { - String host = String.valueOf(address.get("host")); - Integer port = address.get("port"); - mysqlConfig.setHost(host); - mysqlConfig.setPort(port); - contextMap.putIfAbsent(host+":"+port, new MysqlJdbcContextV2(mysqlConfig)); - } - } - return contextMap; - } + ArrayList> inconsistent = new ArrayList<>(); + for (int i = 0; i < timeList.size(); i++) { + for (int j = i; j < timeList.size(); j++) { + Map hostPortAndTime1 = timeList.get(i); + Map hostPortAndTime2 = timeList.get(j); + Object time1 = hostPortAndTime1.get("time"); + Object time2 = hostPortAndTime2.get("time"); + long abs = Math.abs((long) time1 - (long) time2); + if (abs > 1000) { + inconsistent.add(hostPortAndTime1); + inconsistent.add(hostPortAndTime2); + return inconsistent; + } + } + } + } + return null; + } + + public static java.util.HashMap buildContextMapForMasterSlave(MysqlConfig mysqlConfig) { + if (null == mysqlConfig) return null; + java.util.HashMap contextMap = new java.util.HashMap<>(); + String deploymentMode = mysqlConfig.getDeploymentMode(); + if (DeployModeEnum.fromString(deploymentMode) == DeployModeEnum.MASTER_SLAVE) { + testHostPortForMasterSlave(mysqlConfig); + ArrayList> availableMasterSlaveAddress = mysqlConfig.getAvailableMasterSlaveAddress(); + if (EmptyKit.isEmpty(availableMasterSlaveAddress)) return contextMap; + for (LinkedHashMap address : availableMasterSlaveAddress) { + String host = String.valueOf(address.get("host")); + int port = (Integer) address.get("port"); + mysqlConfig.setHost(host); + mysqlConfig.setPort(port); + contextMap.putIfAbsent(host + ":" + port, new MysqlJdbcContextV2(mysqlConfig)); + } + } + return contextMap; + } } diff --git a/connectors-common/mysql-core/src/test/java/io/tapdata/connector/mysql/util/MySQLJsonParserTest.java b/connectors-common/mysql-core/src/test/java/io/tapdata/connector/mysql/util/MySQLJsonParserTest.java new file mode 100644 index 000000000..b1f871f0a --- /dev/null +++ b/connectors-common/mysql-core/src/test/java/io/tapdata/connector/mysql/util/MySQLJsonParserTest.java @@ -0,0 +1,158 @@ +package io.tapdata.connector.mysql.util; + +import org.junit.jupiter.api.Test; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * MySQL JSON 二进制格式解析器测试 + * + * @author TapData + */ +class MySQLJsonParserTest { + + @Test + void testParseNull() { + assertNull(MySQLJsonParser.parseMySQLJsonBinary(null)); + assertNull(MySQLJsonParser.parseMySQLJsonBinary(new byte[0])); + } + + @Test + void testParseMariaDBJsonString() { + // MariaDB 格式的 JSON 字符串(第一个字节 > 0x0F) + String jsonStr = "{\"name\":\"test\"}"; + byte[] data = jsonStr.getBytes(); + String result = MySQLJsonParser.parseMySQLJsonBinary(data); + assertEquals(jsonStr, result); + } + + @Test + void testParseLiteralNull() { + // Type: LITERAL (0x04), Literal Type: NULL (0x00) + byte[] data = {0x04, 0x00}; + String result = MySQLJsonParser.parseMySQLJsonBinary(data); + assertEquals("null", result); + } + + @Test + void testParseLiteralTrue() { + // Type: LITERAL (0x04), Literal Type: TRUE (0x01) + byte[] data = {0x04, 0x01}; + String result = MySQLJsonParser.parseMySQLJsonBinary(data); + assertEquals("true", result); + } + + @Test + void testParseLiteralFalse() { + // Type: LITERAL (0x04), Literal Type: FALSE (0x02) + byte[] data = {0x04, 0x02}; + String result = MySQLJsonParser.parseMySQLJsonBinary(data); + assertEquals("false", result); + } + + @Test + void testParseInt16() { + // Type: INT16 (0x05), Value: 100 + ByteBuffer buffer = ByteBuffer.allocate(3); + buffer.order(ByteOrder.LITTLE_ENDIAN); + buffer.put((byte) 0x05); + buffer.putShort((short) 100); + + String result = MySQLJsonParser.parseMySQLJsonBinary(buffer.array()); + assertEquals("100", result); + } + + @Test + void testParseInt32() { + // Type: INT32 (0x07), Value: 100000 + ByteBuffer buffer = ByteBuffer.allocate(5); + buffer.order(ByteOrder.LITTLE_ENDIAN); + buffer.put((byte) 0x07); + buffer.putInt(100000); + + String result = MySQLJsonParser.parseMySQLJsonBinary(buffer.array()); + assertEquals("100000", result); + } + + @Test + void testParseInt64() { + // Type: INT64 (0x09), Value: 9223372036854775807 + ByteBuffer buffer = ByteBuffer.allocate(9); + buffer.order(ByteOrder.LITTLE_ENDIAN); + buffer.put((byte) 0x09); + buffer.putLong(9223372036854775807L); + + String result = MySQLJsonParser.parseMySQLJsonBinary(buffer.array()); + assertEquals("9223372036854775807", result); + } + + @Test + void testParseDouble() { + // Type: DOUBLE (0x0B), Value: 3.14159 + ByteBuffer buffer = ByteBuffer.allocate(9); + buffer.order(ByteOrder.LITTLE_ENDIAN); + buffer.put((byte) 0x0B); + buffer.putDouble(3.14159); + + String result = MySQLJsonParser.parseMySQLJsonBinary(buffer.array()); + assertTrue(result.startsWith("3.14159")); + } + + @Test + void testParseString() { + // Type: STRING (0x0C), Length: 5, Value: "hello" + String str = "hello"; + ByteBuffer buffer = ByteBuffer.allocate(7); + buffer.order(ByteOrder.LITTLE_ENDIAN); + buffer.put((byte) 0x0C); + buffer.put((byte) str.length()); // variable length: 5 + buffer.put(str.getBytes()); + + String result = MySQLJsonParser.parseMySQLJsonBinary(buffer.array()); + assertEquals("\"hello\"", result); + } + + @Test + void testParseEmptyObject() { + // Type: SMALL_OBJECT (0x00), Element Count: 0, Bytes: 0 + ByteBuffer buffer = ByteBuffer.allocate(5); + buffer.order(ByteOrder.LITTLE_ENDIAN); + buffer.put((byte) 0x00); + buffer.putShort((short) 0); // element count + buffer.putShort((short) 0); // bytes + + String result = MySQLJsonParser.parseMySQLJsonBinary(buffer.array()); + assertEquals("{}", result); + } + + @Test + void testParseEmptyArray() { + // Type: SMALL_ARRAY (0x02), Element Count: 0, Bytes: 0 + ByteBuffer buffer = ByteBuffer.allocate(5); + buffer.order(ByteOrder.LITTLE_ENDIAN); + buffer.put((byte) 0x02); + buffer.putShort((short) 0); // element count + buffer.putShort((short) 0); // bytes + + String result = MySQLJsonParser.parseMySQLJsonBinary(buffer.array()); + assertEquals("[]", result); + } + + @Test + void testParseInvalidData() { + // 无效的类型 - 0xFF 会被当作 MariaDB 格式的字符串 + byte[] data = {(byte) 0xFF}; + String result = MySQLJsonParser.parseMySQLJsonBinary(data); + // MariaDB 格式会返回字符串,而不是 null + assertNotNull(result); + + // 测试真正无效的数据(类型码在有效范围内但数据不足) + byte[] invalidData = {0x05}; // INT16 类型但没有数据 + String invalidResult = MySQLJsonParser.parseMySQLJsonBinary(invalidData); + assertNull(invalidResult); + } +} + diff --git a/connectors-common/postgres-core/src/main/java/io/tapdata/connector/postgres/PostgresSqlMaker.java b/connectors-common/postgres-core/src/main/java/io/tapdata/connector/postgres/PostgresSqlMaker.java index 2a7e2c246..58cf249dd 100644 --- a/connectors-common/postgres-core/src/main/java/io/tapdata/connector/postgres/PostgresSqlMaker.java +++ b/connectors-common/postgres-core/src/main/java/io/tapdata/connector/postgres/PostgresSqlMaker.java @@ -126,7 +126,7 @@ public String buildKeyAndValue(Map record, String splitSymbol, S public void buildOrderClause(StringBuilder builder, TapAdvanceFilter filter) { if (EmptyKit.isNotEmpty(filter.getSortOnList())) { - builder.append("ORDER BY "); + builder.append(" ORDER BY "); List collateList = filter.getCollateList(); builder.append(filter.getSortOnList().stream().map(v -> { Collate collate = null; diff --git a/connectors-common/postgres-core/src/main/java/io/tapdata/connector/postgres/PostgresTest.java b/connectors-common/postgres-core/src/main/java/io/tapdata/connector/postgres/PostgresTest.java index 6b1df013d..7b4ea02f5 100644 --- a/connectors-common/postgres-core/src/main/java/io/tapdata/connector/postgres/PostgresTest.java +++ b/connectors-common/postgres-core/src/main/java/io/tapdata/connector/postgres/PostgresTest.java @@ -4,11 +4,14 @@ import io.tapdata.common.CommonDbTest; import io.tapdata.common.util.FileUtil; import io.tapdata.connector.postgres.config.PostgresConfig; +import io.tapdata.connector.postgres.error.PostgresErrorCode; import io.tapdata.entity.simplify.TapSimplify; +import io.tapdata.exception.TapCodeException; import io.tapdata.kit.EmptyKit; import io.tapdata.kit.StringKit; import io.tapdata.pdk.apis.entity.ConnectionOptions; import io.tapdata.pdk.apis.entity.TestItem; +import io.tapdata.pdk.apis.exception.TapTestItemException; import io.tapdata.pdk.apis.exception.testItem.TapTestCurrentTimeConsistentEx; import io.tapdata.pdk.apis.exception.testItem.TapTestReadPrivilegeEx; import io.tapdata.pdk.apis.exception.testItem.TapTestStreamReadEx; @@ -63,6 +66,7 @@ private void testHostPortForMasterSlave() { isMaster.set(!resultSet.getBoolean(1)); }); if (isMaster.get()) { + masterConnected = true; return; } else { availableHost = commonDbConfig.getHost(); @@ -138,19 +142,24 @@ public Boolean testStreamRead() { properties.put("password", commonDbConfig.getPassword()); properties.put("replication", "database"); properties.put("assumeMinServerVersion", "9.4"); + List testSqls = TapSimplify.list(); + long begin = System.currentTimeMillis(); try { Connection connection = new Driver().connect(commonDbConfig.getDatabaseUrl(), properties); assert connection != null; connection.close(); - List testSqls = TapSimplify.list(); String testSlotName = "test_tapdata_" + UUID.randomUUID().toString().replaceAll("-", "_"); testSqls.add(String.format(PG_LOG_PLUGIN_CREATE_TEST, testSlotName, ((PostgresConfig) commonDbConfig).getLogPluginName())); testSqls.add(PG_LOG_PLUGIN_DROP_TEST); - jdbcContext.batchExecute(testSqls); + jdbcContext.batchExecute(testSqls, 20); consumer.accept(testItem(TestItem.ITEM_READ_LOG, TestItem.RESULT_SUCCESSFULLY, "Cdc can work normally")); return true; } catch (Throwable e) { - consumer.accept(new TestItem(TestItem.ITEM_READ_LOG, new TapTestStreamReadEx(e), TestItem.RESULT_SUCCESSFULLY_WITH_WARN)); + if (System.currentTimeMillis() - begin > 18000) { + consumer.accept(new TestItem(TestItem.ITEM_READ_LOG, new TapTestItemException(new TapCodeException(PostgresErrorCode.CREATE_SLOT_TIMEOUT).dynamicDescriptionParameters(String.join(",", testSqls))), TestItem.RESULT_SUCCESSFULLY_WITH_WARN)); + } else { + consumer.accept(new TestItem(TestItem.ITEM_READ_LOG, new TapTestItemException(new TapCodeException(PostgresErrorCode.CREATE_SLOT_FAILED).dynamicDescriptionParameters(String.join(",", testSqls))), TestItem.RESULT_SUCCESSFULLY_WITH_WARN)); + } return null; } } @@ -185,7 +194,7 @@ public Boolean testWalMinerPgto() { } } - private static final String WALMINER_PACKAGE_NAME = "walminer_x86_64_v4.11.2"; + private static final String WALMINER_PACKAGE_NAME = "walminer_x86_64_v4.12.2"; private boolean deployPgto() { String toolPath = FileUtil.paths("run-resources", "pg-db", "walminer"); @@ -245,7 +254,7 @@ private boolean deployPgto() { process = processBuilder.start(); process.waitFor(60, TimeUnit.SECONDS); new File(FileUtil.paths(toolPath, ctlDir)).mkdir(); - processBuilder.command("/bin/sh", "-c", String.format("walminer pgto -i -c %s -s '%s' -e %s -t 4 --source-connstr1='host=%s port=%s username=%s dbanme=%s password=%s'", toolDir.getAbsolutePath() + "/" + ctlDir, slotName, ((PostgresConfig) commonDbConfig).getPgtoPort(), commonDbConfig.getHost(), commonDbConfig.getPort(), commonDbConfig.getUser(), commonDbConfig.getDatabase(), commonDbConfig.getPassword())); + processBuilder.command("/bin/sh", "-c", String.format("walminer pgto init -c %s -s '%s' -e %s -t 4 --source-connstr1='host=%s port=%s username=%s dbanme=%s password=%s'", toolDir.getAbsolutePath() + "/" + ctlDir, slotName, ((PostgresConfig) commonDbConfig).getPgtoPort(), commonDbConfig.getHost(), commonDbConfig.getPort(), commonDbConfig.getUser(), commonDbConfig.getDatabase(), commonDbConfig.getPassword())); process = processBuilder.start(); process.waitFor(60, TimeUnit.SECONDS); try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()))) { @@ -256,7 +265,7 @@ private boolean deployPgto() { } int exitCode = process.waitFor(); System.out.println("exit code: " + exitCode); - processBuilder.command("/bin/sh", "-c", String.format("walminer pgto -m -c %s", toolDir.getAbsolutePath() + "/" + ctlDir)); + processBuilder.command("/bin/sh", "-c", String.format("walminer pgto run -m -c %s", toolDir.getAbsolutePath() + "/" + ctlDir)); processBuilder.redirectOutput(ProcessBuilder.Redirect.INHERIT); process = processBuilder.start(); process.waitFor(60, TimeUnit.SECONDS); diff --git a/connectors-common/postgres-core/src/main/java/io/tapdata/connector/postgres/cdc/AbstractWalLogMiner.java b/connectors-common/postgres-core/src/main/java/io/tapdata/connector/postgres/cdc/AbstractWalLogMiner.java index e28568fec..d4cc15f71 100644 --- a/connectors-common/postgres-core/src/main/java/io/tapdata/connector/postgres/cdc/AbstractWalLogMiner.java +++ b/connectors-common/postgres-core/src/main/java/io/tapdata/connector/postgres/cdc/AbstractWalLogMiner.java @@ -51,6 +51,7 @@ public abstract class AbstractWalLogMiner { protected CustomAbstractAccepter consumer; protected List tableList; protected boolean filterSchema; + private Map pureDataTypeMap; private Map dataTypeMap; protected final AtomicReference threadException = new AtomicReference<>(); protected final PostgresCDCSQLParser sqlParser = new PostgresCDCSQLParser(); @@ -70,11 +71,13 @@ public AbstractWalLogMiner watch(List tableList, KVReadOnlyMap withSchema = false; this.tableList = tableList; filterSchema = tableList.size() > 50; + this.pureDataTypeMap = new ConcurrentHashMap<>(); this.dataTypeMap = new ConcurrentHashMap<>(); tableList.forEach(tableName -> { TapTable table = tableMap.get(tableName); if (EmptyKit.isNotNull(table)) { - dataTypeMap.putAll(table.getNameFieldMap().entrySet().stream().collect(Collectors.toMap(v -> tableName + "." + v.getKey(), e -> Optional.ofNullable(e.getValue().getPureDataType()).orElse(e.getValue().getDataType())))); + pureDataTypeMap.putAll(table.getNameFieldMap().entrySet().stream().collect(Collectors.toMap(v -> tableName + "." + v.getKey(), e -> Optional.ofNullable(e.getValue().getPureDataType()).orElse(e.getValue().getDataType())))); + dataTypeMap.putAll(table.getNameFieldMap().entrySet().stream().collect(Collectors.toMap(v -> tableName + "." + v.getKey(), e -> e.getValue().getDataType()))); } }); tableList.addAll(getSubPartitionTables(tableMap, tableList)); @@ -85,12 +88,14 @@ public AbstractWalLogMiner watch(Map> schemaTableMap, KVRea withSchema = true; this.schemaTableMap = schemaTableMap; filterSchema = schemaTableMap.entrySet().stream().reduce(0, (a, b) -> a + b.getValue().size(), Integer::sum) > 50; + this.pureDataTypeMap = new ConcurrentHashMap<>(); this.dataTypeMap = new ConcurrentHashMap<>(); schemaTableMap.forEach((schema, tables) -> { tables.forEach(tableName -> { TapTable table = tableMap.get(schema + "." + tableName); if (EmptyKit.isNotNull(table)) { - dataTypeMap.putAll(table.getNameFieldMap().entrySet().stream().collect(Collectors.toMap(v -> schema + "." + tableName + "." + v.getKey(), e -> e.getValue().getPureDataType()))); + pureDataTypeMap.putAll(table.getNameFieldMap().entrySet().stream().collect(Collectors.toMap(v -> tableName + "." + v.getKey(), e -> Optional.ofNullable(e.getValue().getPureDataType()).orElse(e.getValue().getDataType())))); + dataTypeMap.putAll(table.getNameFieldMap().entrySet().stream().collect(Collectors.toMap(v -> tableName + "." + v.getKey(), e -> e.getValue().getDataType()))); } }); tables.addAll(getSubPartitionTables(tableMap, schema, tables)); @@ -196,30 +201,44 @@ protected void parseKeyAndValue(String tableName, Map.Entry stri return; } String key = tableName + "." + stringObjectEntry.getKey(); + String pureDataType = pureDataTypeMap.get(key); String dataType = dataTypeMap.get(key); - if (EmptyKit.isNull(dataType)) { + if (EmptyKit.isNull(pureDataType)) { return; } + switch (pureDataType) { + case "ARRAY": + String arrayString = String.valueOf(value); + List array = new ArrayList<>(); + Arrays.stream(arrayString.substring(1, arrayString.length() - 1).split(",")).forEach(v -> { + array.add(parseType(v, StringKit.removeParentheses(dataType.replace("array", "").trim()))); + }); + stringObjectEntry.setValue(array); + break; + default: + stringObjectEntry.setValue(parseType(value, pureDataType)); + break; + } + } + + private Object parseType(Object value, String dataType) { switch (dataType) { case "smallint": case "integer": case "bigint": case "numeric": + case "money": case "real": case "double precision": - stringObjectEntry.setValue(new BigDecimal((String) value)); - break; + return new BigDecimal((String) value); case "bit": if (value instanceof String && ((String) value).length() == 1) { - stringObjectEntry.setValue("1".equals(value)); + return "1".equals(value); } - break; case "bytea": - stringObjectEntry.setValue(StringKit.toByteArray(((String) value).substring(2))); - break; + return StringKit.toByteArray(String.valueOf(value).substring(2)); case "date": - stringObjectEntry.setValue(LocalDate.parse((String) value).atStartOfDay()); - break; + return LocalDate.parse((String) value).atStartOfDay(); case "interval": String[] intervalArray = ((String) value).split(" "); StringBuilder stringBuilder = new StringBuilder("P"); @@ -250,27 +269,23 @@ protected void parseKeyAndValue(String tableName, Map.Entry stri break; } } - stringObjectEntry.setValue(stringBuilder.toString()); - break; + return stringBuilder.toString(); case "timestamp without time zone": case "timestamp": - stringObjectEntry.setValue(Timestamp.valueOf((String) value).toLocalDateTime().minusHours(postgresConfig.getZoneOffsetHour())); - break; + return Timestamp.valueOf((String) value).toLocalDateTime().minusHours(postgresConfig.getZoneOffsetHour()); case "timestamp with time zone": String timestamp = ((String) value).substring(0, ((String) value).length() - 3); String timezone = ((String) value).substring(((String) value).length() - 3); - stringObjectEntry.setValue(Timestamp.valueOf(timestamp).toLocalDateTime().atZone(TimeZone.getTimeZone("GMT" + timezone + ":00").toZoneId())); - break; + return Timestamp.valueOf(timestamp).toLocalDateTime().atZone(TimeZone.getTimeZone("GMT" + timezone + ":00").toZoneId()); case "time without time zone": case "time": - stringObjectEntry.setValue(LocalTime.parse((String) value).atDate(LocalDate.ofYearDay(1970, 1)).minusHours(postgresConfig.getZoneOffsetHour())); - break; + return LocalTime.parse((String) value).atDate(LocalDate.ofYearDay(1970, 1)).minusHours(postgresConfig.getZoneOffsetHour()); case "time with time zone": String time = ((String) value).substring(0, ((String) value).length() - 3); String zone = ((String) value).substring(((String) value).length() - 3); - stringObjectEntry.setValue(LocalTime.parse(time).atDate(LocalDate.ofYearDay(1970, 1)).atZone(TimeZone.getTimeZone("GMT" + zone + ":00").toZoneId())); - break; + return LocalTime.parse(time).atDate(LocalDate.ofYearDay(1970, 1)).atZone(TimeZone.getTimeZone("GMT" + zone + ":00").toZoneId()); } + return value; } protected static final String WALMINER_STOP = "select walminer_stop()"; @@ -296,7 +311,8 @@ private List getSubPartitionTables(KVReadOnlyMap tableMap, Lis .filter(n -> !tables.contains(n)) .collect(Collectors.toList()); subTableNames.forEach(t -> tableMap.get(table).getNameFieldMap().forEach((k, field) -> { - dataTypeMap.put(t + "." + k, field.getPureDataType()); + pureDataTypeMap.put(t + "." + k, field.getPureDataType()); + dataTypeMap.put(t + "." + k, field.getDataType()); })); subPartitionTableNames.addAll(subTableNames); } @@ -326,7 +342,8 @@ private List getSubPartitionTables(KVReadOnlyMap tableMap, Str .filter(n -> !tables.contains(n)) .collect(Collectors.toList()); subTableNames.forEach(t -> tableMap.get(schema + "." + table).getNameFieldMap().forEach((k, field) -> { - dataTypeMap.put(schema + "." + t + "." + k, field.getPureDataType()); + pureDataTypeMap.put(schema + "." + t + "." + k, field.getPureDataType()); + dataTypeMap.put(t + "." + k, field.getDataType()); })); subPartitionTableNames.addAll(subTableNames); } diff --git a/connectors-common/postgres-core/src/main/java/io/tapdata/connector/postgres/cdc/PostgresCDCSQLParser.java b/connectors-common/postgres-core/src/main/java/io/tapdata/connector/postgres/cdc/PostgresCDCSQLParser.java index b330dfa91..f9d66a3f6 100644 --- a/connectors-common/postgres-core/src/main/java/io/tapdata/connector/postgres/cdc/PostgresCDCSQLParser.java +++ b/connectors-common/postgres-core/src/main/java/io/tapdata/connector/postgres/cdc/PostgresCDCSQLParser.java @@ -59,11 +59,17 @@ protected ResultDO updateBuild(SQLReader sr) { while (true) { tmp = loadName(sr, CANNOT_FIND_COLUMN_NAME); sr.nextAndSkip(isSkip); - result.putData(tmp, loadConditionValue(sr)); - sr.nextAndSkip(isSkip); - if (sr.current(',')) { + try { + result.putData(tmp, loadConditionValue(sr)); sr.nextAndSkip(isSkip); - continue; + if (sr.current(',')) { + sr.nextAndSkip(isSkip); + continue; + } + } catch (RuntimeException e) { + if (!"valueEx".equals(e.getMessage())) { + throw sr.ex(e.getMessage()); + } } break; } @@ -85,11 +91,17 @@ protected ResultDO undoUpdateBuild(SQLReader sr) { while (true) { loadName(sr, "Can't found column name"); sr.nextAndSkip(isSkip); - loadConditionValue(sr); - sr.nextAndSkip(isSkip); - if (sr.current(',')) { + try { + loadConditionValue(sr); sr.nextAndSkip(isSkip); - continue; + if (sr.current(',')) { + sr.nextAndSkip(isSkip); + continue; + } + } catch (RuntimeException e) { + if (!"valueEx".equals(e.getMessage())) { + throw sr.ex(e.getMessage()); + } } break; } @@ -158,7 +170,7 @@ protected Object loadValue(SQLReader sr) { } else if (sr.nextAndSkip(isSkip) && sr.current('(')) { return tmp + sr.loadInQuoteMulti(50, ')'); } - throw sr.ex("Value error '" + tmp + "'"); + throw sr.valueEx(); } } diff --git a/connectors-common/postgres-core/src/main/java/io/tapdata/connector/postgres/cdc/offset/PostgresOffsetStorage.java b/connectors-common/postgres-core/src/main/java/io/tapdata/connector/postgres/cdc/offset/PostgresOffsetStorage.java deleted file mode 100644 index 648d791e5..000000000 --- a/connectors-common/postgres-core/src/main/java/io/tapdata/connector/postgres/cdc/offset/PostgresOffsetStorage.java +++ /dev/null @@ -1,10 +0,0 @@ -package io.tapdata.connector.postgres.cdc.offset; - -import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; - -public class PostgresOffsetStorage { - - public static Map postgresOffsetMap = new ConcurrentHashMap<>(); //one slot one key - -} diff --git a/connectors-common/postgres-core/src/main/java/io/tapdata/connector/postgres/error/PostgresErrorCode.java b/connectors-common/postgres-core/src/main/java/io/tapdata/connector/postgres/error/PostgresErrorCode.java index 31e7d446d..8ba737d49 100644 --- a/connectors-common/postgres-core/src/main/java/io/tapdata/connector/postgres/error/PostgresErrorCode.java +++ b/connectors-common/postgres-core/src/main/java/io/tapdata/connector/postgres/error/PostgresErrorCode.java @@ -54,4 +54,58 @@ public interface PostgresErrorCode { type = TapExType.RUNTIME ) String SELECT_PUBLICATION_FAILED = "410002"; + + @TapExCode( + describe = "Failed to create slot, please analyze the specific error message. \n" + + "Common errors and reasons: \n" + + "ERROR: must be superuser or replication role to use replication slots. \n" + + "The current user does not have permission to create slots.", + describeCN = "创建 slot 失败,需结合具体报错信息分析。\n" + + "常见的报错及原因:\n" + + "ERROR: must be superuser or replication role to use replication slots \n" + + "当前用户没有创建 slot 的权限。", + solution = "Solution (choose one): \n" + + "1. Use a superuser connection (such as postgres). \n" + + "2. Grant the current user the required permissions and execute: ALTER USER username REPLICATION;", + solutionCN = "解决方案(任选其一):\n" + + "1. 使用超级用户连接(例如 postgres)。\n" + + "2. 赋予当前用户所需权限,执行:ALTER USER username REPLICATION;", + dynamicDescription = "Execute sql failed: {}", + dynamicDescriptionCN = "执行语句失败:{}", + level = TapExLevel.CRITICAL, + type = TapExType.RUNTIME + ) + String CREATE_SLOT_FAILED = "410003"; + + @TapExCode( + describe = "Failed to create slot, please analyze the specific error message. \n" + + "Common errors and reasons: \n" + + "ERROR: timeout expired. \n" + + "The creation of slot timed out, which is probably affected by a long time of uncommitted transaction or other database processes blocking wait.", + describeCN = "创建 slot 超时,大概率是受长时间未提交事务影响或其它数据库进程阻塞等待", + solution = "Solution (cautious handling): \n" + + "1. Please check if there are any long uncommitted transactions in the database. If so, please commit them first. \n" + + "2. Please check if there are any other database processes blocking wait. If so, please handle them first.\n" + + "SELECT pid, datname, usename, state, xact_start, now() - xact_start AS duration, query\n" + + "FROM pg_stat_activity\n" + + "WHERE state != 'idle'\n" + + " AND xact_start IS NOT NULL\n" + + " AND now() - xact_start > interval '300 seconds' -- for example, find transactions that have been running for more than 5 minutes\n" + + "ORDER BY xact_start;", + solutionCN = "解决方案(需谨慎处理):\n" + + "1. 请检查数据库是否存在长时间未提交事务,如存在请先提交事务。\n" + + "SELECT pid, datname, usename, state, xact_start, now() - xact_start AS duration, query\n" + + "FROM pg_stat_activity\n" + + "WHERE state != 'idle'\n" + + " AND xact_start IS NOT NULL\n" + + " AND now() - xact_start > interval '300 seconds' -- 例如,查找超过5分钟的事务\n" + + "ORDER BY xact_start;\n" + + "2. 请检查数据库是否存在其它数据库进程阻塞等待,如存在请先处理阻塞。", + dynamicDescription = "Execute sql failed: {}", + dynamicDescriptionCN = "执行语句失败:{}", + level = TapExLevel.CRITICAL, + type = TapExType.RUNTIME, + recoverable = true + ) + String CREATE_SLOT_TIMEOUT = "410004"; } diff --git a/connectors-common/postgres-core/src/main/java/io/tapdata/connector/postgres/exception/PostgresExceptionCollector.java b/connectors-common/postgres-core/src/main/java/io/tapdata/connector/postgres/exception/PostgresExceptionCollector.java index 5ad8db2a2..e8c43bd5f 100644 --- a/connectors-common/postgres-core/src/main/java/io/tapdata/connector/postgres/exception/PostgresExceptionCollector.java +++ b/connectors-common/postgres-core/src/main/java/io/tapdata/connector/postgres/exception/PostgresExceptionCollector.java @@ -5,6 +5,7 @@ import io.tapdata.exception.*; import io.tapdata.kit.ErrorKit; +import java.io.IOException; import java.sql.SQLException; import java.util.List; import java.util.regex.Matcher; @@ -153,8 +154,10 @@ public void collectCdcConfigInvalid(Throwable cause) { public void revealException(Throwable cause) { if (cause instanceof SQLException) { throw new TapPdkRetryableEx(getPdkId(), ErrorKit.getLastCause(cause)) -// .withServerErrorCode(((SQLException) cause).getSQLState()) - ; + .withServerErrorCode(String.valueOf(((SQLException) cause).getErrorCode())); + } + if (cause instanceof IOException) { + throw new TapPdkRetryableEx(getPdkId(), ErrorKit.getLastCause(cause)); } } } diff --git a/connectors-common/postgres-core/src/main/resources/walminer/walminer_x86_64_v4.11.2.tar.gz b/connectors-common/postgres-core/src/main/resources/walminer/walminer_x86_64_v4.12.2.tar.gz similarity index 58% rename from connectors-common/postgres-core/src/main/resources/walminer/walminer_x86_64_v4.11.2.tar.gz rename to connectors-common/postgres-core/src/main/resources/walminer/walminer_x86_64_v4.12.2.tar.gz index f1cdb1a35..a1fc2095f 100644 Binary files a/connectors-common/postgres-core/src/main/resources/walminer/walminer_x86_64_v4.11.2.tar.gz and b/connectors-common/postgres-core/src/main/resources/walminer/walminer_x86_64_v4.12.2.tar.gz differ diff --git a/connectors-common/postgres-core/src/test/java/io/tapdata/connector/postgres/PostgresCDCSQLParserTest.java b/connectors-common/postgres-core/src/test/java/io/tapdata/connector/postgres/PostgresCDCSQLParserTest.java index 7f215b78b..e32eba768 100644 --- a/connectors-common/postgres-core/src/test/java/io/tapdata/connector/postgres/PostgresCDCSQLParserTest.java +++ b/connectors-common/postgres-core/src/test/java/io/tapdata/connector/postgres/PostgresCDCSQLParserTest.java @@ -16,4 +16,10 @@ void testInvalidUpdate() { PostgresCDCSQLParser parser = new PostgresCDCSQLParser(); parser.from("UPDATE public.pg_all_type SET WHERE a1=4", false); } + + @Test + void testSameUpdate() { + PostgresCDCSQLParser parser = new PostgresCDCSQLParser(); + parser.from("UPDATE public.pg_all_type SET a1=a1 WHERE a1=4", false); + } } diff --git a/connectors-common/sql-core/pom.xml b/connectors-common/sql-core/pom.xml index a5561248b..e959e09d1 100644 --- a/connectors-common/sql-core/pom.xml +++ b/connectors-common/sql-core/pom.xml @@ -92,6 +92,23 @@ 5.3.13 test + + org.bgee.log4jdbc-log4j2 + log4jdbc-log4j2-jdbc4.1 + 1.16 + + + org.slf4j + slf4j-api + + + + + org.apache.logging.log4j + log4j-slf4j-impl + ${log4j.version} + provided + @@ -134,6 +151,26 @@ + + org.apache.maven.plugins + maven-antrun-plugin + 1.8 + + + generate-log4jdbc-properties + process-resources + + run + + + + + log4jdbc.spylogdelegator.name = io.tapdata.common.log.CustomLogDelegator + + + + + diff --git a/connectors-common/sql-core/src/main/java/io/tapdata/common/CommonDbConfig.java b/connectors-common/sql-core/src/main/java/io/tapdata/common/CommonDbConfig.java index 96745280a..3cfb53bcd 100644 --- a/connectors-common/sql-core/src/main/java/io/tapdata/common/CommonDbConfig.java +++ b/connectors-common/sql-core/src/main/java/io/tapdata/common/CommonDbConfig.java @@ -1,7 +1,9 @@ package io.tapdata.common; +import io.tapdata.common.log.CustomLogDelegator; import io.tapdata.common.util.FileUtil; import io.tapdata.entity.utils.BeanUtils; +import io.tapdata.entity.utils.DataMap; import io.tapdata.entity.utils.InstanceFactory; import io.tapdata.entity.utils.JsonParser; import io.tapdata.kit.EmptyKit; @@ -44,6 +46,7 @@ public class CommonDbConfig implements Serializable { private int batchReadThreadSize = 4; private Boolean doubleActive = false; private Boolean dataSaving = true; + private Boolean fileLog = false; private Boolean oldVersionTimezone = false; private Boolean createAutoInc = false; private long autoIncJumpValue = 1000000L; @@ -59,6 +62,7 @@ public class CommonDbConfig implements Serializable { protected Boolean enableFileInput = false; protected Long bufferCapacity = 10000000L; + protected Map tableConfig; private Boolean useSSL = false; private String sslCa; @@ -160,6 +164,13 @@ public void deleteSSlFile() { } } + public void startJdbcLog(String loggerName) { + setDbType("log4jdbc:" + getDbType()); + setJdbcDriver("net.sf.log4jdbc.sql.jdbcapi.DriverSpy"); + System.setProperty("log4jdbc.spylogdelegator.name", "io.tapdata.common.log.CustomLogDelegator"); + CustomLogDelegator.setLoggerName(loggerName); + } + public String get__connectionType() { return __connectionType; } @@ -296,6 +307,14 @@ public void setDataSaving(Boolean dataSaving) { this.dataSaving = dataSaving; } + public Boolean getFileLog() { + return fileLog; + } + + public void setFileLog(Boolean fileLog) { + this.fileLog = fileLog; + } + public Boolean getOldVersionTimezone() { return oldVersionTimezone; } @@ -332,6 +351,10 @@ public Boolean getApplyDefault() { return applyDefault; } + public Boolean getApplyDefault(String key) { + return getTableConfigValue(key, "applyDefault", applyDefault); + } + public void setApplyDefault(Boolean applyDefault) { this.applyDefault = applyDefault; } @@ -463,4 +486,28 @@ public int getMaxIndexNameLength() { public void setMaxIndexNameLength(int maxIndexNameLength) { this.maxIndexNameLength = maxIndexNameLength; } + + public Map getTableConfig() { + return tableConfig; + } + + public void setTableConfig(Map tableConfig) { + this.tableConfig = tableConfig; + } + + /** + * Generic method to get table-specific or global configuration value + * + * @param key table name key + * @param propertyName property name in tableConfig + * @param defaultValue default value from global config + * @param type of the value + * @return table-specific value if exists, otherwise global default value + */ + protected T getTableConfigValue(String key, String propertyName, T defaultValue) { + if (tableConfig != null && tableConfig.containsKey(key)) { + return tableConfig.get(key).getValue(propertyName, defaultValue); + } + return defaultValue; + } } diff --git a/connectors-common/sql-core/src/main/java/io/tapdata/common/CommonDbConnector.java b/connectors-common/sql-core/src/main/java/io/tapdata/common/CommonDbConnector.java index 56d83a227..8034ea937 100644 --- a/connectors-common/sql-core/src/main/java/io/tapdata/common/CommonDbConnector.java +++ b/connectors-common/sql-core/src/main/java/io/tapdata/common/CommonDbConnector.java @@ -25,18 +25,17 @@ import io.tapdata.kit.StringKit; import io.tapdata.pdk.apis.context.TapConnectionContext; import io.tapdata.pdk.apis.context.TapConnectorContext; -import io.tapdata.pdk.apis.entity.FilterResult; -import io.tapdata.pdk.apis.entity.FilterResults; -import io.tapdata.pdk.apis.entity.TapAdvanceFilter; -import io.tapdata.pdk.apis.entity.TapFilter; +import io.tapdata.pdk.apis.entity.*; import io.tapdata.pdk.apis.functions.connector.target.CreateTableOptions; +import io.tapdata.util.DateUtil; import org.apache.commons.lang3.StringUtils; import java.io.IOException; -import java.sql.Connection; -import java.sql.ResultSet; -import java.sql.SQLException; -import java.sql.SQLRecoverableException; +import java.sql.*; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; import java.util.*; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.CountDownLatch; @@ -63,6 +62,14 @@ public abstract class CommonDbConnector extends ConnectorBase { protected static final String HAS_AUTO_INCR = "HAS_AUTO_INCR"; protected static final String HAS_REMOVED_COLUMN = "HAS_REMOVED_COLUMN"; protected static final String CANNOT_CLOSE_CONSTRAINT = "CANNOT_CLOSE_CONSTRAINT"; + private static final String PARAMS_NAME = "name"; + private static final String PARAMS_TYPE = "type"; + private static final String PARAMS_MODE = "mode"; + private static final String PARAMS_VALUE = "value"; + private static final String MODE_IN = "in"; + private static final String MODE_OUT = "out"; + private static final String MODE_IN_OUT = "in/out"; + private static final String MODE_RETURN = "return"; //ddlHandlers which for ddl collection protected BiClassHandlers> fieldDDLHandlers; //ddlSqlMaker which for ddl execution @@ -869,8 +876,8 @@ protected void queryByAdvanceFilterWithOffset(TapConnectorContext connectorConte jdbcContext.query(sql, resultSet -> { FilterResults filterResults = new FilterResults(); try { + List allColumn = DbKit.getColumnsFromResultSet(resultSet); while (resultSet.next()) { - List allColumn = DbKit.getColumnsFromResultSet(resultSet); DataMap dataMap = DbKit.getRowFromResultSet(resultSet, allColumn); processDataMap(dataMap, table); filterResults.add(dataMap); @@ -897,8 +904,8 @@ protected void queryByAdvanceFilterWithOffsetV2(TapConnectorContext connectorCon jdbcContext.query(sql, resultSet -> { FilterResults filterResults = new FilterResults(); try { + List allColumn = DbKit.getColumnsFromResultSet(resultSet); while (resultSet.next()) { - List allColumn = DbKit.getColumnsFromResultSet(resultSet); allColumn.remove("ROWNO_"); DataMap dataMap = DbKit.getRowFromResultSet(resultSet, allColumn); processDataMap(dataMap, table); @@ -920,6 +927,34 @@ protected void queryByAdvanceFilterWithOffsetV2(TapConnectorContext connectorCon }); } + //for SQL Server type (with OFFSET-FETCH) + protected void queryByAdvanceFilterWithOffsetFetch(TapConnectorContext connectorContext, TapAdvanceFilter filter, TapTable table, Consumer consumer) throws Throwable { + String sql = commonSqlMaker.buildSelectClause(table, filter, false) + getSchemaAndTable(table.getId()) + commonSqlMaker.buildSqlByAdvanceFilterWithOffsetFetch(filter); + jdbcContext.query(sql, resultSet -> { + FilterResults filterResults = new FilterResults(); + try { + List allColumn = DbKit.getColumnsFromResultSet(resultSet); + while (resultSet.next()) { + DataMap dataMap = DbKit.getRowFromResultSet(resultSet, allColumn); + processDataMap(dataMap, table); + filterResults.add(dataMap); + if (filterResults.getResults().size() == BATCH_ADVANCE_READ_LIMIT) { + consumer.accept(filterResults); + filterResults = new FilterResults(); + } + } + } catch (SQLException e) { + exceptionCollector.collectTerminateByServer(e); + exceptionCollector.collectReadPrivileges("batchReadWithoutOffset", Collections.emptyList(), e); + exceptionCollector.revealException(e); + throw e; + } + if (EmptyKit.isNotEmpty(filterResults.getResults())) { + consumer.accept(filterResults); + } + }); + } + protected void beginTransaction(TapConnectorContext connectorContext) throws Throwable { isTransaction = true; } @@ -1010,10 +1045,328 @@ protected long countByAdvanceFilterV2(TapConnectorContext connectorContext, TapT return count.get(); } + protected long countByAdvanceFilterWithOffsetFetch(TapConnectorContext connectorContext, TapTable tapTable, TapAdvanceFilter tapAdvanceFilter) throws SQLException { + AtomicLong count = new AtomicLong(0); + String sql = "SELECT COUNT(1) FROM " + getSchemaAndTable(tapTable.getId()) + commonSqlMaker.buildSqlByAdvanceFilterWithOffsetFetch(tapAdvanceFilter); + jdbcContext.query(sql, resultSet -> { + if (resultSet.next()) { + count.set(resultSet.getLong(1)); + } + }); + return count.get(); + } + protected List getAfterUniqueAutoIncrementFields(TapTable tapTable, List indexList) { return new ArrayList<>(); } + protected void executeCommand(TapConnectorContext connectorContext, TapExecuteCommand executeCommand, Consumer consumer) throws Throwable { + try { + Map params = executeCommand.getParams(); + String command = executeCommand.getCommand(); + switch (command) { + case "execute": + case "executeQuery": + String sql = (String) params.get("sql"); + int batchSize = params.get("batchSize") != null ? (int) params.get("batchSize") : 1000; + execute(sql, list -> consumer.accept(new ExecuteResult().result(list)), batchSize); + break; + case "call": + String funcName = (String) params.get("funcName"); + List> callParams = (List>) params.get("params"); + consumer.accept(call(funcName, callParams)); + break; + default: + consumer.accept(new ExecuteResult<>().error(new IllegalArgumentException("Not supported command: " + command))); + } + } catch (Throwable e) { + consumer.accept(new ExecuteResult<>().error(e)); + } + } + + protected void execute(String sql, Consumer consumer, int batchSize) throws Throwable { + try (Connection connection = jdbcContext.getConnection(); + Statement sqlStatement = connection.createStatement()) { + boolean hasResult = sqlStatement.execute(sql); + if (!hasResult) { + consumer.accept((long) sqlStatement.getUpdateCount()); + } else { + while (isAlive() && hasResult) { + try (ResultSet resultSet = sqlStatement.getResultSet()) { + List> list = TapSimplify.list(); + String[] columnNames = DbKit.getColumnsFromResultSet(resultSet).toArray(new String[0]); + Integer[] columnTypes = DbKit.getColumnTypeNumbersFromResultSet(resultSet).toArray(new Integer[0]); + while (isAlive() && resultSet.next()) { + DataMap dataMap = filterData(resultSet, columnNames, columnTypes); + list.add(dataMap); + if (list.size() == batchSize) { + consumer.accept(list); + list = TapSimplify.list(); + } + } + if (EmptyKit.isNotEmpty(list)) { + consumer.accept(list); + } + } + hasResult = sqlStatement.getMoreResults(); + } + } + connection.commit(); + } + } + + public ExecuteResult call(String funcName, List> params) { + if (EmptyKit.isEmpty(funcName)) { + throw new IllegalArgumentException("procedure/function is null"); + } + + ExecuteResult executeResult; + + funcName = funcName.trim(); + List outList = new ArrayList<>(); + + try (Connection connection = jdbcContext.getConnection(); + CallableStatement callableStatement = createCallableStatement(funcName, params, connection, outList)) { + if (callableStatement == null) { + throw new RuntimeException("create callableStatement error"); + } + boolean hasResult = callableStatement.execute(); + executeResult = new ExecuteResult<>().result(getOutputFromCall(outList, callableStatement, hasResult)); + } catch (Throwable e) { + executeResult = new ExecuteResult<>().error(new RuntimeException(String.format("Execute database procedure/function %s error, message: %s", funcName, e.getMessage()), e)); + } + return executeResult; + } + + private CallableStatement createCallableStatement(String funcName, List> params, Connection connection, List outList) throws Exception { + + CallableStatement callableStatement; + boolean hasReturn = hasReturn(params); + StringBuilder callStr = new StringBuilder(); + + if (hasReturn) { + callStr.append("{?=call ") + .append(funcName) + .append("(") + .append(StringKit.copyString("?", params.size() - 1, ",")) + .append(")}"); + } else { + callStr.append("{call ") + .append(funcName) + .append("(") + .append(StringKit.copyString("?", params.size(), ",")) + .append(")}"); + } + + callableStatement = connection.prepareCall(callStr.toString()); + + if (callableStatement == null) { + return null; + } + + setCallableStatementParameters(callableStatement, params, outList, connection); + + return callableStatement; + } + + protected void setCallableStatementParameters(CallableStatement callableStatement, List> params, List outList, Connection connection) throws Exception { + if (callableStatement == null || params == null || params.size() == 0) { + return; + } + for (int paramIndex = 1; paramIndex <= params.size(); paramIndex++) { + Map paramMap = params.get(paramIndex - 1); + if (paramMap == null || paramMap.isEmpty()) { + throw new Exception("parameter wrong: cannot be empty"); + } + + Object objMode = paramMap.get(PARAMS_MODE); + String mode = objMode == null ? MODE_IN : objMode.toString(); + Object value = paramMap.get(PARAMS_VALUE); + Object objName = paramMap.get(PARAMS_NAME); + String name = objName == null ? "param" + paramIndex : objName.toString().trim(); + Object objType = paramMap.get(PARAMS_TYPE); + String type = objType == null ? "" : objType.toString(); + int jdbcType = type2JdbcType(type); + + if (mode.equalsIgnoreCase(MODE_IN) || mode.equalsIgnoreCase(MODE_IN_OUT)) { + //帮我完善不同的setObject + if (jdbcType == Types.TIMESTAMP) { + String dateFormat = DateUtil.determineDateFormat(value.toString()); + if (dateFormat != null) { + Instant instant = LocalDateTime.parse(value.toString(), DateTimeFormatter.ofPattern(dateFormat)) + .atZone(ZoneId.systemDefault()) + .toInstant(); + callableStatement.setTimestamp(paramIndex, Timestamp.from(instant)); + } else { + callableStatement.setTimestamp(paramIndex, Timestamp.valueOf(value.toString())); + } + } else { + callableStatement.setObject(paramIndex, value, jdbcType); + } + } + + if (mode.equalsIgnoreCase(MODE_OUT) || mode.equalsIgnoreCase(MODE_IN_OUT)) { + JdbcProcedureParam jdbcProcedureParam = new JdbcProcedureParam(name, paramIndex, type, jdbcType); + outList.add(jdbcProcedureParam); + callableStatement.registerOutParameter(paramIndex, jdbcType); + } + if (mode.equalsIgnoreCase(MODE_RETURN)) { + JdbcProcedureParam jdbcProcedureParam = new JdbcProcedureParam(name, 1, type, jdbcType); + outList.add(jdbcProcedureParam); + callableStatement.registerOutParameter(1, jdbcType); + } + } + } + + private Object getOutputFromCall(List outList, CallableStatement callableStatement, boolean hasResult) throws Exception { + if (outList == null || callableStatement == null) { + return null; + } + Map res = new HashMap<>(); + int resIndex = 1; + while (hasResult) { + try (ResultSet resultSet = callableStatement.getResultSet()) { + List> list = TapSimplify.list(); + String[] columnNames = DbKit.getColumnsFromResultSet(resultSet).toArray(new String[0]); + Integer[] columnTypes = DbKit.getColumnTypeNumbersFromResultSet(resultSet).toArray(new Integer[0]); + while (resultSet.next()) { + DataMap dataMap = filterData(resultSet, columnNames, columnTypes); + list.add(dataMap); + } + res.put("result" + resIndex++, list); + } + hasResult = callableStatement.getMoreResults(); + } + for (JdbcProcedureParam param : outList) { + String name = param.getName(); + int paramIndex = param.getIndex(); + String type = param.getType(); + + try { + Object out = callableStatement.getObject(paramIndex); + out = handleValue(out); + res.put(name, out); + } catch (SQLException e) { + throw new Exception("get value {param name: " + name + ", param index: " + paramIndex + ", param type: " + type + "} error: " + e.getMessage()); + } + } + + return res; + } + + private Object handleValue(Object value) { + try { + if (value instanceof Clob) { + value = DbKit.clobToString((Clob) value); + } else if (value instanceof Blob) { + value = DbKit.blobToBytes((Blob) value); + } else if (value instanceof byte[]) { + value = new String((byte[]) value); + } + } catch (Exception e) { + throw new RuntimeException("handle value error: " + e.getMessage()); + } + + return value; + } + + private boolean hasReturn(List> params) { + if (params == null) { + return false; + } + return params.stream().anyMatch(v -> v != null && MODE_RETURN.equalsIgnoreCase(String.valueOf(v.get(PARAMS_MODE)))); + } + + protected int type2JdbcType(String type) { + + switch (type) { + case "varchar": + case "varchar2": + case "nvarchar2": + case "tinytext": + case "mediumtext": + case "longtext": + case "text": + return Types.VARCHAR; + case "char": + case "nchar": + case "enum": + case "set": + return Types.CHAR; + case "long": + return Types.LONGVARCHAR; + case "number": + case "numeric": + return Types.NUMERIC; + case "raw": + case "varbinary": + return Types.VARBINARY; + case "longraw": + return Types.LONGVARBINARY; + case "date": + case "time": + case "datetime": + case "timestamp": + return Types.TIMESTAMP; + case "clob": + return Types.CLOB; + case "bit": + return Types.BIT; + case "tinyint": + case "bool": + case "boolean": + return Types.TINYINT; + case "smallint": + return Types.SMALLINT; + case "mediumint": + case "int": + case "integer": + return Types.INTEGER; + case "bigint": + return Types.BIGINT; + case "float": + return Types.FLOAT; + case "double": + return Types.DOUBLE; + case "decimal": + return Types.DECIMAL; + case "binary": + return Types.BINARY; + case "tinyblob": + case "mediumblob": + case "longblob": + case "blob": + return Types.BLOB; + default: + throw new IllegalArgumentException("Not supported:" + type); + } + } + + protected void filterColumns(TapTable tapTable, List columns, List columnTypes) { + Iterator columnIterator = columns.iterator(); + Iterator typeIterator = columnTypes.iterator(); + while (columnIterator.hasNext() && typeIterator.hasNext()) { + String column = columnIterator.next(); + typeIterator.next(); + if (!tapTable.getNameFieldMap().containsKey(column)) { + columnIterator.remove(); + typeIterator.remove(); + } + } + } + + protected DataMap filterData(ResultSet resultSet, String[] fields, Integer[] columnTypes) throws SQLException { + DataMap dataMap = new DataMap(); + for (int i = 0; i < fields.length; i++) { + dataMap.put(fields[i], filterData(resultSet.getObject(fields[i]), columnTypes[i])); + } + return dataMap; + } + + protected Object filterData(Object obj, int columnType) { + return obj; + } + protected void executeCommandV2(TapConnectionContext connectionContext, String sqlType, String sql, Consumer> consumer) throws Throwable { switch (sqlType) { case "execute": diff --git a/connectors-common/sql-core/src/main/java/io/tapdata/common/CommonSqlMaker.java b/connectors-common/sql-core/src/main/java/io/tapdata/common/CommonSqlMaker.java index 7b54550aa..91c1c4192 100644 --- a/connectors-common/sql-core/src/main/java/io/tapdata/common/CommonSqlMaker.java +++ b/connectors-common/sql-core/src/main/java/io/tapdata/common/CommonSqlMaker.java @@ -270,7 +270,7 @@ public String queryOperatorToString(QueryOperator operator, String quote) { public void buildOrderClause(StringBuilder builder, TapAdvanceFilter filter) { if (EmptyKit.isNotEmpty(filter.getSortOnList())) { - builder.append("ORDER BY "); + builder.append(" ORDER BY "); builder.append(filter.getSortOnList().stream().map(v -> v.toString(String.valueOf(escapeChar))).collect(Collectors.joining(", "))).append(' '); } } @@ -278,10 +278,10 @@ public void buildOrderClause(StringBuilder builder, TapAdvanceFilter filter) { public void buildLimitOffsetClause(StringBuilder builder, TapAdvanceFilter filter) { if (EmptyKit.isNotNull(filter.getLimit())) { - builder.append("LIMIT ").append(filter.getLimit()).append(' '); + builder.append(" LIMIT ").append(filter.getLimit()).append(' '); } if (EmptyKit.isNotNull(filter.getSkip())) { - builder.append("OFFSET ").append(filter.getSkip()).append(' '); + builder.append(" OFFSET ").append(filter.getSkip()).append(' '); } } @@ -317,6 +317,33 @@ public void buildRowNumberClause(StringBuilder builder, TapAdvanceFilter filter) } } + /** + * build subSql after where for advance query using OFFSET-FETCH syntax + * + * @param filter condition of advance query + * @return where substring + */ + public String buildSqlByAdvanceFilterWithOffsetFetch(TapAdvanceFilter filter) { + StringBuilder builder = new StringBuilder(); + buildWhereClause(builder, filter); + buildOrderClause(builder, filter); + buildOffsetFetchClause(builder, filter); + return builder.toString(); + } + + public void buildOffsetFetchClause(StringBuilder builder, TapAdvanceFilter filter) { + if (EmptyKit.isNotNull(filter.getSkip())) { + builder.append(" OFFSET ").append(filter.getSkip()).append(" ROWS "); + } else if (EmptyKit.isNotNull(filter.getLimit())) { + // FETCH requires OFFSET, so add OFFSET 0 if only LIMIT is specified + builder.append(" OFFSET 0 ROWS "); + } + + if (EmptyKit.isNotNull(filter.getLimit())) { + builder.append(" FETCH FIRST ").append(filter.getLimit()).append(" ROWS ONLY "); + } + } + /** * set value for each column in sql * e.g. diff --git a/connectors-common/sql-core/src/main/java/io/tapdata/common/DefaultSqlExecutor.java b/connectors-common/sql-core/src/main/java/io/tapdata/common/DefaultSqlExecutor.java index a111e7df9..2b49b4113 100644 --- a/connectors-common/sql-core/src/main/java/io/tapdata/common/DefaultSqlExecutor.java +++ b/connectors-common/sql-core/src/main/java/io/tapdata/common/DefaultSqlExecutor.java @@ -4,6 +4,7 @@ import io.tapdata.entity.utils.DataMap; import io.tapdata.kit.DbKit; import io.tapdata.kit.EmptyKit; +import io.tapdata.kit.StringKit; import io.tapdata.pdk.apis.entity.ExecuteResult; import io.tapdata.pdk.apis.functions.TapSupplier; import io.tapdata.util.DateUtil; @@ -19,331 +20,321 @@ import java.util.Map; import java.util.function.Consumer; import java.util.function.Supplier; -import java.util.stream.IntStream; public class DefaultSqlExecutor implements SqlExecutor { - private static final String PARAMS_NAME = "name"; - private static final String PARAMS_TYPE = "type"; - private static final String PARAMS_MODE = "mode"; - private static final String PARAMS_VALUE = "value"; - private static final String PARAMS_CUSTOM_TYPE = "custom_type"; - private static final String MODE_IN = "in"; - private static final String MODE_OUT = "out"; - private static final String MODE_IN_OUT = "in/out"; - private static final String MODE_RETURN = "return"; - - private Consumer processDataMapConsumer; - - public DefaultSqlExecutor(Consumer processDataMapConsumer) { - this.processDataMapConsumer = processDataMapConsumer; - } - - public DefaultSqlExecutor() { - } - - @Override - public void execute(String sql, TapSupplier connectionSupplier, Consumer consumer, Supplier aliveSupplier, int batchSize) throws Throwable { - try (Connection connection = connectionSupplier.get(); - Statement sqlStatement = connection.createStatement()) { - boolean isQuery = sqlStatement.execute(sql); - if (isQuery) { - List> list = TapSimplify.list(); - try (ResultSet resultSet = sqlStatement.getResultSet()) { - List columnNames = DbKit.getColumnsFromResultSet(resultSet); - while (aliveSupplier.get() && resultSet.next()) { - DataMap dataMap = DbKit.getRowFromResultSet(resultSet, columnNames); - processDataMap(dataMap); - list.add(dataMap); - if (list.size() == batchSize) { - consumer.accept(list); - list = TapSimplify.list(); - } - } - if (EmptyKit.isNotEmpty(list)) { - consumer.accept(list); - } - } - } else { - consumer.accept((long) sqlStatement.getUpdateCount()); - } - connection.commit(); - } - } - - @Override - public ExecuteResult execute(String sql, TapSupplier connectionSupplier) { - ExecuteResult executeResult; - try (Connection connection = connectionSupplier.get(); - Statement sqlStatement = connection.createStatement()) { - boolean isQuery = sqlStatement.execute(sql); - if (isQuery) { - try (ResultSet resultSet = sqlStatement.getResultSet()) { - List dataMaps = DbKit.getDataFromResultSet(resultSet); - processDataMap(dataMaps); - executeResult = new ExecuteResult<>().result(dataMaps); - } - } else { - executeResult = new ExecuteResult<>().result((long) sqlStatement.getUpdateCount()); - } - connection.commit(); + private static final String PARAMS_NAME = "name"; + private static final String PARAMS_TYPE = "type"; + private static final String PARAMS_MODE = "mode"; + private static final String PARAMS_VALUE = "value"; + private static final String MODE_IN = "in"; + private static final String MODE_OUT = "out"; + private static final String MODE_IN_OUT = "in/out"; + private static final String MODE_RETURN = "return"; + + private Consumer processDataMapConsumer; - } catch (Throwable e) { - executeResult = new ExecuteResult<>().error(e); + public DefaultSqlExecutor(Consumer processDataMapConsumer) { + this.processDataMapConsumer = processDataMapConsumer; } - return executeResult; - } - - private void processDataMap(DataMap dataMap) throws SQLException { - if (processDataMapConsumer != null) { - processDataMapConsumer.accept(dataMap); - } - } - - private void processDataMap(List dataMaps) throws SQLException { - for (DataMap dataMap : dataMaps) { - processDataMap(dataMap); - } - } - @Override - public ExecuteResult call(String funcName, List> params, TapSupplier connectionSupplier) { - if (funcName == null || "".equals(funcName)) { - throw new IllegalArgumentException("procedure/function is null"); + + public DefaultSqlExecutor() { } - ExecuteResult executeResult; - - funcName = funcName.trim(); - List outList = new ArrayList<>(); - - try (Connection connection = connectionSupplier.get(); - CallableStatement callableStatement = createCallableStatement(funcName, params, connection, outList)) { - if (callableStatement == null) { - throw new RuntimeException("create callableStatement error"); - } - callableStatement.execute(); - - if (outList.size() > 0) { - executeResult = new ExecuteResult<>().result(true); - } else { - executeResult = new ExecuteResult<>().result(getOutputFromCall(outList, callableStatement)); - } - } catch (Throwable e) { - executeResult = new ExecuteResult<>().error(new RuntimeException(String.format("Execute database procedure/function %s error, message: %s", funcName, e.getMessage()), e)); + @Override + public void execute(String sql, TapSupplier connectionSupplier, Consumer consumer, Supplier aliveSupplier, int batchSize) throws Throwable { + try (Connection connection = connectionSupplier.get(); + Statement sqlStatement = connection.createStatement()) { + boolean hasResult = sqlStatement.execute(sql); + if (!hasResult) { + consumer.accept((long) sqlStatement.getUpdateCount()); + } else { + while (aliveSupplier.get() && hasResult) { + try (ResultSet resultSet = sqlStatement.getResultSet()) { + List> list = TapSimplify.list(); + List columnNames = DbKit.getColumnsFromResultSet(resultSet); + while (aliveSupplier.get() && resultSet.next()) { + DataMap dataMap = DbKit.getRowFromResultSet(resultSet, columnNames); + processDataMap(dataMap); + list.add(dataMap); + if (list.size() == batchSize) { + consumer.accept(list); + list = TapSimplify.list(); + } + } + if (EmptyKit.isNotEmpty(list)) { + consumer.accept(list); + } + } + hasResult = sqlStatement.getMoreResults(); + } + } + connection.commit(); + } } - return executeResult; - } - - private CallableStatement createCallableStatement(String funcName, List> params, Connection connection, List outList) throws Exception { - - CallableStatement callableStatement; - boolean isFunction = isFunction(params); - StringBuilder callStr = new StringBuilder(); - - if (isFunction) { - callStr.append("{?=call ") - .append(funcName) - .append("(") - .append(buildProcedureParams(params.size() - 1)) - .append(")}"); - } else { - callStr.append("{call ") - .append(funcName) - .append("(") - .append(buildProcedureParams(params.size())) - .append(")}"); + + @Override + @Deprecated + public ExecuteResult execute(String sql, TapSupplier connectionSupplier) { + ExecuteResult executeResult; + try (Connection connection = connectionSupplier.get(); + Statement sqlStatement = connection.createStatement()) { + boolean isQuery = sqlStatement.execute(sql); + if (isQuery) { + try (ResultSet resultSet = sqlStatement.getResultSet()) { + List dataMaps = DbKit.getDataFromResultSet(resultSet); + processDataMap(dataMaps); + executeResult = new ExecuteResult<>().result(dataMaps); + } + } else { + executeResult = new ExecuteResult<>().result((long) sqlStatement.getUpdateCount()); + } + connection.commit(); + + } catch (Throwable e) { + executeResult = new ExecuteResult<>().error(e); + } + return executeResult; } - callableStatement = connection.prepareCall(callStr.toString()); + private void processDataMap(DataMap dataMap) { + if (processDataMapConsumer != null) { + processDataMapConsumer.accept(dataMap); + } + } - if (callableStatement == null) { - return null; + private void processDataMap(List dataMaps) throws SQLException { + for (DataMap dataMap : dataMaps) { + processDataMap(dataMap); + } } - setCallableStatementParameters(callableStatement, params, outList, connection); + @Override + public ExecuteResult call(String funcName, List> params, TapSupplier connectionSupplier) { + if (EmptyKit.isEmpty(funcName)) { + throw new IllegalArgumentException("procedure/function is null"); + } + + ExecuteResult executeResult; - return callableStatement; - } + funcName = funcName.trim(); + List outList = new ArrayList<>(); - protected void setCallableStatementParameters(CallableStatement callableStatement, List> params, List outList, Connection connection) throws Exception { - if (callableStatement == null || params == null || params.size() == 0) { - return; + try (Connection connection = connectionSupplier.get(); + CallableStatement callableStatement = createCallableStatement(funcName, params, connection, outList)) { + if (callableStatement == null) { + throw new RuntimeException("create callableStatement error"); + } + boolean hasResult = callableStatement.execute(); + executeResult = new ExecuteResult<>().result(getOutputFromCall(outList, callableStatement, hasResult)); + } catch (Throwable e) { + executeResult = new ExecuteResult<>().error(new RuntimeException(String.format("Execute database procedure/function %s error, message: %s", funcName, e.getMessage()), e)); + } + return executeResult; } - for (int paramIndex = 1; paramIndex <= params.size(); paramIndex++) { - Map paramMap = params.get(paramIndex - 1); - if (paramMap == null || paramMap.isEmpty()) { - throw new Exception("parameter wrong: cannot be empty"); - } - - Object objMode = paramMap.get(PARAMS_MODE); - String mode = objMode == null ? MODE_IN : objMode.toString(); - - Object value = paramMap.get(PARAMS_VALUE); - - Object objName = paramMap.get(PARAMS_NAME); - String name = objName == null ? "param" + paramIndex : objName.toString().trim(); - - Object objType = paramMap.get(PARAMS_TYPE); - String type = objType == null ? "" : objType.toString(); - int jdbcType = type2JdbcType(type); - - if (mode.equalsIgnoreCase(MODE_IN) || mode.equalsIgnoreCase(MODE_IN_OUT)) { - if (jdbcType == Types.TIMESTAMP) { - String dateFormat = DateUtil.determineDateFormat(value.toString()); - if (dateFormat != null) { - Instant instant = LocalDateTime.parse(value.toString(), DateTimeFormatter.ofPattern(dateFormat)) - .atZone(ZoneId.systemDefault()) - .toInstant(); - callableStatement.setTimestamp(paramIndex, Timestamp.from(instant)); - } else { - callableStatement.setTimestamp(paramIndex, Timestamp.valueOf(value.toString())); - } + private CallableStatement createCallableStatement(String funcName, List> params, Connection connection, List outList) throws Exception { + + CallableStatement callableStatement; + boolean hasReturn = hasReturn(params); + StringBuilder callStr = new StringBuilder(); + + if (hasReturn) { + callStr.append("{?=call ") + .append(funcName) + .append("(") + .append(StringKit.copyString("?", params.size() - 1, ",")) + .append(")}"); + } else { + callStr.append("{call ") + .append(funcName) + .append("(") + .append(StringKit.copyString("?", params.size(), ",")) + .append(")}"); } - } - if (mode.equalsIgnoreCase(MODE_OUT) || mode.equalsIgnoreCase(MODE_IN_OUT) || mode.equalsIgnoreCase(MODE_RETURN)) { - JdbcProcedureParam jdbcProcedureParam = new JdbcProcedureParam(name, paramIndex, type, jdbcType); - outList.add(jdbcProcedureParam); - } - } - } + callableStatement = connection.prepareCall(callStr.toString()); - private Object getOutputFromCall(List outList, CallableStatement callableStatement) throws Exception { - if (outList == null || outList.size() == 0 || callableStatement == null) { - return null; - } + if (callableStatement == null) { + return null; + } - Map res = new HashMap<>(); - for (JdbcProcedureParam param : outList) { - String name = param.getName(); - int paramIndex = param.getIndex(); - String type = param.getType(); - - try { - Object out = callableStatement.getObject(paramIndex); - out = handleValue(out); - res.put(name, out); - } catch (SQLException e) { - throw new Exception("get value {param name: " + name + ", param index: " + paramIndex + ", param type: " + type + "} error: " + e.getMessage()); - } - } + setCallableStatementParameters(callableStatement, params, outList, connection); - return res; - } - - private Object handleValue(Object value) { - try { - if (value instanceof Clob) { - value = DbKit.clobToString((Clob) value); - } else if (value instanceof Blob) { - value = DbKit.blobToBytes((Blob) value); - } else if (value instanceof byte[]) { - value = new String((byte[]) value); - } - } catch (Exception e) { - throw new RuntimeException("handle value error: " + e.getMessage()); + return callableStatement; } - return value; - } + protected void setCallableStatementParameters(CallableStatement callableStatement, List> params, List outList, Connection connection) throws Exception { + if (callableStatement == null || params == null || params.size() == 0) { + return; + } + for (int paramIndex = 1; paramIndex <= params.size(); paramIndex++) { + Map paramMap = params.get(paramIndex - 1); + if (paramMap == null || paramMap.isEmpty()) { + throw new Exception("parameter wrong: cannot be empty"); + } - private boolean isFunction(List> params) { - boolean isFunction = false; + Object objMode = paramMap.get(PARAMS_MODE); + String mode = objMode == null ? MODE_IN : objMode.toString(); + Object value = paramMap.get(PARAMS_VALUE); + Object objName = paramMap.get(PARAMS_NAME); + String name = objName == null ? "param" + paramIndex : objName.toString().trim(); + Object objType = paramMap.get(PARAMS_TYPE); + String type = objType == null ? "" : objType.toString(); + int jdbcType = type2JdbcType(type); + + if (mode.equalsIgnoreCase(MODE_IN) || mode.equalsIgnoreCase(MODE_IN_OUT)) { + //帮我完善不同的setObject + if (jdbcType == Types.TIMESTAMP) { + String dateFormat = DateUtil.determineDateFormat(value.toString()); + if (dateFormat != null) { + Instant instant = LocalDateTime.parse(value.toString(), DateTimeFormatter.ofPattern(dateFormat)) + .atZone(ZoneId.systemDefault()) + .toInstant(); + callableStatement.setTimestamp(paramIndex, Timestamp.from(instant)); + } else { + callableStatement.setTimestamp(paramIndex, Timestamp.valueOf(value.toString())); + } + } else { + callableStatement.setObject(paramIndex, value, jdbcType); + } + } - if (params != null && params.size() > 0) { - Map param = params.get(0); - if (param.containsKey(PARAMS_MODE)) { - Object objMode = param.get(PARAMS_MODE); - String mode = objMode == null ? "" : objMode.toString(); - if (mode.equalsIgnoreCase(MODE_RETURN)) { - isFunction = true; + if (mode.equalsIgnoreCase(MODE_OUT) || mode.equalsIgnoreCase(MODE_IN_OUT)) { + JdbcProcedureParam jdbcProcedureParam = new JdbcProcedureParam(name, paramIndex, type, jdbcType); + outList.add(jdbcProcedureParam); + callableStatement.registerOutParameter(paramIndex, jdbcType); + } + if (mode.equalsIgnoreCase(MODE_RETURN)) { + JdbcProcedureParam jdbcProcedureParam = new JdbcProcedureParam(name, 1, type, jdbcType); + outList.add(jdbcProcedureParam); + callableStatement.registerOutParameter(1, jdbcType); + } } - } } - return isFunction; - } + private Object getOutputFromCall(List outList, CallableStatement callableStatement, boolean hasResult) throws Exception { + if (outList == null || callableStatement == null) { + return null; + } + Map res = new HashMap<>(); + int resIndex = 1; + while (hasResult) { + try (ResultSet resultSet = callableStatement.getResultSet()) { + List> list = TapSimplify.list(); + List columnNames = DbKit.getColumnsFromResultSet(resultSet); + while (resultSet.next()) { + DataMap dataMap = DbKit.getRowFromResultSet(resultSet, columnNames); + processDataMap(dataMap); + list.add(dataMap); + } + res.put("result" + resIndex++, list); + } + hasResult = callableStatement.getMoreResults(); + } + for (JdbcProcedureParam param : outList) { + String name = param.getName(); + int paramIndex = param.getIndex(); + String type = param.getType(); + + try { + Object out = callableStatement.getObject(paramIndex); + out = handleValue(out); + res.put(name, out); + } catch (SQLException e) { + throw new Exception("get value {param name: " + name + ", param index: " + paramIndex + ", param type: " + type + "} error: " + e.getMessage()); + } + } - private static String buildProcedureParams(int count) { - StringBuilder stringBuilder = new StringBuilder(); + return res; + } - IntStream.range(0, count).forEach(i -> { - stringBuilder.append("?,"); - }); + private Object handleValue(Object value) { + try { + if (value instanceof Clob) { + value = DbKit.clobToString((Clob) value); + } else if (value instanceof Blob) { + value = DbKit.blobToBytes((Blob) value); + } else if (value instanceof byte[]) { + value = new String((byte[]) value); + } + } catch (Exception e) { + throw new RuntimeException("handle value error: " + e.getMessage()); + } - String params = stringBuilder.toString(); + return value; + } - if (params.endsWith(",")) { - params = params.substring(0, params.length() - 1); + private boolean hasReturn(List> params) { + if (params == null) { + return false; + } + return params.stream().anyMatch(v -> v != null && MODE_RETURN.equalsIgnoreCase(String.valueOf(v.get(PARAMS_MODE)))); } - return params; - } - - protected int type2JdbcType(String type) { - - switch (type) { - case "varchar": - case "varchar2": - case "nvarchar2": - case "tinytext": - case "mediumtext": - case "longtext": - case "text": - return Types.VARCHAR; - case "char": - case "nchar": - case "enum": - case "set": - return Types.CHAR; - case "long": - return Types.LONGVARCHAR; - case "number": - case "numeric": - return Types.NUMERIC; - case "raw": - case "varbinary": - return Types.VARBINARY; - case "longraw": - return Types.LONGVARBINARY; - case "date": - case "time": - case "datetime": - case "timestamp": - return Types.TIMESTAMP; - case "clob": - return Types.CLOB; - case "bit": - return Types.BIT; - case "tinyint": - case "bool": - case "boolean": - return Types.TINYINT; - case "smallint": - return Types.SMALLINT; - case "mediumint": - case "int": - case "integer": - return Types.INTEGER; - case "bigint": - return Types.BIGINT; - case "float": - return Types.FLOAT; - case "double": - return Types.DOUBLE; - case "decimal": - return Types.DECIMAL; - case "binary": - return Types.BINARY; - case "tinyblob": - case "mediumblob": - case "longblob": - case "blob": - return Types.BLOB; - default: - throw new IllegalArgumentException("Not supported:" + type); + protected int type2JdbcType(String type) { + + switch (type) { + case "varchar": + case "varchar2": + case "nvarchar2": + case "tinytext": + case "mediumtext": + case "longtext": + case "text": + return Types.VARCHAR; + case "char": + case "nchar": + case "enum": + case "set": + return Types.CHAR; + case "long": + return Types.LONGVARCHAR; + case "number": + case "numeric": + return Types.NUMERIC; + case "raw": + case "varbinary": + return Types.VARBINARY; + case "longraw": + return Types.LONGVARBINARY; + case "date": + case "time": + case "datetime": + case "timestamp": + return Types.TIMESTAMP; + case "clob": + return Types.CLOB; + case "bit": + return Types.BIT; + case "tinyint": + case "bool": + case "boolean": + return Types.TINYINT; + case "smallint": + return Types.SMALLINT; + case "mediumint": + case "int": + case "integer": + return Types.INTEGER; + case "bigint": + return Types.BIGINT; + case "float": + return Types.FLOAT; + case "double": + return Types.DOUBLE; + case "decimal": + return Types.DECIMAL; + case "binary": + return Types.BINARY; + case "tinyblob": + case "mediumblob": + case "longblob": + case "blob": + return Types.BLOB; + default: + throw new IllegalArgumentException("Not supported:" + type); + } } - } } diff --git a/connectors-common/sql-core/src/main/java/io/tapdata/common/JdbcContext.java b/connectors-common/sql-core/src/main/java/io/tapdata/common/JdbcContext.java index a1ec1ebce..5e0f0438a 100644 --- a/connectors-common/sql-core/src/main/java/io/tapdata/common/JdbcContext.java +++ b/connectors-common/sql-core/src/main/java/io/tapdata/common/JdbcContext.java @@ -227,6 +227,17 @@ public void execute(String sql) throws SQLException { } } + public void execute(String sql, int timeout) throws SQLException { + try ( + Connection connection = getConnection(); + Statement statement = connection.createStatement() + ) { + statement.setQueryTimeout(timeout); + statement.execute(sql); + connection.commit(); + } + } + public void batchExecute(List sqlList) throws SQLException { try ( Connection connection = getConnection(); @@ -239,6 +250,19 @@ public void batchExecute(List sqlList) throws SQLException { } } + public void batchExecute(List sqlList, int timeout) throws SQLException { + try ( + Connection connection = getConnection(); + Statement statement = connection.createStatement() + ) { + statement.setQueryTimeout(timeout); + for (String sql : sqlList) { + statement.execute(sql); + } + connection.commit(); + } + } + public void queryAllTables(List tableNames, int batchSize, Consumer> consumer) throws SQLException { List temp = list(); query(queryAllTablesSql(getConfig().getSchema(), tableNames), diff --git a/connectors-common/sql-core/src/main/java/io/tapdata/common/SqlExecuteCommandFunction.java b/connectors-common/sql-core/src/main/java/io/tapdata/common/SqlExecuteCommandFunction.java index e72822726..b2fe65117 100644 --- a/connectors-common/sql-core/src/main/java/io/tapdata/common/SqlExecuteCommandFunction.java +++ b/connectors-common/sql-core/src/main/java/io/tapdata/common/SqlExecuteCommandFunction.java @@ -13,78 +13,79 @@ public class SqlExecuteCommandFunction { - private static final SqlExecutor defaultSqlExecutor = new DefaultSqlExecutor(); + private static final SqlExecutor defaultSqlExecutor = new DefaultSqlExecutor(); - private final SqlExecutor sqlExecutor; + private final SqlExecutor sqlExecutor; - public SqlExecuteCommandFunction(SqlExecutor sqlExecutor) { - this.sqlExecutor = sqlExecutor; - } + public SqlExecuteCommandFunction(SqlExecutor sqlExecutor) { + this.sqlExecutor = sqlExecutor; + } - public void executeSqlCommand(TapConnectorContext tapConnectorContext, TapExecuteCommand tapExecuteCommand, TapSupplier connectionSupplier, Consumer executeResultConsumer) { - executeCommand(sqlExecutor, tapExecuteCommand, connectionSupplier, executeResultConsumer); - } + @Deprecated + public void executeSqlCommand(TapConnectorContext tapConnectorContext, TapExecuteCommand tapExecuteCommand, TapSupplier connectionSupplier, Consumer executeResultConsumer) { + executeCommand(sqlExecutor, tapExecuteCommand, connectionSupplier, executeResultConsumer); + } - public void executeSqlCommand(TapConnectorContext tapConnectorContext, TapExecuteCommand tapExecuteCommand, TapSupplier connectionSupplier, Supplier aliveSupplier, Consumer executeResultConsumer) { - executeCommand(sqlExecutor, tapExecuteCommand, connectionSupplier, aliveSupplier, executeResultConsumer); - } + public void executeSqlCommand(TapConnectorContext tapConnectorContext, TapExecuteCommand tapExecuteCommand, TapSupplier connectionSupplier, Supplier aliveSupplier, Consumer executeResultConsumer) { + executeCommand(sqlExecutor, tapExecuteCommand, connectionSupplier, aliveSupplier, executeResultConsumer); + } - @Deprecated - public static void executeCommand(TapConnectorContext tapConnectorContext, TapExecuteCommand tapExecuteCommand, TapSupplier connectionSupplier, Consumer executeResultConsumer) { - executeCommand(defaultSqlExecutor, tapExecuteCommand, connectionSupplier, executeResultConsumer); - } + @Deprecated + public static void executeCommand(TapConnectorContext tapConnectorContext, TapExecuteCommand tapExecuteCommand, TapSupplier connectionSupplier, Consumer executeResultConsumer) { + executeCommand(defaultSqlExecutor, tapExecuteCommand, connectionSupplier, executeResultConsumer); + } - private static void executeCommand(SqlExecutor sqlExecutor, TapExecuteCommand tapExecuteCommand, TapSupplier connectionSupplier, Consumer executeResultConsumer) { - ExecuteResult executeResult; - try { - Map params = tapExecuteCommand.getParams(); - String command = tapExecuteCommand.getCommand(); - switch (command) { - case "execute": - case "executeQuery": - String sql = (String) params.get("sql"); - executeResult = sqlExecutor.execute(sql, connectionSupplier); - break; - case "call": - String funcName = (String) params.get("funcName"); - List> callParams = (List>) params.get("params"); - executeResult = sqlExecutor.call(funcName, callParams, connectionSupplier); - break; - default: - executeResult = new ExecuteResult<>().error(new IllegalArgumentException("Not supported command: " + command)); - } - } catch (Exception e) { - executeResult = new ExecuteResult<>().error(e); - } - executeResultConsumer.accept(executeResult); - } + @Deprecated + private static void executeCommand(SqlExecutor sqlExecutor, TapExecuteCommand tapExecuteCommand, TapSupplier connectionSupplier, Consumer executeResultConsumer) { + ExecuteResult executeResult; + try { + Map params = tapExecuteCommand.getParams(); + String command = tapExecuteCommand.getCommand(); + switch (command) { + case "execute": + case "executeQuery": + String sql = (String) params.get("sql"); + executeResult = sqlExecutor.execute(sql, connectionSupplier); + break; + case "call": + String funcName = (String) params.get("funcName"); + List> callParams = (List>) params.get("params"); + executeResult = sqlExecutor.call(funcName, callParams, connectionSupplier); + break; + default: + executeResult = new ExecuteResult<>().error(new IllegalArgumentException("Not supported command: " + command)); + } + } catch (Exception e) { + executeResult = new ExecuteResult<>().error(e); + } + executeResultConsumer.accept(executeResult); + } - @Deprecated - public static void executeCommand(TapConnectorContext tapConnectorContext, TapExecuteCommand tapExecuteCommand, TapSupplier connectionSupplier, Supplier aliveSupplier, Consumer executeResultConsumer) { - executeCommand(defaultSqlExecutor, tapExecuteCommand, connectionSupplier, aliveSupplier, executeResultConsumer); - } + public static void executeCommand(TapConnectorContext tapConnectorContext, TapExecuteCommand tapExecuteCommand, TapSupplier connectionSupplier, Supplier aliveSupplier, Consumer executeResultConsumer) { + executeCommand(defaultSqlExecutor, tapExecuteCommand, connectionSupplier, aliveSupplier, executeResultConsumer); + } - private static void executeCommand(SqlExecutor sqlExecutor, TapExecuteCommand tapExecuteCommand, TapSupplier connectionSupplier, Supplier aliveSupplier, Consumer executeResultConsumer) { - try { - Map params = tapExecuteCommand.getParams(); - String command = tapExecuteCommand.getCommand(); - switch (command) { - case "execute": - case "executeQuery": - String sql = (String) params.get("sql"); - int batchSize = params.get("batchSize") != null ? (int) params.get("batchSize") : 1000; - sqlExecutor.execute(sql, connectionSupplier, list -> executeResultConsumer.accept(new ExecuteResult().result(list)), aliveSupplier, batchSize); - break; - case "call": - String funcName = (String) params.get("funcName"); - List> callParams = (List>) params.get("params"); - executeResultConsumer.accept(sqlExecutor.call(funcName, callParams, connectionSupplier)); - break; - default: - executeResultConsumer.accept(new ExecuteResult<>().error(new IllegalArgumentException("Not supported command: " + command))); - } - } catch (Throwable e) { - executeResultConsumer.accept(new ExecuteResult<>().error(e)); - } - } + private static void executeCommand(SqlExecutor sqlExecutor, TapExecuteCommand tapExecuteCommand, TapSupplier connectionSupplier, Supplier aliveSupplier, Consumer executeResultConsumer) { + try { + Map params = tapExecuteCommand.getParams(); + String command = tapExecuteCommand.getCommand(); + switch (command) { + case "execute": + case "executeQuery": + String sql = (String) params.get("sql"); + int batchSize = params.get("batchSize") != null ? (int) params.get("batchSize") : 1000; + sqlExecutor.execute(sql, connectionSupplier, list -> executeResultConsumer.accept(new ExecuteResult().result(list)), aliveSupplier, batchSize); + break; + case "call": + String funcName = (String) params.get("funcName"); + List> callParams = (List>) params.get("params"); + executeResultConsumer.accept(sqlExecutor.call(funcName, callParams, connectionSupplier)); + break; + default: + executeResultConsumer.accept(new ExecuteResult<>().error(new IllegalArgumentException("Not supported command: " + command))); + } + } catch (Throwable e) { + executeResultConsumer.accept(new ExecuteResult<>().error(e)); + } + } } diff --git a/connectors-common/sql-core/src/main/java/io/tapdata/common/SqlExecutor.java b/connectors-common/sql-core/src/main/java/io/tapdata/common/SqlExecutor.java index e9a688744..425d24031 100644 --- a/connectors-common/sql-core/src/main/java/io/tapdata/common/SqlExecutor.java +++ b/connectors-common/sql-core/src/main/java/io/tapdata/common/SqlExecutor.java @@ -11,9 +11,10 @@ public interface SqlExecutor { - void execute(String sql, TapSupplier connectionSupplier, Consumer consumer, Supplier aliveSupplier, int batchSize) throws Throwable; + void execute(String sql, TapSupplier connectionSupplier, Consumer consumer, Supplier aliveSupplier, int batchSize) throws Throwable; - ExecuteResult execute(String sql, TapSupplier connectionSupplier); + @Deprecated + ExecuteResult execute(String sql, TapSupplier connectionSupplier); - ExecuteResult call(String funcName, List> params, TapSupplier connectionSupplier); + ExecuteResult call(String funcName, List> params, TapSupplier connectionSupplier); } diff --git a/connectors-common/sql-core/src/main/java/io/tapdata/common/dml/NormalRecordWriter.java b/connectors-common/sql-core/src/main/java/io/tapdata/common/dml/NormalRecordWriter.java index ecfcc9ac6..5359b0b66 100644 --- a/connectors-common/sql-core/src/main/java/io/tapdata/common/dml/NormalRecordWriter.java +++ b/connectors-common/sql-core/src/main/java/io/tapdata/common/dml/NormalRecordWriter.java @@ -221,13 +221,6 @@ public void setAutoIncFields(List autoIncFields) { this.autoIncFields = autoIncFields; } - public void setLargeSql(boolean largeSql) { - this.largeSql = largeSql; - insertRecorder.setLargeSql(largeSql); - updateRecorder.setLargeSql(largeSql); - deleteRecorder.setLargeSql(largeSql); - } - public Map getAutoIncMap() { return autoIncMap; } diff --git a/connectors-common/sql-core/src/main/java/io/tapdata/common/dml/NormalWriteRecorder.java b/connectors-common/sql-core/src/main/java/io/tapdata/common/dml/NormalWriteRecorder.java index 2390e2483..135ca982d 100644 --- a/connectors-common/sql-core/src/main/java/io/tapdata/common/dml/NormalWriteRecorder.java +++ b/connectors-common/sql-core/src/main/java/io/tapdata/common/dml/NormalWriteRecorder.java @@ -164,7 +164,6 @@ public void executeBatch(WriteListResult listResult) throws SQLE } preparedStatement.clearBatch(); batchCache.clear(); - batchCacheSize = 0; } } catch (SQLException e) { // Map map = batchCache.stream().collect(Collectors.toMap(Function.identity(), (v) -> e)); @@ -172,6 +171,7 @@ public void executeBatch(WriteListResult listResult) throws SQLE batchCacheSize = 0; throw e; } + batchCacheSize = 0; atomicLong.addAndGet(succeed); } @@ -545,7 +545,10 @@ protected Object filterValue(Object value, String dataType) throws SQLException return value; } - private static final DateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSSSSS"); + // SimpleDateFormat 非线程安全,用 ThreadLocal 保证每个线程独立持有一个实例 + protected static final ThreadLocal dateFormat = + ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSSSSS")); + // DateTimeFormatter 不可变,天然线程安全 private static final DateTimeFormatter dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSSSSS"); protected String object2String(Object obj) { @@ -557,7 +560,7 @@ protected String object2String(Object obj) { } else if (obj instanceof Number) { result = obj.toString(); } else if (obj instanceof Date) { - result = "'" + dateFormat.format(obj) + "'"; + result = "'" + dateFormat.get().format(obj) + "'"; } else if (obj instanceof Instant) { result = "'" + LocalDateTime.ofInstant((Instant) obj, ZoneId.of("GMT")).format(dateTimeFormatter) + "'"; } else if (obj instanceof byte[]) { diff --git a/connectors-common/sql-core/src/main/java/io/tapdata/common/log/CustomLogDelegator.java b/connectors-common/sql-core/src/main/java/io/tapdata/common/log/CustomLogDelegator.java new file mode 100644 index 000000000..1b219563d --- /dev/null +++ b/connectors-common/sql-core/src/main/java/io/tapdata/common/log/CustomLogDelegator.java @@ -0,0 +1,300 @@ +package io.tapdata.common.log; + +import io.tapdata.write.FileLogger; +import net.sf.log4jdbc.log.SpyLogDelegator; +import net.sf.log4jdbc.sql.Spy; +import net.sf.log4jdbc.sql.resultsetcollector.ResultSetCollector; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import java.io.File; +import java.io.IOException; +import java.util.concurrent.ConcurrentHashMap; + + +/** + * Custom log delegator for postgres connector that allows custom logger name per instance. + * This class uses ThreadLocal to support multiple concurrent tasks with different logger names. + */ +public class CustomLogDelegator implements SpyLogDelegator { + + static { + System.out.println("[CustomLogDelegator] Class loaded successfully!"); + } + + // ThreadLocal to store logger name for each thread/task + private static final ThreadLocal threadLocalLoggerName = new ThreadLocal() { + @Override + protected String initialValue() { + return "jdbc.none.name"; + } + }; + + // Cache for FileLogger instances, keyed by logger name + private static final ConcurrentHashMap fileLoggerCache = new ConcurrentHashMap<>(); + + // Log directory + private static final String LOG_DIR = "logs" + File.separator + "connector"; + + private final Logger sqlOnlyLogger; + private final Logger sqlTimingLogger; + private final Logger connectionLogger; + private final Logger resultSetLogger; + private final Logger auditLogger; + + public CustomLogDelegator() { + // Get logger name from ThreadLocal when instance is created + String loggerName = threadLocalLoggerName.get(); + sqlOnlyLogger = LogManager.getLogger(loggerName + ".sqlonly"); + sqlTimingLogger = LogManager.getLogger(loggerName + ".sqltiming"); + connectionLogger = LogManager.getLogger(loggerName + ".connection"); + resultSetLogger = LogManager.getLogger(loggerName + ".resultset"); + auditLogger = LogManager.getLogger(loggerName + ".audit"); + } + + /** + * Set custom logger name prefix for current thread. + * This should be called before creating JDBC connections in each task. + */ + public static void setLoggerName(String name) { + threadLocalLoggerName.set(name); + } + + /** + * Clear the logger name for current thread to prevent memory leaks. + * This should be called when task is finished. + */ + public static void clearLoggerName() { + threadLocalLoggerName.remove(); + } + + @Override + public boolean isJdbcLoggingEnabled() { + return sqlOnlyLogger.isErrorEnabled() || sqlTimingLogger.isErrorEnabled(); + } + + @Override + public void exceptionOccured(Spy spy, String methodCall, Exception e, String sql, long execTime) { + String classType = spy == null ? "null" : spy.getClass().getName(); + String message = classType + "." + methodCall + " threw " + e.getClass().getName() + ": " + e.getMessage(); + if (sql != null && sql.length() > 0) { + message += " SQL: " + sql; + } + sqlOnlyLogger.error(message, e); + sqlTimingLogger.error(message + " {" + execTime + " msec}", e); + } + + @Override + public void methodReturned(Spy spy, String methodCall, String returnMsg) { + + } + + @Override + public void constructorReturned(Spy spy, String constructionInfo) { + + } + + @Override + public void sqlOccurred(Spy spy, String methodCall, String sql) { + sqlOnlyLogger.debug(sql); + // Write to file + writeToFile(threadLocalLoggerName.get(), "[DEBUG] " + sql); + } + + @Override + public void sqlTimingOccurred(Spy spy, long execTime, String methodCall, String sql) { + String message = sql + " {" + execTime + " msec}"; + sqlTimingLogger.info(message); + // Write to file + writeToFile(threadLocalLoggerName.get(), "[INFO] " + message); + } + + @Override + public void connectionOpened(Spy spy, long execTime) { + // Disabled - don't log connection opened + } + + @Override + public void connectionClosed(Spy spy, long execTime) { + // Disabled - don't log connection closed + } + + @Override + public void connectionAborted(Spy spy, long execTime) { + + } + + @Override + public boolean isResultSetCollectionEnabled() { + return resultSetLogger.isInfoEnabled(); + } + + @Override + public boolean isResultSetCollectionEnabledWithUnreadValueFillIn() { + return resultSetLogger.isInfoEnabled(); + } + + @Override + public void resultSetCollected(ResultSetCollector resultSetCollector) { + + } + + @Override + public void debug(String msg) { + auditLogger.debug(msg); + } + + /** + * Write log message to file using FileLogger + * File path: logs/connector/{loggerName}_*.log + * + * @param loggerName Logger name (used as file prefix) + * @param message Log message + */ + private void writeToFile(String loggerName, String message) { + if (loggerName == null || message == null) { + return; + } + + try { + FileLogger fileLogger = getOrCreateFileLogger(loggerName); + if (fileLogger != null) { + // FileLogger already adds timestamp, so just write the message + fileLogger.write(message); + } + } catch (Exception e) { + // Log error but don't throw exception to avoid breaking the application + System.err.println("Failed to write JDBC log to file: " + e.getMessage()); + } + } + + /** + * Get or create FileLogger for the given logger name + * + * @param loggerName Logger name + * @return FileLogger instance or null if creation failed + */ + private FileLogger getOrCreateFileLogger(String loggerName) { + return fileLoggerCache.computeIfAbsent(loggerName, name -> { + try { + // Create FileLogger with custom configuration + String filePrefix = name.replace('.', '_'); + + FileLogger logger = FileLogger.builder() + .logDirectory(LOG_DIR) + .logFilePrefix(filePrefix) + .queueCapacity(10000) // Queue capacity + .batchSize(100) // Batch size + .flushIntervalMs(1000) // Flush every 1 second + .maxFileSizeMB(100) // Max file size 100MB + .autoTimestamp(true) // Auto add timestamp + .enableCompression(true) + .compressIntervalMs(1000 * 60 * 30) + .maxFileSizeMB(100) + .build(); + + System.out.println("[CustomLogDelegator] Created FileLogger for: " + name + " -> " + LOG_DIR + "/" + filePrefix); + + return logger; + } catch (IOException e) { + System.err.println("Failed to create FileLogger for logger: " + name + ", error: " + e.getMessage()); + return null; + } + }); + } + + /** + * Flush all FileLoggers to ensure data is written to disk + */ + public static void flushAllWriters() { + fileLoggerCache.forEach((name, logger) -> { + try { + if (logger != null) { + logger.forceFlush(); + } + } catch (Exception e) { + System.err.println("Failed to flush FileLogger for: " + name + ", error: " + e.getMessage()); + } + }); + } + + /** + * Flush FileLogger for specific logger name + * + * @param loggerName Logger name + */ + public static void flushWriter(String loggerName) { + FileLogger logger = fileLoggerCache.get(loggerName); + if (logger != null) { + try { + logger.forceFlush(); + } catch (Exception e) { + System.err.println("Failed to flush FileLogger for: " + loggerName + ", error: " + e.getMessage()); + } + } + } + + /** + * Close all FileLoggers and clear cache + * This should be called when shutting down or when no longer needed + */ + public static void closeAllWriters() { + System.out.println("[CustomLogDelegator] Closing all FileLoggers, total: " + fileLoggerCache.size()); + fileLoggerCache.forEach((name, logger) -> { + try { + if (logger != null) { + FileLogger.LoggerStats stats = logger.getStats(); + System.out.println("[CustomLogDelegator] Closing FileLogger for: " + name + ", stats: " + stats); + logger.close(); + } + } catch (Exception e) { + System.err.println("Failed to close FileLogger for: " + name + ", error: " + e.getMessage()); + } + }); + fileLoggerCache.clear(); + System.out.println("[CustomLogDelegator] All FileLoggers closed"); + } + + /** + * Close FileLogger for specific logger name + * + * @param loggerName Logger name + */ + public static void closeWriter(String loggerName) { + FileLogger logger = fileLoggerCache.remove(loggerName); + if (logger != null) { + try { + FileLogger.LoggerStats stats = logger.getStats(); + System.out.println("[CustomLogDelegator] Closing FileLogger for: " + loggerName + ", stats: " + stats); + logger.close(); + } catch (Exception e) { + System.err.println("Failed to close FileLogger for: " + loggerName + ", error: " + e.getMessage()); + } + } + } + + /** + * Get queue size for monitoring + * + * @param loggerName Logger name + * @return Queue size or -1 if logger not found + */ + public static int getQueueSize(String loggerName) { + FileLogger logger = fileLoggerCache.get(loggerName); + if (logger != null) { + return logger.getStats().getQueueSize(); + } + return -1; + } + + /** + * Get statistics for specific logger + * + * @param loggerName Logger name + * @return LoggerStats or null if logger not found + */ + public static FileLogger.LoggerStats getStats(String loggerName) { + FileLogger logger = fileLoggerCache.get(loggerName); + return logger != null ? logger.getStats() : null; + } +} diff --git a/connectors-common/sql-core/src/main/java/io/tapdata/common/sqlparser/SQLReader.java b/connectors-common/sql-core/src/main/java/io/tapdata/common/sqlparser/SQLReader.java index 2cbcb4fc4..d2c5aa9a1 100644 --- a/connectors-common/sql-core/src/main/java/io/tapdata/common/sqlparser/SQLReader.java +++ b/connectors-common/sql-core/src/main/java/io/tapdata/common/sqlparser/SQLReader.java @@ -184,6 +184,10 @@ public RuntimeException ex(String msg) { return new RuntimeException(msg + ", position " + position + ": " + data); } + public RuntimeException valueEx() { + return new RuntimeException("valueEx"); + } + public static SQLReader build(String data) { return new SQLReader(data); } diff --git a/connectors-common/sql-core/src/test/java/io/tapdata/common/CommonSqlMakerTest.java b/connectors-common/sql-core/src/test/java/io/tapdata/common/CommonSqlMakerTest.java index 8a6e15ad4..b65018c0c 100644 --- a/connectors-common/sql-core/src/test/java/io/tapdata/common/CommonSqlMakerTest.java +++ b/connectors-common/sql-core/src/test/java/io/tapdata/common/CommonSqlMakerTest.java @@ -1,10 +1,12 @@ package io.tapdata.common; import io.tapdata.entity.utils.DataMap; +import io.tapdata.pdk.apis.entity.SortOn; import io.tapdata.pdk.apis.entity.TapAdvanceFilter; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; +import java.util.Arrays; import java.util.HashMap; import java.util.Map; @@ -76,5 +78,102 @@ void buildCommandWhereSqlParamsWithWhereTest(){ Assertions.assertEquals(whereSql,actualData); } + @Test + void buildSqlByAdvanceFilterWithOffsetFetchTest() { + CommonSqlMaker commonSqlMaker = new CommonSqlMaker(); + TapAdvanceFilter filter = new TapAdvanceFilter(); + + // Test with both skip and limit + filter.setSkip(10); + filter.setLimit(20); + filter.setSortOnList(Arrays.asList(new SortOn("id", SortOn.ASCENDING))); + + String result = commonSqlMaker.buildSqlByAdvanceFilterWithOffsetFetch(filter); + String expected = " ORDER BY \"id\" ASC OFFSET 10 ROWS FETCH FIRST 20 ROWS ONLY "; + Assertions.assertEquals(expected, result); + } + + @Test + void buildSqlByAdvanceFilterWithOffsetFetchOnlyLimitTest() { + CommonSqlMaker commonSqlMaker = new CommonSqlMaker(); + TapAdvanceFilter filter = new TapAdvanceFilter(); + + // Test with only limit (should add OFFSET 0) + filter.setLimit(15); + filter.setSortOnList(Arrays.asList(new SortOn("name", SortOn.DESCENDING))); + + String result = commonSqlMaker.buildSqlByAdvanceFilterWithOffsetFetch(filter); + String expected = " ORDER BY \"name\" DESC OFFSET 0 ROWS FETCH FIRST 15 ROWS ONLY "; + Assertions.assertEquals(expected, result); + } + + @Test + void buildSqlByAdvanceFilterWithOffsetFetchOnlySkipTest() { + CommonSqlMaker commonSqlMaker = new CommonSqlMaker(); + TapAdvanceFilter filter = new TapAdvanceFilter(); + + // Test with only skip + filter.setSkip(5); + filter.setSortOnList(Arrays.asList(new SortOn("created_at", SortOn.ASCENDING))); + + String result = commonSqlMaker.buildSqlByAdvanceFilterWithOffsetFetch(filter); + String expected = " ORDER BY \"created_at\" ASC OFFSET 5 ROWS "; + Assertions.assertEquals(expected, result); + } + + @Test + void buildSqlByAdvanceFilterWithOffsetFetchNoOrderByTest() { + CommonSqlMaker commonSqlMaker = new CommonSqlMaker(); + TapAdvanceFilter filter = new TapAdvanceFilter(); + + // Test without ORDER BY but with pagination (should not add ORDER BY) + filter.setSkip(10); + filter.setLimit(20); + + String result = commonSqlMaker.buildSqlByAdvanceFilterWithOffsetFetch(filter); + String expected = " OFFSET 10 ROWS FETCH FIRST 20 ROWS ONLY "; + Assertions.assertEquals(expected, result); + } + + @Test + void buildOffsetFetchClauseTest() { + CommonSqlMaker commonSqlMaker = new CommonSqlMaker(); + StringBuilder builder = new StringBuilder(); + TapAdvanceFilter filter = new TapAdvanceFilter(); + + filter.setSkip(100); + filter.setLimit(50); + filter.setSortOnList(Arrays.asList(new SortOn("id", SortOn.ASCENDING))); + + commonSqlMaker.buildOffsetFetchClause(builder, filter); + String result = builder.toString(); + String expected = " OFFSET 100 ROWS FETCH FIRST 50 ROWS ONLY "; + Assertions.assertEquals(expected, result); + } + + @Test + void buildSqlByAdvanceFilterWithOffsetFetchNoPaginationTest() { + CommonSqlMaker commonSqlMaker = new CommonSqlMaker(); + TapAdvanceFilter filter = new TapAdvanceFilter(); + + // Test without any pagination parameters + filter.setSortOnList(Arrays.asList(new SortOn("id", SortOn.ASCENDING))); + + String result = commonSqlMaker.buildSqlByAdvanceFilterWithOffsetFetch(filter); + String expected = " ORDER BY \"id\" ASC "; + Assertions.assertEquals(expected, result); + } + + @Test + void buildSqlByAdvanceFilterWithOffsetFetchEmptyTest() { + CommonSqlMaker commonSqlMaker = new CommonSqlMaker(); + TapAdvanceFilter filter = new TapAdvanceFilter(); + + // Test with completely empty filter + String result = commonSqlMaker.buildSqlByAdvanceFilterWithOffsetFetch(filter); + String expected = ""; + Assertions.assertEquals(expected, result); + } + } diff --git a/connectors-javascript/ai-chat-connector/src/main/resources/spec.json b/connectors-javascript/ai-chat-connector/src/main/resources/spec.json index 2a4574e8f..7b58cd291 100644 --- a/connectors-javascript/ai-chat-connector/src/main/resources/spec.json +++ b/connectors-javascript/ai-chat-connector/src/main/resources/spec.json @@ -1,6 +1,7 @@ { "properties": { "name": "AIChat", + "realName": "AI Chat", "icon": "icon/chat.png", "doc" : "${doc}", "id": "ai-chat", diff --git a/connectors-javascript/ali1688-connector/src/main/resources/spec.json b/connectors-javascript/ali1688-connector/src/main/resources/spec.json index 222253c24..465b3721e 100644 --- a/connectors-javascript/ali1688-connector/src/main/resources/spec.json +++ b/connectors-javascript/ali1688-connector/src/main/resources/spec.json @@ -1,6 +1,7 @@ { "properties": { "name": "Alibaba 1688", + "realName": "Alibaba 1688", "icon": "icon/alibaba.png", "doc" : "${doc}", "id": "ali1688", diff --git a/connectors-javascript/beschannels-connector/src/main/resources/spec.json b/connectors-javascript/beschannels-connector/src/main/resources/spec.json index bdc7e6692..4706dc196 100644 --- a/connectors-javascript/beschannels-connector/src/main/resources/spec.json +++ b/connectors-javascript/beschannels-connector/src/main/resources/spec.json @@ -1,6 +1,7 @@ { "properties": { "name": "BesChannels", + "realName": "BesChannels", "icon": "icon/bes-channels.png", "doc" : "${doc}", "id": "bes-channels", diff --git a/connectors-javascript/github-connector/src/main/resources/spec.json b/connectors-javascript/github-connector/src/main/resources/spec.json index 8a823a22a..738026ad2 100644 --- a/connectors-javascript/github-connector/src/main/resources/spec.json +++ b/connectors-javascript/github-connector/src/main/resources/spec.json @@ -1,6 +1,7 @@ { "properties": { "name": "GitHub", + "realName": "GitHub", "icon": "icon/github.png", "doc" : "${doc}", "id": "GitHub", diff --git a/connectors-javascript/hubspot-connector/src/main/resources/spec.json b/connectors-javascript/hubspot-connector/src/main/resources/spec.json index bae686e41..b183d024d 100644 --- a/connectors-javascript/hubspot-connector/src/main/resources/spec.json +++ b/connectors-javascript/hubspot-connector/src/main/resources/spec.json @@ -1,6 +1,7 @@ { "properties": { "name": "HubSpot", + "realName": "HubSpot", "icon": "icon/hubspot.png", "doc" : "${doc}", "id": "hubspot", diff --git a/connectors-javascript/lark-approval-connector/src/main/resources/spec.json b/connectors-javascript/lark-approval-connector/src/main/resources/spec.json index 2e6de7719..242998540 100644 --- a/connectors-javascript/lark-approval-connector/src/main/resources/spec.json +++ b/connectors-javascript/lark-approval-connector/src/main/resources/spec.json @@ -1,6 +1,7 @@ { "properties": { "name": "Lark Approval", + "realName": "Lark Approval", "icon": "icon/task.png", "doc": "${doc}", "id": "lark-approval", diff --git a/connectors-javascript/lark-bitable-connector/src/main/resources/spec.json b/connectors-javascript/lark-bitable-connector/src/main/resources/spec.json index 51634cf68..69790d701 100644 --- a/connectors-javascript/lark-bitable-connector/src/main/resources/spec.json +++ b/connectors-javascript/lark-bitable-connector/src/main/resources/spec.json @@ -1,6 +1,7 @@ { "properties": { "name": "Feishu-Bitable", + "realName": "Lark Bitable", "icon": "icon/bitable.png", "doc" : "${doc}", "id": "feishu-bitable", diff --git a/connectors-javascript/lark-doc-connector/src/main/resources/spec-oauth.json b/connectors-javascript/lark-doc-connector/src/main/resources/spec-oauth.json index 7d6ae0fa5..7ca7fbd19 100644 --- a/connectors-javascript/lark-doc-connector/src/main/resources/spec-oauth.json +++ b/connectors-javascript/lark-doc-connector/src/main/resources/spec-oauth.json @@ -1,6 +1,7 @@ { "properties": { "name": "Lark Doc", + "realName": "Lark Doc", "icon": "icon/lark-doc.png", "doc" : "${doc}", "id": "lark-doc", diff --git a/connectors-javascript/lark-doc-connector/src/main/resources/spec.json b/connectors-javascript/lark-doc-connector/src/main/resources/spec.json index d34f4ae5c..c81b920dd 100644 --- a/connectors-javascript/lark-doc-connector/src/main/resources/spec.json +++ b/connectors-javascript/lark-doc-connector/src/main/resources/spec.json @@ -1,6 +1,7 @@ { "properties": { "name": "Lark Doc", + "realName": "Lark Doc", "icon": "icon/lark-doc.png", "doc" : "${doc}", "id": "lark-doc", diff --git a/connectors-javascript/lark-im-connector/src/main/resources/spec.json b/connectors-javascript/lark-im-connector/src/main/resources/spec.json index 12d97ee4c..edd3f40f2 100644 --- a/connectors-javascript/lark-im-connector/src/main/resources/spec.json +++ b/connectors-javascript/lark-im-connector/src/main/resources/spec.json @@ -1,6 +1,7 @@ { "properties": { "name": "Lark-IM", + "realName": "Lark IM", "icon": "icon/lark.png", "doc": "${doc}", "id": "lark-im", diff --git a/connectors-javascript/lark-task-connector/src/main/resources/spec.json b/connectors-javascript/lark-task-connector/src/main/resources/spec.json index 660c3ecf8..7774a0b0a 100644 --- a/connectors-javascript/lark-task-connector/src/main/resources/spec.json +++ b/connectors-javascript/lark-task-connector/src/main/resources/spec.json @@ -1,6 +1,7 @@ { "properties": { "name": "LarkTask", + "realName": "Lark Task", "icon": "icon/task.png", "doc": "${doc}", "id": "lark-task", diff --git a/connectors-javascript/metabase-connector/src/main/resources/spec.json b/connectors-javascript/metabase-connector/src/main/resources/spec.json index 957681707..584a16fbf 100644 --- a/connectors-javascript/metabase-connector/src/main/resources/spec.json +++ b/connectors-javascript/metabase-connector/src/main/resources/spec.json @@ -1,6 +1,7 @@ { "properties": { "name": "Metabase", + "realName": "Metabase", "icon": "icon/metabase.png", "doc" : "${doc}", "id": "metabase", diff --git a/connectors-javascript/salesforce-connector/src/main/resources/spec.json b/connectors-javascript/salesforce-connector/src/main/resources/spec.json index 92df4e654..c7a5ed402 100644 --- a/connectors-javascript/salesforce-connector/src/main/resources/spec.json +++ b/connectors-javascript/salesforce-connector/src/main/resources/spec.json @@ -1,6 +1,7 @@ { "properties": { "name": "Salesforce", + "realName": "Salesforce", "icon": "icon/Salesforce-Logo.png", "doc" : "${doc}", "id": "salesforce", diff --git a/connectors-javascript/shein-connector/src/main/resources/spec.json b/connectors-javascript/shein-connector/src/main/resources/spec.json index ed6469461..39981fad8 100644 --- a/connectors-javascript/shein-connector/src/main/resources/spec.json +++ b/connectors-javascript/shein-connector/src/main/resources/spec.json @@ -1,6 +1,7 @@ { "properties": { "name": "Shein", + "realName": "Shein", "icon": "icon/shein.png", "doc" : "${doc}", "id": "shein", diff --git a/connectors-javascript/zoho-crm-connector/src/main/resources/spec.json b/connectors-javascript/zoho-crm-connector/src/main/resources/spec.json index 0bcbd8961..0afeef16f 100644 --- a/connectors-javascript/zoho-crm-connector/src/main/resources/spec.json +++ b/connectors-javascript/zoho-crm-connector/src/main/resources/spec.json @@ -1,6 +1,7 @@ { "properties": { "name": "Zoho-CRM", + "realName": "Zoho CRM", "icon": "icon/zoho.png", "doc" : "${doc}", "id": "zoho-crm", diff --git a/connectors-unpackage/hive1-connector/src/main/resources/spec_hive1.json b/connectors-unpackage/hive1-connector/src/main/resources/spec_hive1.json index ac3a21b73..bbc1b6ee2 100644 --- a/connectors-unpackage/hive1-connector/src/main/resources/spec_hive1.json +++ b/connectors-unpackage/hive1-connector/src/main/resources/spec_hive1.json @@ -1,6 +1,7 @@ { "properties": { "name": "hive1", + "realName": "Apache Hive1", "icon": "icons/hive1.png", "id": "hive1", "doc": "${doc}", diff --git a/connectors-unpackage/hive3-connector/src/main/resources/spec_hive3.json b/connectors-unpackage/hive3-connector/src/main/resources/spec_hive3.json index 6fbd93afe..127494c8a 100644 --- a/connectors-unpackage/hive3-connector/src/main/resources/spec_hive3.json +++ b/connectors-unpackage/hive3-connector/src/main/resources/spec_hive3.json @@ -1,6 +1,7 @@ { "properties": { "name": "hive3", + "realName": "Apache Hive3", "icon": "icons/hive3.png", "id": "hive3", "doc": "${doc}", diff --git a/connectors-unpackage/mrs-hive3-connector/src/main/resources/spec_mrshive3.json b/connectors-unpackage/mrs-hive3-connector/src/main/resources/spec_mrshive3.json index 54a801d0e..f5c509313 100644 --- a/connectors-unpackage/mrs-hive3-connector/src/main/resources/spec_mrshive3.json +++ b/connectors-unpackage/mrs-hive3-connector/src/main/resources/spec_mrshive3.json @@ -1,6 +1,7 @@ { "properties": { "name": "mrs-hive3", + "realName": "Mrs Hive3", "icon": "icons/mrshive3.png", "id": "mrs-hive3", "doc": "${doc}", diff --git a/connectors/activemq-connector/src/main/resources/spec_activemq.json b/connectors/activemq-connector/src/main/resources/spec_activemq.json index 0b5c39737..08bbc9eda 100644 --- a/connectors/activemq-connector/src/main/resources/spec_activemq.json +++ b/connectors/activemq-connector/src/main/resources/spec_activemq.json @@ -1,6 +1,7 @@ { "properties": { "name": "ActiveMQ", + "realName": "Apache ActiveMQ", "icon": "icons/activemq.png", "doc": "${doc}", "id": "activemq", diff --git a/connectors/aliyun-adb-mysql-connector/pom.xml b/connectors/aliyun-adb-mysql-connector/pom.xml index 98bf66629..6f247ce6a 100644 --- a/connectors/aliyun-adb-mysql-connector/pom.xml +++ b/connectors/aliyun-adb-mysql-connector/pom.xml @@ -22,7 +22,7 @@ - 2.0.0-SNAPSHOT + 2.0.5-SNAPSHOT 8 diff --git a/connectors/aliyun-adb-mysql-connector/src/main/resources/aliyun-adb-mysql-spec.json b/connectors/aliyun-adb-mysql-connector/src/main/resources/aliyun-adb-mysql-spec.json index 84b86d42e..798ac3584 100644 --- a/connectors/aliyun-adb-mysql-connector/src/main/resources/aliyun-adb-mysql-spec.json +++ b/connectors/aliyun-adb-mysql-connector/src/main/resources/aliyun-adb-mysql-spec.json @@ -1,6 +1,7 @@ { "properties": { "name": "Aliyun ADB MySQL", + "realName": "ApsaraDB AnalyticDB (MySQL Edition)", "icon": "icons/aliyun_adb_mysql.png", "id": "aliyun-adb-mysql", "doc": "${doc}", diff --git a/connectors/aliyun-adb-postgres-connector/src/main/resources/aliyun-adb-postgres-spec.json b/connectors/aliyun-adb-postgres-connector/src/main/resources/aliyun-adb-postgres-spec.json index 4375d3c57..510e94e3b 100644 --- a/connectors/aliyun-adb-postgres-connector/src/main/resources/aliyun-adb-postgres-spec.json +++ b/connectors/aliyun-adb-postgres-connector/src/main/resources/aliyun-adb-postgres-spec.json @@ -1,6 +1,7 @@ { "properties": { "name": "Aliyun ADB PostgreSQL", + "realName": "ApsaraDB AnalyticDB for PostgreSQL", "icon": "icons/aliyun_adb_postgres.png", "doc" : "${doc}", "id": "aliyun-adb-postgres", diff --git a/connectors/aliyun-mongodb-connector/pom.xml b/connectors/aliyun-mongodb-connector/pom.xml index 2527d6187..fd7ba5d06 100644 --- a/connectors/aliyun-mongodb-connector/pom.xml +++ b/connectors/aliyun-mongodb-connector/pom.xml @@ -23,7 +23,7 @@ 8 - 2.0.0-SNAPSHOT + 2.0.5-SNAPSHOT diff --git a/connectors/aliyun-mongodb-connector/src/main/resources/aliyun-mongodb-spec.json b/connectors/aliyun-mongodb-connector/src/main/resources/aliyun-mongodb-spec.json index 84831a6a2..6c5abbf84 100644 --- a/connectors/aliyun-mongodb-connector/src/main/resources/aliyun-mongodb-spec.json +++ b/connectors/aliyun-mongodb-connector/src/main/resources/aliyun-mongodb-spec.json @@ -1,6 +1,7 @@ { "properties": { "name": "Aliyun MongoDB", + "realName": "ApsaraDB for MongoDB", "icon": "icons/aliyun-mongodb.png", "doc" : "${doc}", "tags" : ["schema-free","Database"], diff --git a/connectors/aliyun-rds-mariadb-connector/pom.xml b/connectors/aliyun-rds-mariadb-connector/pom.xml index c55ae7ffa..b1eda00e7 100644 --- a/connectors/aliyun-rds-mariadb-connector/pom.xml +++ b/connectors/aliyun-rds-mariadb-connector/pom.xml @@ -23,7 +23,7 @@ 8 - 2.0.0-SNAPSHOT + 2.0.5-SNAPSHOT diff --git a/connectors/aliyun-rds-mariadb-connector/src/main/resources/aliyun-rds-mariadb-spec.json b/connectors/aliyun-rds-mariadb-connector/src/main/resources/aliyun-rds-mariadb-spec.json index 1dbe312ad..9af68d8a1 100644 --- a/connectors/aliyun-rds-mariadb-connector/src/main/resources/aliyun-rds-mariadb-spec.json +++ b/connectors/aliyun-rds-mariadb-connector/src/main/resources/aliyun-rds-mariadb-spec.json @@ -1,6 +1,7 @@ { "properties": { "name": "Aliyun RDS Mariadb", + "realName": "ApsaraDB RDS for MariaDB", "icon": "icons/aliyun_rds_mariadb.png", "id": "aliyun-rds-mariadb", "doc": "${doc}", diff --git a/connectors/aliyun-rds-mysql-connector/pom.xml b/connectors/aliyun-rds-mysql-connector/pom.xml index 899de4eac..b370499f7 100644 --- a/connectors/aliyun-rds-mysql-connector/pom.xml +++ b/connectors/aliyun-rds-mysql-connector/pom.xml @@ -23,7 +23,7 @@ 8 - 2.0.0-SNAPSHOT + 2.0.5-SNAPSHOT diff --git a/connectors/aliyun-rds-mysql-connector/src/main/resources/aliyun-rds-mysql-spec.json b/connectors/aliyun-rds-mysql-connector/src/main/resources/aliyun-rds-mysql-spec.json index 40756d30d..2fd63d137 100644 --- a/connectors/aliyun-rds-mysql-connector/src/main/resources/aliyun-rds-mysql-spec.json +++ b/connectors/aliyun-rds-mysql-connector/src/main/resources/aliyun-rds-mysql-spec.json @@ -1,6 +1,7 @@ { "properties": { "name": "Aliyun RDS MySQL", + "realName": "ApsaraDB RDS for MySQL", "icon": "icons/aliyun_rds_mysql.png", "id": "aliyun-rds-mysql", "doc": "${doc}", diff --git a/connectors/aliyun-rds-postgres-connector/src/main/resources/aliyun-rds-postgres-spec.json b/connectors/aliyun-rds-postgres-connector/src/main/resources/aliyun-rds-postgres-spec.json index adb52dd66..055044887 100644 --- a/connectors/aliyun-rds-postgres-connector/src/main/resources/aliyun-rds-postgres-spec.json +++ b/connectors/aliyun-rds-postgres-connector/src/main/resources/aliyun-rds-postgres-spec.json @@ -1,6 +1,7 @@ { "properties": { "name": "Aliyun RDS PostgreSQL", + "realName": "ApsaraDB RDS for PostgreSQL", "icon": "icons/aliyun_rds_postgres.png", "doc" : "${doc}", "id": "aliyun-rds-postgres", diff --git a/connectors/aws-clickhouse-connector/pom.xml b/connectors/aws-clickhouse-connector/pom.xml index c567a9aa9..6993350f6 100644 --- a/connectors/aws-clickhouse-connector/pom.xml +++ b/connectors/aws-clickhouse-connector/pom.xml @@ -19,7 +19,7 @@ 1.0-SNAPSHOT 3.12.0 31.0.1-jre - 2.0.0-SNAPSHOT + 2.0.5-SNAPSHOT diff --git a/connectors/aws-clickhouse-connector/src/main/resources/aws_clickhouse.json b/connectors/aws-clickhouse-connector/src/main/resources/aws_clickhouse.json index 993a0ef02..364172851 100644 --- a/connectors/aws-clickhouse-connector/src/main/resources/aws_clickhouse.json +++ b/connectors/aws-clickhouse-connector/src/main/resources/aws_clickhouse.json @@ -1,6 +1,7 @@ { "properties": { "name": "Aws Clickhouse", + "realName": "ClickHouse Cloud on AWS", "icon": "icons/clickhouse.png", "id": "aws-clickhouse", "doc": "${doc}", diff --git a/connectors/aws-rds-mysql-connector/pom.xml b/connectors/aws-rds-mysql-connector/pom.xml index cf779b9a9..6cd87fa61 100644 --- a/connectors/aws-rds-mysql-connector/pom.xml +++ b/connectors/aws-rds-mysql-connector/pom.xml @@ -23,7 +23,7 @@ 8 - 2.0.0-SNAPSHOT + 2.0.5-SNAPSHOT diff --git a/connectors/aws-rds-mysql-connector/src/main/resources/aws-rds-mysql-spec.json b/connectors/aws-rds-mysql-connector/src/main/resources/aws-rds-mysql-spec.json index cbaf71b6e..248b27a5d 100644 --- a/connectors/aws-rds-mysql-connector/src/main/resources/aws-rds-mysql-spec.json +++ b/connectors/aws-rds-mysql-connector/src/main/resources/aws-rds-mysql-spec.json @@ -1,6 +1,7 @@ { "properties": { "name": "AWS RDS MySQL", + "realName": "Amazon RDS for MySQL", "icon": "icons/aws_rds_mysql.png", "id": "aws-rds-mysql", "doc": "${doc}", diff --git a/connectors/azure-cosmosdb-connector/pom.xml b/connectors/azure-cosmosdb-connector/pom.xml index e37151837..13125f917 100644 --- a/connectors/azure-cosmosdb-connector/pom.xml +++ b/connectors/azure-cosmosdb-connector/pom.xml @@ -19,7 +19,7 @@ 8 - 2.0.0-SNAPSHOT + 2.0.5-SNAPSHOT diff --git a/connectors/azure-cosmosdb-connector/src/main/resources/azure-cosmosdb-spec.json b/connectors/azure-cosmosdb-connector/src/main/resources/azure-cosmosdb-spec.json index ac3cfa05d..bddf0037b 100644 --- a/connectors/azure-cosmosdb-connector/src/main/resources/azure-cosmosdb-spec.json +++ b/connectors/azure-cosmosdb-connector/src/main/resources/azure-cosmosdb-spec.json @@ -1,6 +1,7 @@ { "properties": { "name": "Azure CosmosDB", + "realName": "Azure Cosmos DB", "icon": "icons/azure-cosmosdb.png", "doc": "${doc}", "tags": [ diff --git a/connectors/bigquery-connector/src/main/resources/spec-v2.json b/connectors/bigquery-connector/src/main/resources/spec-v2.json index 0f5c74d11..d4ddf66ff 100644 --- a/connectors/bigquery-connector/src/main/resources/spec-v2.json +++ b/connectors/bigquery-connector/src/main/resources/spec-v2.json @@ -1,6 +1,7 @@ { "properties": { "name": "BigQuery", + "realName": "BigQuery", "icon": "icons/bigquery.png", "doc" : "${doc}", "id": "bigquery", diff --git a/connectors/bigquery-connector/src/main/resources/spec.json b/connectors/bigquery-connector/src/main/resources/spec.json index 34b2afe77..6c905bd3d 100644 --- a/connectors/bigquery-connector/src/main/resources/spec.json +++ b/connectors/bigquery-connector/src/main/resources/spec.json @@ -1,6 +1,7 @@ { "properties": { "name": "BigQuery", + "realName": "BigQuery", "icon": "icons/bigquery.png", "doc" : "${doc}", "id": "bigquery", diff --git a/connectors/clickhouse-connector/pom.xml b/connectors/clickhouse-connector/pom.xml index 8d9147681..d060f5870 100644 --- a/connectors/clickhouse-connector/pom.xml +++ b/connectors/clickhouse-connector/pom.xml @@ -15,11 +15,10 @@ 1.8 1.0-SNAPSHOT - 1.5.4.Final - 1.0-SNAPSHOT 3.12.0 31.0.1-jre - 2.0.0-SNAPSHOT + 2.0.6-SNAPSHOT + ru.yandex.clickhouse.ClickHouseDriver @@ -155,6 +154,27 @@ ${project.basedir}/../../.git + + + org.apache.maven.plugins + maven-antrun-plugin + 1.8 + + + generate-log4jdbc-properties + process-resources + + run + + + + + log4jdbc.drivers = ${origin.driver.class} + + + + + maven-resources-plugin 3.1.0 diff --git a/connectors/clickhouse-connector/src/main/java/io/tapdata/connector/clickhouse/ClickhouseConnector.java b/connectors/clickhouse-connector/src/main/java/io/tapdata/connector/clickhouse/ClickhouseConnector.java index e3c4d00ac..b3f142467 100644 --- a/connectors/clickhouse-connector/src/main/java/io/tapdata/connector/clickhouse/ClickhouseConnector.java +++ b/connectors/clickhouse-connector/src/main/java/io/tapdata/connector/clickhouse/ClickhouseConnector.java @@ -13,7 +13,6 @@ import io.tapdata.entity.event.ddl.index.TapCreateIndexEvent; import io.tapdata.entity.event.ddl.table.*; import io.tapdata.entity.event.dml.TapRecordEvent; -import io.tapdata.entity.logger.TapLogger; import io.tapdata.entity.schema.TapField; import io.tapdata.entity.schema.TapIndex; import io.tapdata.entity.schema.TapIndexField; @@ -85,14 +84,26 @@ public void onStart(TapConnectionContext connectionContext) throws SQLException protected void initConnection(TapConnectionContext connectionContext) throws SQLException { clickhouseConfig = new ClickhouseConfig().load(connectionContext.getConnectionConfig()); - isConnectorStarted(connectionContext, connectorContext -> clickhouseConfig.load(connectorContext.getNodeConfig())); + isConnectorStarted(connectionContext, connectorContext -> { + clickhouseConfig.load(connectorContext.getNodeConfig()); + clickhouseConfig.setTableConfig(connectionContext.getTableNodeConfig()); + firstConnectorId = (String) connectorContext.getStateMap().get("firstConnectorId"); + if (EmptyKit.isNull(firstConnectorId)) { + firstConnectorId = UUID.randomUUID().toString().replace("-", ""); + connectorContext.getStateMap().put("firstConnectorId", firstConnectorId); + } + }); + tapLogger = connectionContext.getLog(); + if (clickhouseConfig.getFileLog()) { + tapLogger.info("Starting Jdbc Logging, connectorId: {}", firstConnectorId); + clickhouseConfig.startJdbcLog(firstConnectorId); + } clickhouseJdbcContext = new ClickhouseJdbcContext(clickhouseConfig); commonDbConfig = clickhouseConfig; jdbcContext = clickhouseJdbcContext; clickhouseVersion = clickhouseJdbcContext.queryVersion(); dbTimeZone = TimeZone.getTimeZone(clickhouseJdbcContext.queryTimeZone()); commonSqlMaker = new ClickhouseSqlMaker().withVersion(clickhouseVersion); - tapLogger = connectionContext.getLog(); exceptionCollector = new ClickhouseExceptionCollector(); } @@ -270,10 +281,10 @@ protected CreateTableOptions createTableV2(TapConnectorContext tapConnectorConte // primary key Collection primaryKeys = tapTable.primaryKeys(true); - if (EmptyKit.isBlank(clickhouseConfig.getEngineExpr())) { + if (EmptyKit.isBlank(clickhouseConfig.getEngineExpr(tapTable.getId()))) { if (EmptyKit.isNotEmpty(primaryKeys)) { sql.append(") ENGINE = ReplacingMergeTree"); - if (clickhouseConfig.getMixFastWrite()) { + if (clickhouseConfig.getMixFastWrite(tapTable.getId())) { sql.append("(`version`)"); } sql.append(" PRIMARY KEY (").append(TapTableWriter.sqlQuota(",", primaryKeys)).append(")"); @@ -281,26 +292,26 @@ protected CreateTableOptions createTableV2(TapConnectorContext tapConnectorConte sql.append(") ENGINE = MergeTree"); } } else { - sql.append(") ENGINE = ").append(clickhouseConfig.getEngineExpr()); - if (clickhouseConfig.getSupportPk() && EmptyKit.isNotEmpty(primaryKeys)) { + sql.append(") ENGINE = ").append(clickhouseConfig.getEngineExpr(tapTable.getId())); + if (clickhouseConfig.getSupportPk(tapTable.getId()) && EmptyKit.isNotEmpty(primaryKeys)) { sql.append(" PRIMARY KEY (").append(TapTableWriter.sqlQuota(",", primaryKeys)).append(")"); } } - if (EmptyKit.isNotBlank(clickhouseConfig.getPartitionExpr())) { - sql.append(" PARTITION BY ").append(clickhouseConfig.getPartitionExpr()); + if (EmptyKit.isNotBlank(clickhouseConfig.getPartitionExpr(tapTable.getId()))) { + sql.append(" PARTITION BY ").append(clickhouseConfig.getPartitionExpr(tapTable.getId())); } - if (EmptyKit.isBlank(clickhouseConfig.getOrderExpr())) { + if (EmptyKit.isBlank(clickhouseConfig.getOrderExpr(tapTable.getId()))) { if (EmptyKit.isNotEmpty(primaryKeys)) { sql.append(" ORDER BY (").append(TapTableWriter.sqlQuota(",", primaryKeys)).append(")"); } else { sql.append(" ORDER BY tuple()"); } } else { - sql.append(" ORDER BY ").append(clickhouseConfig.getOrderExpr()); + sql.append(" ORDER BY ").append(clickhouseConfig.getOrderExpr(tapTable.getId())); } - if (EmptyKit.isNotEmpty(clickhouseConfig.getTableProperties())) { + if (EmptyKit.isNotEmpty(clickhouseConfig.getTableProperties(tapTable.getId()))) { sql.append(" SETTINGS "); - for (Map property : clickhouseConfig.getTableProperties()) { + for (Map property : clickhouseConfig.getTableProperties(tapTable.getId())) { sql.append(property.get("propKey")).append("=").append(property.get("propValue")).append(","); } sql.setLength(sql.length() - 1); diff --git a/connectors/clickhouse-connector/src/main/java/io/tapdata/connector/clickhouse/config/ClickhouseConfig.java b/connectors/clickhouse-connector/src/main/java/io/tapdata/connector/clickhouse/config/ClickhouseConfig.java index a8d018eb6..7c98f9a5c 100644 --- a/connectors/clickhouse-connector/src/main/java/io/tapdata/connector/clickhouse/config/ClickhouseConfig.java +++ b/connectors/clickhouse-connector/src/main/java/io/tapdata/connector/clickhouse/config/ClickhouseConfig.java @@ -4,6 +4,7 @@ import java.io.Serializable; import java.util.*; +import java.util.function.Function; public class ClickhouseConfig extends CommonDbConfig implements Serializable { @@ -13,7 +14,7 @@ public class ClickhouseConfig extends CommonDbConfig implements Serializable { private String engineExpr; private String partitionExpr; private String orderExpr; - private List> tableProperties = new ArrayList<>();; + private List> tableProperties = new ArrayList<>(); public ClickhouseConfig() { setDbType("clickhouse"); @@ -43,6 +44,10 @@ public Boolean getMixFastWrite() { return mixFastWrite; } + public Boolean getMixFastWrite(String key) { + return getTableConfigValue(key, "mixFastWrite", mixFastWrite); + } + public void setMixFastWrite(Boolean mixFastWrite) { this.mixFastWrite = mixFastWrite; } @@ -51,6 +56,10 @@ public Boolean getSupportPk() { return supportPk; } + public Boolean getSupportPk(String key) { + return getTableConfigValue(key, "supportPk", supportPk); + } + public void setSupportPk(Boolean supportPk) { this.supportPk = supportPk; } @@ -59,6 +68,10 @@ public String getEngineExpr() { return engineExpr; } + public String getEngineExpr(String key) { + return getTableConfigValue(key, "engineExpr", engineExpr); + } + public void setEngineExpr(String engineExpr) { this.engineExpr = engineExpr; } @@ -67,6 +80,10 @@ public String getPartitionExpr() { return partitionExpr; } + public String getPartitionExpr(String key) { + return getTableConfigValue(key, "partitionExpr", partitionExpr); + } + public void setPartitionExpr(String partitionExpr) { this.partitionExpr = partitionExpr; } @@ -75,6 +92,10 @@ public String getOrderExpr() { return orderExpr; } + public String getOrderExpr(String key) { + return getTableConfigValue(key, "orderExpr", orderExpr); + } + public void setOrderExpr(String orderExpr) { this.orderExpr = orderExpr; } @@ -83,6 +104,10 @@ public List> getTableProperties() { return tableProperties; } + public List> getTableProperties(String key) { + return getTableConfigValue(key, "tableProperties", tableProperties); + } + public void setTableProperties(List> tableProperties) { this.tableProperties = tableProperties; } diff --git a/connectors/clickhouse-connector/src/main/java/io/tapdata/connector/clickhouse/ddl/sqlmaker/ClickhouseSqlMaker.java b/connectors/clickhouse-connector/src/main/java/io/tapdata/connector/clickhouse/ddl/sqlmaker/ClickhouseSqlMaker.java index 1c8ce50f9..9750535e5 100644 --- a/connectors/clickhouse-connector/src/main/java/io/tapdata/connector/clickhouse/ddl/sqlmaker/ClickhouseSqlMaker.java +++ b/connectors/clickhouse-connector/src/main/java/io/tapdata/connector/clickhouse/ddl/sqlmaker/ClickhouseSqlMaker.java @@ -52,7 +52,7 @@ public String buildColumnDefinition(TapTable tapTable, boolean needComment) { //null to omit - if (tapField.getDefaultValue() != null && !"".equals(tapField.getDefaultValue())) { + if (Boolean.TRUE.equals(applyDefault) && tapField.getDefaultValue() != null && !"".equals(tapField.getDefaultValue())) { builder.append("DEFAULT").append(' '); if (tapField.getDefaultValue() instanceof Number) { builder.append(tapField.getDefaultValue()).append(' '); diff --git a/connectors/clickhouse-connector/src/main/resources/spec_clickhouse.json b/connectors/clickhouse-connector/src/main/resources/spec_clickhouse.json index bac4e3817..0cb639403 100644 --- a/connectors/clickhouse-connector/src/main/resources/spec_clickhouse.json +++ b/connectors/clickhouse-connector/src/main/resources/spec_clickhouse.json @@ -1,6 +1,7 @@ { "properties": { "name": "Clickhouse", + "realName": "ClickHouse", "icon": "icons/clickhouse.png", "id": "clickhouse", "doc": "${doc}", @@ -223,12 +224,14 @@ }, "node": { "type": "object", + "x-tableConfigScope": ["target"], "properties": { "supportPk": { "type": "boolean", "title": "${supportPk}", "default": true, "x-index": 1, + "x-perTable": true, "x-decorator": "FormItem", "x-component": "Switch", "x-reactions": [ @@ -248,6 +251,7 @@ "x-decorator": "FormItem", "x-component": "Input", "x-index": 2, + "x-perTable": true, "x-decorator-props": { "tooltip": "${engineExprTooltip}" } @@ -258,6 +262,7 @@ "x-decorator": "FormItem", "x-component": "Input", "x-index": 3, + "x-perTable": true, "x-decorator-props": { "tooltip": "${partitionExprTooltip}" } @@ -268,6 +273,7 @@ "x-decorator": "FormItem", "x-component": "Input", "x-index": 4, + "x-perTable": true, "x-decorator-props": { "tooltip": "${orderExprTooltip}" } @@ -288,6 +294,7 @@ } ], "x-index": 5, + "x-perTable": true, "items": { "type": "object", "properties": { @@ -333,6 +340,28 @@ } } }, + "applyDefault": { + "type": "boolean", + "title": "${applyDefault}", + "default": false, + "x-index": 7, + "x-perTable": true, + "x-decorator": "FormItem", + "x-component": "Switch", + "x-decorator-props": { + "tooltip": "${applyDefaultTooltip}" + }, + "x-reactions": [ + { + "dependencies": ["$inputs"], + "fulfill": { + "state": { + "display": "{{$deps[0].length > 0 ? \"visible\":\"hidden\"}}" + } + } + } + ] + }, "mergeMinutes": { "required": true, "type": "string", @@ -359,6 +388,7 @@ "title": "${mixFastWrite}", "default": false, "x-index": 12, + "x-perTable": true, "x-decorator": "FormItem", "x-component": "Switch", "x-decorator-props": { @@ -407,6 +437,8 @@ "mergeMinutes": "Optimize Interval (Minutes)", "mixFastWrite": "Mix Fast Write", "mixFastWriteTooltip": "Enable mix fast write, but the target table will create is_deleted, delete_time columns, insert strategy is used for insert, update, delete, greatly improve performance", + "applyDefault": "Apply default value", + "applyDefaultTooltip": "When the switch is turned on, the default value will be applied to the target. If there are unadapted functions or expressions, it may cause an error", "doc": "docs/clickhouse_en_US.md" }, "zh_CN": { @@ -436,6 +468,8 @@ "mergeMinutes": "合并分区间隔(分钟)", "mixFastWrite": "混合快速写入", "mixFastWriteTooltip": "启用混合快速写入,但目标表会多创建is_deleted, delete_time列, 增删改统一使用插入策略大大提升性能", + "applyDefault": "应用默认值", + "applyDefaultTooltip": "开关打开时会将默认值应用到目标,如果有未适配的函数或表达式,可能会导致报错", "doc": "docs/clickhouse_zh_CN.md" }, "zh_TW": { @@ -465,6 +499,8 @@ "mergeMinutes": "合併分區間隔(分鐘)", "mixFastWrite": "混合快速寫入", "mixFastWriteTooltip": "啟用混合快速寫入,但目標表會多創建is_deleted, delete_time列, 增刪改統一使用插入策略大大提升性能", + "applyDefault": "應用默認值", + "applyDefaultTooltip": "開關打開時會將默認值應用到目標,如果有未适配的函数或表达式,可能会导致报错", "doc": "docs/clickhouse_zh_TW.md" } }, diff --git a/connectors/coding-connector/src/main/resources/spec-oauth.json b/connectors/coding-connector/src/main/resources/spec-oauth.json index 39f0f6843..41c1197f3 100644 --- a/connectors/coding-connector/src/main/resources/spec-oauth.json +++ b/connectors/coding-connector/src/main/resources/spec-oauth.json @@ -1,6 +1,7 @@ { "properties": { "name": "Coding", + "realName": "Coding", "icon": "icons/coding.png", "doc" : "${doc}", "id": "coding", diff --git a/connectors/coding-connector/src/main/resources/spec.json b/connectors/coding-connector/src/main/resources/spec.json index 936adb03d..d6972cff7 100644 --- a/connectors/coding-connector/src/main/resources/spec.json +++ b/connectors/coding-connector/src/main/resources/spec.json @@ -1,6 +1,7 @@ { "properties": { "name": "Coding", + "realName": "Coding", "icon": "icons/coding.png", "doc" : "${doc}", "id": "coding", diff --git a/connectors/connector-perf-test/pom.xml b/connectors/connector-perf-test/pom.xml new file mode 100644 index 000000000..db7b3c101 --- /dev/null +++ b/connectors/connector-perf-test/pom.xml @@ -0,0 +1,194 @@ + + + 4.0.0 + + io.tapdata + connector-perf-test + 1.0-SNAPSHOT + jar + + Paimon Connector Performance Test + Paimon 写入性能参数调优测试 - 独立 Maven 工程 + + + + 11 + 11 + UTF-8 + 11 + + 1.3.1 + 3.3.6 + 2.5-SNAPSHOT + 2.0.6-SNAPSHOT + 1.12.600 + 4.11.0 + 5.8.1 + + + + + + org.slf4j + slf4j-api + 2.0.12 + + + ch.qos.logback + logback-classic + 1.5.13 + + + + io.tapdata + paimon-connector + 1.0-SNAPSHOT + + + + + io.tapdata + tapdata-pdk-runner + ${tapdata.pdk.runner.version} + + + + + io.tapdata + tapdata-pdk-api + ${tapdata.api.version} + + + + + io.tapdata + tapdata-api + ${tapdata.api.version} + + + + org.apache.paimon + paimon-core + ${paimon.version} + + + org.apache.paimon + paimon-common + ${paimon.version} + + + org.apache.paimon + paimon-format + ${paimon.version} + + + + org.apache.hadoop + hadoop-common + ${hadoop.version} + + log4jlog4j + org.slf4jslf4j-log4j12 + + + + org.apache.hadoop + hadoop-hdfs + ${hadoop.version} + + + + com.amazonaws + aws-java-sdk-s3 + ${aws.sdk.version} + + + + org.apache.commons + commons-lang3 + 3.12.0 + + + + + org.mockito + mockito-core + ${mockito.version} + + + + org.junit.jupiter + junit-jupiter + ${junit.jupiter.version} + test + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.11.0 + + ${java.version} + ${java.version} + UTF-8 + + + + + org.apache.maven.plugins + maven-surefire-plugin + 2.22.2 + + + + **/S3FileScanDebugTest.java + + 300 + + + + + + org.codehaus.mojo + exec-maven-plugin + 3.1.0 + + io.tapdata.connector.paimon.perf.PerformanceTestRunner + false + compile + + + + + + + + + nexus-releases + nexus-maven-release + https://nexus.tapdata.net/repository/maven-releases/ + true + false + + + nexus-snapshots + nexus-maven-snapshot + https://nexus.tapdata.net/repository/maven-snapshots/ + false + true + + + tapdata-tapdata-maven + https://tapdata-maven.pkg.coding.net/repository/tapdata/maven/ + true + true + + + diff --git a/connectors/connector-perf-test/run-perf-test.sh b/connectors/connector-perf-test/run-perf-test.sh new file mode 100755 index 000000000..1020a96e1 --- /dev/null +++ b/connectors/connector-perf-test/run-perf-test.sh @@ -0,0 +1,141 @@ +#!/bin/bash +################################################################################ +# Paimon 写入性能参数调优测试 - 一键启动脚本(独立工程版) +# +# 测试模式配置已统一到 TestModeConfig.java +# 新增测试模式只需修改 TestModeConfig.java,无需修改此脚本 +# +# 用法: ./run-perf-test.sh [模式] +# +# 可用模式(详见 TestModeConfig.java): +# 1 basic - 基础用例组(TC-01~03) 默认 +# 2 all - 全量测试(所有组) +# 3 nosmallfile - 无小文件测试(TC-50~54) +# 4 single - 单个基准用例(TC-01) +# 5 bucket - 分桶策略测试(TC-30~35) +# 6 compaction - 合并策略测试(TC-40~45) +# 7 buffer - 写入缓冲区测试(TC-10~16) +# 8 target - 目标文件大小测试(TC-20~23) +# 9 format - 文件格式压缩测试(TC-60~64) +# 10 pkupdate - 主键更新测试(TC-70~73) +# 11 parallelism - 写入并行度测试(TC-80~83) +# auto - 全自动运行(无需交互) +# 空/无参数 - 交互式选择 +################################################################################ + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR" + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' + +echo -e "${BLUE}" +echo "════════════════════════════════════════════════════════════" +echo " Paimon 1.3.1 写入性能参数调优测试" +echo " 测试仓库: /tmp/paimon-perf-test" +echo "════════════════════════════════════════════════════════════" +echo -e "${NC}" + +# ── 帮助信息 ────────────────────────────────────────────────────────────── +if [ "$1" = "--help" ] || [ "$1" = "-h" ]; then + echo "用法: ./run-perf-test.sh [模式]" + echo "" + echo "可用模式:" + echo " 1 basic - 基础用例组(TC-01~03)" + echo " 2 all - 全量测试(所有组)" + echo " 3 nosmallfile - 无小文件测试(TC-50~54)" + echo " 4 single - 单个基准用例(TC-01)" + echo " 5 bucket - 分桶策略测试(TC-30~35)" + echo " 6 compaction - 合并策略测试(TC-40~45)" + echo " 7 buffer - 写入缓冲区测试(TC-10~16)" + echo " 8 target - 目标文件大小测试(TC-20~23)" + echo " 9 format - 文件格式压缩测试(TC-60~64)" + echo " 10 pkupdate - 主键更新测试(TC-70~73)" + echo " 11 parallelism - 写入并行度测试(TC-80~83)" + echo " auto - 全自动运行(无需按回车)" + echo "" + echo "默认模式: basic (1)" + exit 0 +fi + +# ── 环境检查 ────────────────────────────────────────────────────────────────── +if ! command -v java &>/dev/null; then + echo -e "${RED}[ERROR] 未找到 Java,请先安装 JDK 11+${NC}"; exit 1 +fi +echo -e "${GREEN}[INFO] Java : $(java -version 2>&1 | head -1)${NC}" + +if ! command -v mvn &>/dev/null; then + echo -e "${RED}[ERROR] 未找到 Maven${NC}"; exit 1 +fi +echo -e "${GREEN}[INFO] Maven: $(mvn -version 2>&1 | head -1)${NC}" +echo "" + +# ── 获取测试模式 ──────────────────────────────────────────────────────────── +TEST_MODE="${1:-}" + +# ── 编译 paimon-connector 主工程(兄弟目录) ────────────────────────────── +echo -e "${YELLOW}[1/3] 编译 paimon-connector 主工程...${NC}" +mvn clean install -DskipTests -q -f ../paimon-connector/pom.xml +echo -e "${GREEN} 主工程编译完成${NC}" +echo "" + +# ── 编译性能测试代码(src/main/java,标准 compile goal) ──────────────────── +echo -e "${YELLOW}[2/3] 编译性能测试代码...${NC}" +mvn compile -q +echo -e "${GREEN} 测试代码编译完成${NC}" +echo "" + +# ── 运行测试 ───────────────────────────────────────────────────────────────── +echo -e "${YELLOW}[3/3] 启动测试程序...${NC}" +echo "" + +# JVM 参数(-Xmx4g 保证有足够堆测试大缓冲区场景) +JVM_OPTS="-Xmx4g -Xms512m -XX:+UseG1GC" + +if [ -n "$TEST_MODE" ]; then + echo -e "${BLUE}>> 测试模式: ${TEST_MODE}${NC}" + mvn exec:java \ + -Dexec.cleanupDaemonThreads=false \ + -Dexec.args="${TEST_MODE}" \ + -Dexec.jvmArgs="${JVM_OPTS}" +else + # 交互式菜单 + echo -e "${BLUE}请选择测试模式 (直接回车默认 1):${NC}" + echo " 1 basic - 基础用例组(TC-01~03)" + echo " 2 all - 全量测试(所有组)" + echo " 3 nosmallfile - 无小文件测试(TC-50~54)" + echo " 4 single - 单个基准用例(TC-01)" + echo " 5 bucket - 分桶策略测试(TC-30~35)" + echo " 6 compaction - 合并策略测试(TC-40~45)" + echo " 7 buffer - 写入缓冲区测试(TC-10~16)" + echo " 8 target - 目标文件大小测试(TC-20~23)" + echo " 9 format - 文件格式压缩测试(TC-60~64)" + echo " 10 pkupdate - 主键更新测试(TC-70~73)" + echo " 11 parallelism - 写入并行度测试(TC-80~83)" + echo " auto - 全自动运行(无需按回车)" + echo "" + read -r -p " 请输入选项 [1]: " CHOICE + CHOICE="${CHOICE:-1}" + echo "" + echo -e "${BLUE}>> 测试模式: ${CHOICE}${NC}" + mvn exec:java \ + -Dexec.cleanupDaemonThreads=false \ + -Dexec.args="${CHOICE}" \ + -Dexec.jvmArgs="${JVM_OPTS}" +fi + +REPORT_FILE="/tmp/paimon-perf-test/test-report.md" +echo "" +echo -e "${GREEN}════════════════════════════════════════════════════════════${NC}" +echo -e "${GREEN} 测试完成!${NC}" +if [ -f "${REPORT_FILE}" ]; then + echo -e "${GREEN} 测试报告: ${REPORT_FILE}${NC}" + echo -e "${YELLOW} 查看报告: cat ${REPORT_FILE}${NC}" +fi +echo -e "${YELLOW} 查看文件: ls -lhR /tmp/paimon-perf-test/TC-*/${NC}" +echo -e "${GREEN}════════════════════════════════════════════════════════════${NC}" diff --git a/connectors/connector-perf-test/src/main/java/io/tapdata/connector/paimon/perf/AutoTestRunner.java b/connectors/connector-perf-test/src/main/java/io/tapdata/connector/paimon/perf/AutoTestRunner.java new file mode 100644 index 000000000..21b3df252 --- /dev/null +++ b/connectors/connector-perf-test/src/main/java/io/tapdata/connector/paimon/perf/AutoTestRunner.java @@ -0,0 +1,348 @@ +package io.tapdata.connector.paimon.perf; + +import org.apache.paimon.catalog.*; +import org.apache.paimon.data.InternalRow; +import org.apache.paimon.options.Options; +import org.apache.paimon.schema.Schema; +import org.apache.paimon.table.FileStoreTable; +import org.apache.paimon.table.sink.StreamTableCommit; +import org.apache.paimon.table.sink.StreamTableWrite; +import org.apache.paimon.table.sink.StreamWriteBuilder; +import org.apache.paimon.types.DataTypes; + +import java.io.File; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.text.SimpleDateFormat; +import java.util.*; +import java.util.concurrent.atomic.AtomicLong; + +/** + * Paimon 写入性能参数调优测试运行器 - 自动模式(无需交互) + */ +public class AutoTestRunner { + + private static final String WAREHOUSE_BASE = "/tmp/paimon-perf-test-" + System.currentTimeMillis(); + private static final String DATABASE = "perf_db"; + private static final List PRIMARY_KEYS = Collections.singletonList("id"); + + private static final List TEST_CASES = Arrays.asList( + new TestCase("CASE-001", "基准配置 (Baseline)", + map("bucket", "-1", "write-buffer-size", "256mb", "target-file-size", "128mb", + "file.format", "parquet", "compaction.async.enabled", "true", + "num-sorted-run.compaction-trigger", "3", "num-sorted-run.stop-trigger", "5", + "commit.force-compact", "false", "sink.parallelism", "4"), + 50000, 0.3), + + new TestCase("CASE-002", "大 write-buffer (512MB)", + map("bucket", "-1", "write-buffer-size", "512mb", "target-file-size", "256mb", + "file.format", "parquet", "compaction.async.enabled", "true", + "num-sorted-run.compaction-trigger", "3", "num-sorted-run.stop-trigger", "5", + "commit.force-compact", "false", "sink.parallelism", "4"), + 50000, 0.3), + + new TestCase("CASE-003", "超大 write-buffer (1GB) + 大文件 (512MB)", + map("bucket", "-1", "write-buffer-size", "1gb", "target-file-size", "512mb", + "file.format", "parquet", "compaction.async.enabled", "true", + "num-sorted-run.compaction-trigger", "5", "num-sorted-run.stop-trigger", "10", + "commit.force-compact", "false", "sink.parallelism", "8"), + 50000, 0.3), + + new TestCase("CASE-004", "bucket=-2 (延迟分桶/Deferred Bucket)", + map("bucket", "-2", "write-buffer-size", "512mb", "target-file-size", "256mb", + "file.format", "parquet", "compaction.async.enabled", "true", + "num-sorted-run.compaction-trigger", "3", "num-sorted-run.stop-trigger", "8", + "commit.force-compact", "false", "sink.parallelism", "4"), + 50000, 0.3), + + new TestCase("CASE-005", "无 Compaction (关闭合并)", + map("bucket", "-1", "write-buffer-size", "512mb", "target-file-size", "256mb", + "file.format", "parquet", "compaction.async.enabled", "false", + "commit.force-compact", "false", "sink.parallelism", "4"), + 50000, 0.3), + + new TestCase("CASE-006", "ORC 格式 + 大缓冲区", + map("bucket", "-1", "write-buffer-size", "512mb", "target-file-size", "256mb", + "file.format", "orc", "compaction.async.enabled", "true", + "num-sorted-run.compaction-trigger", "3", "num-sorted-run.stop-trigger", "5", + "commit.force-compact", "false", "sink.parallelism", "4"), + 50000, 0.3), + + new TestCase("CASE-007", "高并行度 (sink.parallelism=16)", + map("bucket", "-1", "write-buffer-size", "512mb", "target-file-size", "256mb", + "file.format", "parquet", "compaction.async.enabled", "true", + "num-sorted-run.compaction-trigger", "5", "num-sorted-run.stop-trigger", "10", + "commit.force-compact", "false", "sink.parallelism", "16"), + 50000, 0.3) + ); + + public static void main(String[] args) throws Exception { + printHeader(); + + List results = new ArrayList<>(); + + for (TestCase tc : TEST_CASES) { + System.out.println("\n" + "─".repeat(70)); + System.out.println(">>> 执行: " + tc.id + " - " + tc.name); + System.out.println("─".repeat(70)); + + TestResult result = runSingleTest(tc); + results.add(result); + + printResult(result); + } + + generateReport(results); + } + + private static TestResult runSingleTest(TestCase tc) throws Exception { + String warehouse = WAREHOUSE_BASE + "/" + tc.id; + Files.createDirectories(Paths.get(warehouse)); + + System.out.println("\n [配置]"); + System.out.printf(" 数据量: %,d 条 | 重复率: %.0f%%%n", tc.totalRecords, tc.duplicateRate * 100); + System.out.println(" 参数:"); + for (Map.Entry e : tc.tableOptions.entrySet()) { + System.out.printf(" %-40s = %s%n", e.getKey(), e.getValue()); + } + + Options options = new Options(); + options.set("warehouse", warehouse); + CatalogContext context = CatalogContext.create(options); + Catalog catalog = CatalogFactory.createCatalog(context); + + try { + catalog.createDatabase(DATABASE, true); + + String tableName = "test_table"; + Identifier tableId = Identifier.create(DATABASE, tableName); + + Schema.Builder schemaBuilder = Schema.newBuilder() + .column("id", DataTypes.BIGINT().notNull()) + .column("balance_detail_id", DataTypes.STRING()) + .column("before_detail_balance", DataTypes.DECIMAL(18, 0)) + .column("amount", DataTypes.DECIMAL(18, 0)) + .column("expiry_date", DataTypes.TIMESTAMP(6)) + .column("compor_id", DataTypes.STRING()) + .column("transaction_type", DataTypes.STRING()) + .column("channel", DataTypes.STRING()) + .column("pos_reference", DataTypes.STRING()) + .column("outlet", DataTypes.STRING()) + .column("remark", DataTypes.STRING()) + .column("created_time", DataTypes.TIMESTAMP(6)) + .column("payment_detail_id", DataTypes.STRING()) + .column("payment_id", DataTypes.BIGINT()) + .column("created_by", DataTypes.STRING()) + .column("op", DataTypes.STRING()) + .column("after_detail_balance", DataTypes.DECIMAL(18, 0)) + .column("source_system", DataTypes.STRING()) + .column("dollar_type_id", DataTypes.STRING()) + .column("exception_balance", DataTypes.DECIMAL(18, 0)) + .column("patron_id", DataTypes.STRING()) + .column("source_key", DataTypes.STRING()) + .column("device_id", DataTypes.STRING()) + .column("after_balance", DataTypes.DECIMAL(18, 0)) + .column("before_balance", DataTypes.DECIMAL(18, 0)) + .column("outlet_code", DataTypes.STRING()) + .column("ods_updated_at", DataTypes.TIMESTAMP(3)) + .column("property", DataTypes.STRING()) + .column("pt_created_date", DataTypes.INT()) + .primaryKey(PRIMARY_KEYS); + + for (Map.Entry opt : tc.tableOptions.entrySet()) { + schemaBuilder.option(opt.getKey(), opt.getValue()); + } + + catalog.createTable(tableId, schemaBuilder.build(), false); + FileStoreTable table = (FileStoreTable) catalog.getTable(tableId); + + System.out.println(" [写入] 开始..."); + + long startTime = System.currentTimeMillis(); + AtomicLong writeCount = new AtomicLong(0); + + StreamWriteBuilder writeBuilder = table.newStreamWriteBuilder(); + try (StreamTableWrite writer = writeBuilder.newWrite(); + StreamTableCommit committer = writeBuilder.newCommit()) { + + PaimonDataGenerator generator = new PaimonDataGenerator(tc.totalRecords, 0, tc.duplicateRate, 10000); + + while (generator.hasMore()) { + InternalRow row = generator.nextRecord(); + if (row == null) break; + + // 在动态分桶模式下,使用 0 作为默认 bucket 值 + writer.write(row, 0); + writeCount.incrementAndGet(); + + if (writeCount.get() % 10000 == 0) { + long cpId = writeCount.get() / 10000; + List messages = writer.prepareCommit(false, cpId); + if (!messages.isEmpty()) { + committer.commit(cpId, messages); + } + } + } + + long finalCp = (writeCount.get() / 10000) + 1; + List finalMessages = writer.prepareCommit(true, finalCp); + if (!finalMessages.isEmpty()) { + committer.commit(finalCp, finalMessages); + } + } + + long elapsed = System.currentTimeMillis() - startTime; + double throughput = writeCount.get() * 1000.0 / Math.max(elapsed, 1); + + System.out.printf(" [完成] %,d 条, %,d ms, %.0f 条/秒%n", + writeCount.get(), elapsed, throughput); + + FileScanResult fileScan = scanFiles(warehouse); + + return new TestResult(tc, writeCount.get(), elapsed, throughput, fileScan); + + } finally { + catalog.close(); + } + } + + private static FileScanResult scanFiles(String warehouse) throws Exception { + FileScanResult result = new FileScanResult(); + String tablePath = warehouse + "/" + DATABASE + ".db/test_table"; + File tableDir = new File(tablePath); + + if (!tableDir.exists()) return result; + scanDirectory(tableDir, result); + return result; + } + + private static void scanDirectory(File dir, FileScanResult result) { + File[] files = dir.listFiles(); + if (files == null) return; + + for (File f : files) { + if (f.isDirectory()) { + scanDirectory(f, result); + } else if (f.getName().endsWith(".parquet") || f.getName().endsWith(".orc")) { + result.fileCount++; + result.totalSize += f.length(); + result.files.add(new FileInfo(f.getPath(), f.length())); + } + } + } + + private static void printHeader() { + System.out.println("\n" + "═".repeat(70)); + System.out.println(" Paimon 1.3.1 写入性能参数调优测试 (自动模式)"); + System.out.println(" 日期: " + new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(new Date())); + System.out.println(" Warehouse: " + WAREHOUSE_BASE); + System.out.println(" CPU: " + Runtime.getRuntime().availableProcessors() + " 核"); + System.out.println(" JVM: " + (Runtime.getRuntime().maxMemory() / 1024 / 1024) + " MB"); + System.out.println("═".repeat(70)); + + System.out.println("\n【测试用例】"); + for (TestCase tc : TEST_CASES) { + System.out.printf(" %-10s | %-40s | %,d 条%n", tc.id, tc.name, tc.totalRecords); + } + } + + private static void printResult(TestResult result) { + System.out.printf("%n [结果] %s%n", result.testCase.id); + System.out.printf(" 吞吐: %.0f 条/秒 | 文件: %d 个 | 大小: %.2f MB%n", + result.throughput, result.fileScan.fileCount, result.fileScan.totalSize / 1024.0 / 1024.0); + } + + private static void generateReport(List results) throws Exception { + System.out.println("\n" + "═".repeat(70)); + System.out.println(" 测试报告"); + System.out.println("═".repeat(70)); + + System.out.printf("%n%-10s | %-8s | %-8s | %-10s | %-8s | %-10s%n", + "用例", "记录数", "耗时(ms)", "吞吐(条/s)", "文件数", "大小(MB)"); + System.out.println("-".repeat(70)); + + TestResult best = null, fewest = null; + + for (TestResult r : results) { + System.out.printf("%-10s | %,8d | %,8d | %,10.0f | %,8d | %,10.2f%n", + r.testCase.id, r.recordCount, r.elapsedMs, r.throughput, + r.fileScan.fileCount, r.fileScan.totalSize / 1024.0 / 1024.0); + + if (best == null || r.throughput > best.throughput) best = r; + if (fewest == null || r.fileScan.fileCount < fewest.fileScan.fileCount) fewest = r; + } + + System.out.println("\n【核心结论】"); + System.out.println("1. 最高吞吐: " + best.testCase.id + " (" + best.testCase.name + ")"); + System.out.printf(" 吞吐量: %.0f 条/秒%n", best.throughput); + + System.out.println("\n2. 最少文件: " + fewest.testCase.id + " (" + fewest.testCase.name + ")"); + System.out.printf(" 文件数: %d 个, 总大小: %.2f MB%n", + fewest.fileScan.fileCount, fewest.fileScan.totalSize / 1024.0 / 1024.0); + + TestResult bucketDeferred = results.stream() + .filter(r -> r.testCase.id.equals("CASE-004")).findFirst().orElse(null); + + if (bucketDeferred != null) { + System.out.println("\n3. bucket=-2 (延迟分桶):"); + System.out.printf(" 吞吐: %.0f vs 基准 %.0f (%.1f%%)%n", + bucketDeferred.throughput, results.get(0).throughput, + bucketDeferred.throughput / results.get(0).throughput * 100 - 100); + } + + System.out.println("\n【推荐生产参数】"); + System.out.println(" 'write-buffer-size' = '512mb'"); + System.out.println(" 'target-file-size' = '256mb'"); + System.out.println(" 'file.format' = 'parquet'"); + System.out.println(" 'compaction.async.enabled' = 'true'"); + System.out.println(" 'num-sorted-run.compaction-trigger' = '5'"); + System.out.println(" 'num-sorted-run.stop-trigger' = '10'"); + System.out.println(" 'commit.force-compact' = 'false'"); + System.out.println(" 'sink.parallelism' = '8'"); + + System.out.println("\n" + "═".repeat(70)); + System.out.println(" Warehouse: " + WAREHOUSE_BASE); + System.out.println(" 查看: ls -lh " + WAREHOUSE_BASE); + System.out.println(" 详情: du -sh " + WAREHOUSE_BASE + "/*"); + System.out.println("═".repeat(70) + "\n"); + } + + private static Map map(String... kvs) { + Map m = new LinkedHashMap<>(); + for (int i = 0; i < kvs.length; i += 2) m.put(kvs[i], kvs[i + 1]); + return m; + } + + static class TestCase { + String id, name; + Map tableOptions; + long totalRecords; + double duplicateRate; + TestCase(String id, String name, Map opts, long records, double dupRate) { + this.id = id; this.name = name; this.tableOptions = opts; + this.totalRecords = records; this.duplicateRate = dupRate; + } + } + + static class TestResult { + TestCase testCase; + long recordCount, elapsedMs; + double throughput; + FileScanResult fileScan; + TestResult(TestCase tc, long records, long elapsed, double tp, FileScanResult scan) { + this.testCase = tc; this.recordCount = records; this.elapsedMs = elapsed; + this.throughput = tp; this.fileScan = scan; + } + } + + static class FileScanResult { + int fileCount = 0; + long totalSize = 0; + List files = new ArrayList<>(); + } + + static class FileInfo { + String path; long size; + FileInfo(String p, long s) { path = p; size = s; } + } +} diff --git a/connectors/connector-perf-test/src/main/java/io/tapdata/connector/paimon/perf/DataGenerator.java b/connectors/connector-perf-test/src/main/java/io/tapdata/connector/paimon/perf/DataGenerator.java new file mode 100644 index 000000000..94879c2c0 --- /dev/null +++ b/connectors/connector-perf-test/src/main/java/io/tapdata/connector/paimon/perf/DataGenerator.java @@ -0,0 +1,329 @@ +package io.tapdata.connector.paimon.perf; + +import io.tapdata.entity.event.dml.TapInsertRecordEvent; +import io.tapdata.entity.event.dml.TapRecordEvent; +import io.tapdata.entity.event.dml.TapUpdateRecordEvent; +import io.tapdata.entity.schema.TapField; +import io.tapdata.entity.schema.TapTable; +import io.tapdata.entity.schema.value.*; + +import java.sql.Timestamp; +import java.util.*; +import java.util.concurrent.atomic.AtomicLong; +import java.util.function.Consumer; + +/** + * 数据生成器 - 支持配置总数据量、写入QPS、主键重复率 + * + *

内存优化设计(支持 10 亿+ 规模): + *

    + *
  • 零存储重复池:使用 MurmurHash3 确定性伪随机选择重复 ID,无需缓存历史
  • + *
  • 内存 O(1):无论 1 亿还是 100 亿条,内存恒定 < 1 MB
  • + *
  • ID 按需生成:long → String 按需转换,消除字符串缓存
  • + *
+ * + *

内存对比: + *

+ *   优化前(HashSet + ArrayList):
+ *     1 亿条 → ~5 GB
+ *    10 亿条 → OOM (>50 GB)
+ *
+ *   优化后(确定性伪随机):
+ *     1 亿条 → < 1 MB
+ *    10 亿条 → < 1 MB ✅
+ *   100 亿条 → < 1 MB ✅
+ * 
+ * + *

重复 ID 生成原理: + *

+ *   当需要重复 ID 时:
+ *     1. 确定重复池大小 = min(当前已生成唯一 ID 数, 上限值)
+ *     2. 使用 MurmurHash3(currentId + seed) 计算哈希
+ *     3. 取模映射到重复池:hash % poolSize
+ *     4. 确定性:相同输入 → 相同输出,无需存储
+ *
+ *   优点:
+ *     - 均匀分布:哈希函数保证重复 ID 分散
+ *     - 可重现:固定 seed 下结果一致,便于调试
+ *     - 零存储:不保留任何历史 ID
+ * 
+ */ +public class DataGenerator { + private final Random random; + private final AtomicLong idGenerator; + + /** + * 确定性重复 ID 生成的种子(固定值保证可重现) + * 可修改此值改变重复分布,但相同种子下结果一致 + */ + private static final long DUPLICATE_SEED = 0x517cc1b727220a95L; + + /** + * 重复 ID 池大小上限 + * 当已生成唯一 ID 超过此值时,重复池固定为此大小 + * 避免取模运算溢出,同时保证重复 ID 分布均匀 + */ + private static final int MAX_DUPLICATE_POOL = 100_000_000; // 1 亿 + + private final int primaryKeyDuplicateRate; + private final String tableName; + + public DataGenerator(int primaryKeyDuplicateRate) { + this(primaryKeyDuplicateRate, "test_table"); + } + + public DataGenerator(int primaryKeyDuplicateRate, String tableName) { + this.random = new Random(System.currentTimeMillis()); + this.idGenerator = new AtomicLong(1); + this.primaryKeyDuplicateRate = Math.max(0, Math.min(100, primaryKeyDuplicateRate)); + this.tableName = tableName; + } + + /** + * MurmurHash3 32-bit 简化实现 + * 用于确定性伪随机映射:相同输入 → 相同输出 + */ + private static int murmurHash3_32(long key) { + long h = key ^ (key >>> 33); + h *= 0xff51afd7ed558ccdL; + h ^= h >>> 33; + h *= 0xc4ceb9fe1a85ec53L; + h ^= h >>> 33; + return (int) h; + } + + /** + * 确定性生成一个重复 ID(无需存储历史) + * + * @param currentId 当前记录的 ID + * @param poolSize 重复池大小(已生成的唯一 ID 数) + * @return 映射到重复池中的 ID(1 ~ poolSize) + */ + private static long deterministicDuplicateId(long currentId, long poolSize) { + if (poolSize <= 0) { + return currentId; // 无可用重复 ID + } + // 使用 MurmurHash3 计算确定性哈希 + int hash = murmurHash3_32(currentId ^ DUPLICATE_SEED); + // 映射到 [1, poolSize] 范围 + long offset = (hash & 0x7fffffffL) % poolSize; + return 1 + offset; + } + + /** + * 将 long ID 转为 String(按需创建,不缓存) + */ + private static String idToString(long id) { + return Long.toString(id); + } + + /** + * 生成单个记录 + */ + public Map generateRecord() { + Map record = new HashMap<>(); + + // 生成主键 + long idLong; + long currentUniqueCount = idGenerator.get(); // 当前已生成的唯一 ID 数(未包含本次) + + if (primaryKeyDuplicateRate > 0 && currentUniqueCount > 1 + && random.nextInt(100) < primaryKeyDuplicateRate) { + // 生成重复主键(用于 UPDATE 场景) + // 使用确定性伪随机映射,无需存储历史 ID + long poolSize = Math.min(currentUniqueCount - 1, MAX_DUPLICATE_POOL); + // 使用当前时间戳 + 随机值作为种子,确保每次调用有不同的哈希输入 + long seed = System.nanoTime() ^ random.nextLong(); + idLong = deterministicDuplicateId(seed, poolSize); + // 注意:不增加 idGenerator,因为这是重复 ID + } else { + // 生成新主键 + idLong = idGenerator.getAndIncrement(); + } + + String id = idToString(idLong); + record.put("id", id); + record.put("name", "name-" + id + "-" + System.currentTimeMillis()); + record.put("value", random.nextInt(1000000)); + record.put("ts", new Timestamp(System.currentTimeMillis())); + + return record; + } + + /** + * 生成批量记录事件 + */ + public List generateRecordEvents(int batchSize) { + List events = new ArrayList<>(batchSize); + + for (int i = 0; i < batchSize; i++) { + Map record = generateRecord(); + TapInsertRecordEvent event = new TapInsertRecordEvent(); + event.setAfter(record); + event.setTableId(tableName); + event.setReferenceTime(System.currentTimeMillis()); + events.add(event); + } + + return events; + } + + /** + * 生成更新事件 + */ + public TapUpdateRecordEvent generateUpdateEvent() { + long currentUniqueCount = idGenerator.get(); + if (currentUniqueCount <= 1) { + return null; + } + + // 使用确定性伪随机生成重复 ID + long poolSize = Math.min(currentUniqueCount - 1, MAX_DUPLICATE_POOL); + long dummyId = random.nextLong(); + long idLong = deterministicDuplicateId(dummyId, poolSize); + String id = idToString(idLong); + + Map before = new HashMap<>(); + before.put("id", id); + before.put("name", "name-" + id); + before.put("value", random.nextInt(1000000)); + before.put("ts", new Timestamp(System.currentTimeMillis() - 86400000L)); + + Map after = new HashMap<>(); + after.put("id", id); + after.put("name", "name-" + id + "-updated-" + System.currentTimeMillis()); + after.put("value", random.nextInt(1000000)); + after.put("ts", new Timestamp(System.currentTimeMillis())); + + TapUpdateRecordEvent event = new TapUpdateRecordEvent(); + event.setBefore(before); + event.setAfter(after); + event.setTableId(tableName); + event.setReferenceTime(System.currentTimeMillis()); + + return event; + } + + /** + * 生成TapTable结构 + */ + public TapTable generateTapTable() { + TapTable table = new TapTable(); + table.setName(tableName); + table.setId(tableName); + + // 添加字段 - 使用正确的TapField构造函数 + TapField idField = new TapField(); + idField.setName("id"); + idField.setDataType("VARCHAR"); + idField.setNullable(true); + idField.setPrimaryKey(true); + idField.setPrimaryKeyPos(1); + table.add(idField); + + TapField nameField = new TapField(); + nameField.setName("name"); + nameField.setDataType("VARCHAR"); + nameField.setNullable(true); + table.add(nameField); + + TapField valueField = new TapField(); + valueField.setName("value"); + valueField.setDataType("INTEGER"); + valueField.setNullable(true); + table.add(valueField); + + TapField tsField = new TapField(); + tsField.setName("ts"); + tsField.setDataType("TIMESTAMP"); + tsField.setNullable(true); + table.add(tsField); + + // 设置主键 + table.setDefaultPrimaryKeys(Collections.singletonList("id")); + + return table; + } + + /** + * 生成指定数量的记录,按指定QPS控制速度 + * @param totalRecords 总记录数 + * @param qps 每秒写入记录数(0表示不限制) + * @param consumer 处理批次数据的消费者 + */ + public void generateRecordsWithRate(long totalRecords, int qps, Consumer> consumer) { + long batchSize = 1000; // 每批次1000条 + long totalBatches = (totalRecords + batchSize - 1) / batchSize; + + System.out.println(" >> 开始生成数据: 总量=" + totalRecords + "条, 批次大小=" + batchSize); + if (qps > 0) { + System.out.println(" >> QPS限制=" + qps + "条/秒"); + } else { + System.out.println(" >> QPS限制=无限制(全速写入)"); + } + + long startTime = System.currentTimeMillis(); + long totalWritten = 0; + + for (long i = 0; i < totalBatches; i++) { + long currentBatchSize = Math.min(batchSize, totalRecords - i * batchSize); + + // 生成批次数据 + List events = new ArrayList<>((int) currentBatchSize); + for (int j = 0; j < currentBatchSize; j++) { + Map record = generateRecord(); + TapInsertRecordEvent event = new TapInsertRecordEvent(); + event.setAfter(record); + event.setTableId(tableName); + event.setReferenceTime(System.currentTimeMillis()); + events.add(event); + } + + // 调用消费者处理数据 + consumer.accept(events); + totalWritten += events.size(); + + // 控制QPS + if (qps > 0) { + long expectedTimePerBatch = (batchSize * 1000) / qps; + long actualTime = System.currentTimeMillis() - startTime; + long sleepTime = expectedTimePerBatch - actualTime; + + if (sleepTime > 0) { + try { + Thread.sleep(sleepTime); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + break; + } + } + startTime = System.currentTimeMillis(); + } + + // 每10批次打印进度 + if ((i + 1) % 10 == 0 || i == totalBatches - 1) { + double progress = (double) (i + 1) / totalBatches * 100; + System.out.printf(" >> 进度: %d/%d 批次 (%.1f%%), 已写入: %d 条%n", + i + 1, totalBatches, progress, totalWritten); + } + } + + System.out.println(" >> 数据生成完成: 总计 " + totalWritten + " 条"); + } + + /** + * 获取已生成的唯一ID数量 + * + *

返回实际生成的唯一 ID 总数(非窗口限制值) + */ + public long getUniqueIdsCount() { + return idGenerator.get() - 1; + } + + /** + * 获取总生成记录数(包括重复) + */ + public long getTotalGenerated() { + return idGenerator.get() - 1; + } +} diff --git a/connectors/connector-perf-test/src/main/java/io/tapdata/connector/paimon/perf/PaimonDataGenerator.java b/connectors/connector-perf-test/src/main/java/io/tapdata/connector/paimon/perf/PaimonDataGenerator.java new file mode 100644 index 000000000..2bf159bbd --- /dev/null +++ b/connectors/connector-perf-test/src/main/java/io/tapdata/connector/paimon/perf/PaimonDataGenerator.java @@ -0,0 +1,130 @@ +package io.tapdata.connector.paimon.perf; + +import org.apache.paimon.data.BinaryString; +import org.apache.paimon.data.Decimal; +import org.apache.paimon.data.GenericRow; +import org.apache.paimon.data.InternalRow; +import org.apache.paimon.types.DataTypes; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.Random; +import java.util.concurrent.atomic.AtomicLong; + +/** + * 数据生成器 - 为 AutoTestRunner 专用 + * 生成交易明细表测试数据(29个字段) + */ +public class PaimonDataGenerator { + private final long totalRecords; + private final int warmupRecords; + private final double duplicateRate; + private final int batchSize; + + private final Random random; + private final AtomicLong generatedCount; + private final AtomicLong idCounter; + + // 交易明细表字段数据 + private static final String[] COMPOR_IDS = {"C001", "C002", "C003", "C004", "C005"}; + private static final String[] TXN_TYPES = {"SALE", "REFUND", "VOID", "EXCHANGE"}; + private static final String[] CHANNELS = {"CASH", "CARD", "MOBILE", "ONLINE"}; + private static final String[] OUTLETS = {"OUTLET_A", "OUTLET_B", "OUTLET_C", "OUTLET_D"}; + private static final String[] SOURCES = {"POS", "ECOM", "MOBILE_APP"}; + private static final String[] DOLLAR_TYPES = {"USD", "EUR", "CNY", "JPY"}; + private static final String[] PROPERTIES = {"NORMAL", "VIP", "PROMO"}; + + public PaimonDataGenerator(long totalRecords, int warmupRecords, double duplicateRate, int batchSize) { + this.totalRecords = totalRecords; + this.warmupRecords = warmupRecords; + this.duplicateRate = duplicateRate; + this.batchSize = batchSize; + this.random = new Random(42); // 固定种子以保证可重复性 + this.generatedCount = new AtomicLong(0); + this.idCounter = new AtomicLong(1); + } + + /** + * 是否还有更多记录 + */ + public boolean hasMore() { + return generatedCount.get() < totalRecords; + } + + /** + * 生成下一条记录 + */ + public InternalRow nextRecord() { + if (!hasMore()) { + return null; + } + + long currentId = generatedCount.incrementAndGet(); + + // 根据重复率决定是否生成重复ID + long id; + if (duplicateRate > 0 && generatedCount.get() > warmupRecords && random.nextDouble() < duplicateRate) { + id = Math.max(1, idCounter.get() - random.nextInt(1000) - 1); + } else { + id = idCounter.getAndIncrement(); + } + + return createRow(id); + } + + /** + * 创建一行数据 + */ + private GenericRow createRow(long id) { + GenericRow row = GenericRow.of( + id, // id (BIGINT) + BinaryString.fromString("BD_" + id), // balance_detail_id (STRING) + Decimal.fromBigDecimal(randomBigDecimal(18, 0), 18, 0), // before_detail_balance (DECIMAL) + Decimal.fromBigDecimal(randomBigDecimal(18, 0), 18, 0), // amount (DECIMAL) + BinaryString.fromString(randomDateTime()), // expiry_date (TIMESTAMP) + BinaryString.fromString(randomArrayElement(COMPOR_IDS)), // compor_id (STRING) + BinaryString.fromString(randomArrayElement(TXN_TYPES)), // transaction_type (STRING) + BinaryString.fromString(randomArrayElement(CHANNELS)), // channel (STRING) + BinaryString.fromString("POS_REF_" + id), // pos_reference (STRING) + BinaryString.fromString(randomArrayElement(OUTLETS)), // outlet (STRING) + BinaryString.fromString("Remark for " + id), // remark (STRING) + BinaryString.fromString(randomDateTime()), // created_time (TIMESTAMP) + BinaryString.fromString("PD_" + id), // payment_detail_id (STRING) + id + 1000000L, // payment_id (BIGINT) + BinaryString.fromString("admin"), // created_by (STRING) + BinaryString.fromString("I"), // op (STRING) + Decimal.fromBigDecimal(randomBigDecimal(18, 0), 18, 0), // after_detail_balance (DECIMAL) + BinaryString.fromString(randomArrayElement(SOURCES)), // source_system (STRING) + BinaryString.fromString(randomArrayElement(DOLLAR_TYPES)), // dollar_type_id (STRING) + Decimal.fromBigDecimal(randomBigDecimal(18, 0), 18, 0), // exception_balance (DECIMAL) + BinaryString.fromString("PATRON_" + (id % 1000)), // patron_id (STRING) + BinaryString.fromString("KEY_" + id), // source_key (STRING) + BinaryString.fromString("DEV_" + (id % 100)), // device_id (STRING) + Decimal.fromBigDecimal(randomBigDecimal(18, 0), 18, 0), // after_balance (DECIMAL) + Decimal.fromBigDecimal(randomBigDecimal(18, 0), 18, 0), // before_balance (DECIMAL) + BinaryString.fromString(randomArrayElement(OUTLETS)), // outlet_code (STRING) + BinaryString.fromString(randomDateTime()), // ods_updated_at (TIMESTAMP) + BinaryString.fromString(randomArrayElement(PROPERTIES)), // property (STRING) + 20240101 + (int)(id % 10000) // pt_created_date (INT) + ); + + return row; + } + + private BigDecimal randomBigDecimal(int precision, int scale) { + long maxValue = (long) Math.pow(10, precision - scale) - 1; + long randomValue = (long) (random.nextDouble() * maxValue); + return BigDecimal.valueOf(randomValue, scale); + } + + private String randomDateTime() { + long epochDay = 19000 + random.nextInt(1000); // 2022-2025 + int secondOfDay = random.nextInt(86400); + LocalDateTime ldt = LocalDateTime.ofEpochSecond(epochDay * 86400L + secondOfDay, 0, java.time.ZoneOffset.UTC); + return ldt.toString(); + } + + private T randomArrayElement(T[] array) { + return array[random.nextInt(array.length)]; + } +} diff --git a/connectors/connector-perf-test/src/main/java/io/tapdata/connector/paimon/perf/PaimonFileObserver.java b/connectors/connector-perf-test/src/main/java/io/tapdata/connector/paimon/perf/PaimonFileObserver.java new file mode 100644 index 000000000..e94add508 --- /dev/null +++ b/connectors/connector-perf-test/src/main/java/io/tapdata/connector/paimon/perf/PaimonFileObserver.java @@ -0,0 +1,380 @@ +package io.tapdata.connector.paimon.perf; + +import org.apache.hadoop.fs.FileStatus; + +import java.io.IOException; +import java.util.*; +import java.util.concurrent.TimeUnit; + +/** + * Paimon 文件观测器 - 增强版(支持本地和 S3) + * 自动扫描并输出Paimon数据目录的文件列表、文件大小、文件数量 + */ +public class PaimonFileObserver { + private final String warehousePath; + private final String database; + private final String tableName; + private final boolean isS3; + private final UnifiedFileSystem fileSystem; + private List lastScanFiles = new ArrayList<>(); + + /** + * 创建本地文件观测器 + */ + public PaimonFileObserver(String warehousePath, String database, String tableName) { + this.warehousePath = warehousePath; + this.database = database; + this.tableName = tableName; + this.isS3 = false; + this.fileSystem = UnifiedFileSystem.createLocal(); + } + + /** + * 创建 S3 文件观测器 + */ + public PaimonFileObserver(String warehousePath, String database, String tableName, + String s3Endpoint, String s3AccessKey, String s3SecretKey, String s3Region) { + this.warehousePath = warehousePath; + this.database = database; + this.tableName = tableName; + this.isS3 = true; + // 从仓库路径中提取 bucket 名称 (格式: s3://bucket/key) + String bucket = "default-bucket"; + if (warehousePath.startsWith("s3://")) { + String pathWithoutScheme = warehousePath.substring(5); + int slashIdx = pathWithoutScheme.indexOf('/'); + if (slashIdx > 0) { + bucket = pathWithoutScheme.substring(0, slashIdx); + } else { + bucket = pathWithoutScheme; + } + } + this.fileSystem = UnifiedFileSystem.createS3(s3Endpoint, s3AccessKey, s3SecretKey, s3Region, bucket); + } + + /** + * 获取表的数据目录路径(兼容 bucket-N 和 data/ 两种布局) + */ + public String getTableDataPath() { + // Paimon 1.x 动态分桶下数据直接在表根目录下的 bucket-N/ 中 + return getTablePath(); + } + + /** + * 获取表的完整目录路径 + */ + public String getTablePath() { + if (isS3) { + // S3 路径格式:s3a://bucket/prefix/TC-01/default.db/test_table + return warehousePath + "/" + database + ".db" + "/" + tableName; + } else { + return warehousePath + java.io.File.separator + database + ".db" + java.io.File.separator + tableName; + } + } + + /** + * 扫描表的数据文件(支持 bucket-N/ 和 data/ 两种目录结构) + */ + public List scanDataFiles() throws IOException { + List fileInfos = new ArrayList<>(); + String tablePath = getTablePath(); + + try { + if (!fileSystem.exists(tablePath) || !fileSystem.isDirectory(tablePath)) { + lastScanFiles = fileInfos; + return fileInfos; + } + + // 递归列出所有文件 + List fileStatuses = fileSystem.listFilesRecursive(tablePath); + + for (FileStatus status : fileStatuses) { + String fileName = status.getPath().getName(); + String pathStr = status.getPath().toString(); + + // 只统计数据文件(parquet/orc/avro),排除元数据 + if ((fileName.endsWith(".parquet") || + fileName.endsWith(".orc") || + fileName.endsWith(".avro")) + && !pathStr.contains("/snapshot/") + && !pathStr.contains("/schema/") + && !pathStr.contains("/index/") + && !pathStr.contains("/manifest/")) { + fileInfos.add(new FileInfo( + status.getPath().toString(), + status.getLen(), + status.getModificationTime() + )); + } + } + } catch (Exception e) { + System.err.println(" [WARN] 扫描文件失败: " + e.getMessage()); + PerformanceTestRunner.printStackTrace(e); + } + + lastScanFiles = fileInfos; + return fileInfos; + } + + /** + * 打印详细文件信息 + */ + public void printFileInfo() throws IOException { + List fileInfos = scanDataFiles(); + printFileInfo(fileInfos); + } + + /** + * 打印文件信息(使用已扫描的结果) + */ + public void printFileInfo(List fileInfos) throws IOException { + System.out.println("\n" + "=".repeat(70)); + System.out.println(" Paimon 数据文件观测报告"); + if (isS3) { + System.out.println(" 存储类型: S3 对象存储"); + } else { + System.out.println(" 存储类型: 本地文件系统"); + } + System.out.println("=".repeat(70)); + System.out.println("表路径: " + getTableDataPath()); + System.out.println("扫描时间: " + new java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(new Date())); + System.out.println("-".repeat(70)); + + if (fileInfos.isEmpty()) { + System.out.println("⚠ 未找到数据文件!"); + System.out.println("=".repeat(70)); + return; + } + + // 基础统计 + long totalSize = fileInfos.stream().mapToLong(FileInfo::getSize).sum(); + long minSize = fileInfos.stream().mapToLong(FileInfo::getSize).min().orElse(0); + long maxSize = fileInfos.stream().mapToLong(FileInfo::getSize).max().orElse(0); + double avgSize = (double) totalSize / fileInfos.size(); + + System.out.println("📊 文件统计:"); + System.out.println(" 文件总数: " + fileInfos.size() + " 个"); + System.out.println(" 总大小: " + formatSize(totalSize)); + System.out.println(" 平均大小: " + formatSize((long) avgSize)); + System.out.println(" 最小文件: " + formatSize(minSize)); + System.out.println(" 最大文件: " + formatSize(maxSize)); + + // 文件大小分布 + System.out.println("\n📏 文件大小分布:"); + Map sizeDistribution = calculateSizeDistribution(fileInfos); + for (Map.Entry entry : sizeDistribution.entrySet()) { + String bar = "█".repeat(Math.max(1, entry.getValue().intValue())); + System.out.printf(" %-20s: %3d 个 %s%n", entry.getKey(), entry.getValue(), bar); + } + + // 文件列表(前20个) + System.out.println("\n📄 文件列表(前20个):"); + System.out.printf(" %-70s %12s%n", "文件路径", "大小"); + System.out.println(" " + "-".repeat(70) + " " + "-".repeat(12)); + + int count = 0; + String tablePath = getTableDataPath(); + for (FileInfo fileInfo : fileInfos) { + if (count >= 20) break; + // 显示相对路径 + String relativePath = fileInfo.getPath(); + if (relativePath.startsWith(tablePath)) { + relativePath = relativePath.substring(tablePath.length() + 1); + } + System.out.printf(" %-70s %12s%n", relativePath, formatSize(fileInfo.getSize())); + count++; + } + + if (fileInfos.size() > 20) { + System.out.println(" ... 还有 " + (fileInfos.size() - 20) + " 个文件"); + } + + System.out.println("=".repeat(70)); + } + + /** + * 计算文件大小分布 + */ + private Map calculateSizeDistribution(List fileInfos) { + Map distribution = new LinkedHashMap<>(); + distribution.put("< 1KB", fileInfos.stream().filter(f -> f.getSize() < 1024).count()); + distribution.put("1KB - 1MB", fileInfos.stream().filter(f -> + f.getSize() >= 1024 && f.getSize() < 1024 * 1024).count()); + distribution.put("1MB - 10MB", fileInfos.stream().filter(f -> + f.getSize() >= 1024 * 1024 && f.getSize() < 10 * 1024 * 1024).count()); + distribution.put("10MB - 100MB", fileInfos.stream().filter(f -> + f.getSize() >= 10 * 1024 * 1024 && f.getSize() < 100 * 1024 * 1024).count()); + distribution.put("100MB - 500MB", fileInfos.stream().filter(f -> + f.getSize() >= 100 * 1024 * 1024 && f.getSize() < 500 * 1024 * 1024).count()); + distribution.put("> 500MB", fileInfos.stream().filter(f -> + f.getSize() >= 500 * 1024 * 1024).count()); + return distribution; + } + + /** + * 监控文件变化 + */ + public void monitorFileChanges(long durationMs) throws IOException, InterruptedException { + System.out.println("\n" + "=".repeat(70)); + System.out.println(" 开始监控文件变化 (持续时间: " + (durationMs / 1000) + "秒)"); + System.out.println("=".repeat(70)); + + List initialFiles = scanDataFiles(); + System.out.println("初始文件数量: " + initialFiles.size()); + System.out.println("初始总大小: " + formatSize(initialFiles.stream().mapToLong(FileInfo::getSize).sum())); + + long startTime = System.currentTimeMillis(); + int checkCount = 0; + + while (System.currentTimeMillis() - startTime < durationMs) { + TimeUnit.SECONDS.sleep(2); + List currentFiles = scanDataFiles(); + long currentSize = currentFiles.stream().mapToLong(FileInfo::getSize).sum(); + checkCount++; + + System.out.printf(" [%ds] 检查#%d: 文件数=%d, 总大小=%s, 新增文件=%d%n", + (System.currentTimeMillis() - startTime) / 1000, + checkCount, + currentFiles.size(), + formatSize(currentSize), + currentFiles.size() - initialFiles.size()); + + initialFiles = currentFiles; + } + + System.out.println("监控结束"); + } + + /** + * 对比两次扫描的差异 + */ + public FileChangeReport compareWithLastScan() throws IOException { + List currentFiles = scanDataFiles(); + FileChangeReport report = new FileChangeReport(); + + report.setPreviousFileCount(lastScanFiles.size()); + report.setCurrentFileCount(currentFiles.size()); + report.setNewFiles(currentFiles.size() - lastScanFiles.size()); + + long previousSize = lastScanFiles.stream().mapToLong(FileInfo::getSize).sum(); + long currentSize = currentFiles.stream().mapToLong(FileInfo::getSize).sum(); + report.setPreviousSize(previousSize); + report.setCurrentSize(currentSize); + report.setSizeChange(currentSize - previousSize); + + return report; + } + + /** + * 格式化文件大小 + */ + public static String formatSize(long size) { + if (size < 1024) { + return size + " B"; + } else if (size < 1024 * 1024) { + return String.format("%.2f KB", (double) size / 1024); + } else if (size < 1024 * 1024 * 1024) { + return String.format("%.2f MB", (double) size / (1024 * 1024)); + } else { + return String.format("%.2f GB", (double) size / (1024 * 1024 * 1024)); + } + } + + /** + * 文件信息类 + */ + public static class FileInfo { + private final String path; + private final long size; + private final long lastModified; + + public FileInfo(String path, long size, long lastModified) { + this.path = path; + this.size = size; + this.lastModified = lastModified; + } + + public String getPath() { return path; } + public long getSize() { return size; } + public long getLastModified() { return lastModified; } + + @Override + public String toString() { + return String.format("FileInfo{path='%s', size=%s, lastModified=%d}", + path, formatSize(size), lastModified); + } + } + + /** + * 文件变化报告 + */ + public static class FileChangeReport { + private int previousFileCount; + private int currentFileCount; + private int newFiles; + private long previousSize; + private long currentSize; + private long sizeChange; + + public int getPreviousFileCount() { return previousFileCount; } + public void setPreviousFileCount(int previousFileCount) { this.previousFileCount = previousFileCount; } + public int getCurrentFileCount() { return currentFileCount; } + public void setCurrentFileCount(int currentFileCount) { this.currentFileCount = currentFileCount; } + public int getNewFiles() { return newFiles; } + public void setNewFiles(int newFiles) { this.newFiles = newFiles; } + public long getPreviousSize() { return previousSize; } + public void setPreviousSize(long previousSize) { this.previousSize = previousSize; } + public long getCurrentSize() { return currentSize; } + public void setCurrentSize(long currentSize) { this.currentSize = currentSize; } + public long getSizeChange() { return sizeChange; } + public void setSizeChange(long sizeChange) { this.sizeChange = sizeChange; } + + @Override + public String toString() { + return String.format("文件变化: 数量 %d -> %d (新增%d), 大小 %s -> %s (变化%s)", + previousFileCount, currentFileCount, newFiles, + formatSize(previousSize), formatSize(currentSize), + (sizeChange >= 0 ? "+" : "") + formatSize(sizeChange)); + } + } + + /** + * 扫描所有文件(包含 data 目录和其他元数据目录下的数据文件) + */ + public List scanAllFiles() throws IOException { + return scanDataFiles(); + } + + /** + * 紧凑格式打印(至15个文件) + */ + public void printCompact() throws IOException { + List files = scanDataFiles(); + if (files.isEmpty()) { + System.out.println(" [文件] 暂无数据文件(可能尚未 flush)"); + return; + } + long total = files.stream().mapToLong(FileInfo::getSize).sum(); + System.out.printf(" [文件] 数量: %d 总大小: %s%n", files.size(), formatSize(total)); + int shown = Math.min(files.size(), 15); + String tablePath = getTablePath(); + for (int i = 0; i < shown; i++) { + FileInfo fi = files.get(i); + String rel = fi.getPath(); + if (rel.startsWith(tablePath)) { + rel = rel.substring(tablePath.length() + 1); + } + System.out.printf(" %-65s %s%n", rel, formatSize(fi.getSize())); + } + if (files.size() > 15) { + System.out.printf(" ... 还有 %d 个文件%n", files.size() - 15); + } + } + + /** + * 获取最后扫描的文件列表 + */ + public List getLastScanFiles() { + return lastScanFiles; + } +} diff --git a/connectors/connector-perf-test/src/main/java/io/tapdata/connector/paimon/perf/PerformanceTestRunner.java b/connectors/connector-perf-test/src/main/java/io/tapdata/connector/paimon/perf/PerformanceTestRunner.java new file mode 100644 index 000000000..4c87e608a --- /dev/null +++ b/connectors/connector-perf-test/src/main/java/io/tapdata/connector/paimon/perf/PerformanceTestRunner.java @@ -0,0 +1,1652 @@ +package io.tapdata.connector.paimon.perf; + +import io.tapdata.connector.paimon.config.PaimonConfig; +import io.tapdata.connector.paimon.service.PaimonService; +import io.tapdata.entity.event.dml.TapInsertRecordEvent; +import io.tapdata.entity.event.dml.TapRecordEvent; +import io.tapdata.entity.logger.Log; +import io.tapdata.entity.schema.TapTable; +import io.tapdata.entity.utils.DataMap; +import io.tapdata.pdk.apis.context.TapConnectorContext; +import io.tapdata.pdk.apis.spec.TapNodeSpecification; +import org.apache.commons.lang3.StringUtils; +import org.apache.paimon.catalog.Catalog; +import org.apache.paimon.catalog.CatalogContext; +import org.apache.paimon.catalog.CatalogFactory; +import org.apache.paimon.catalog.Identifier; +import org.apache.paimon.options.Options; +import org.apache.paimon.table.Table; +import org.mockito.Mockito; + +import java.io.*; +import java.text.SimpleDateFormat; +import java.util.*; +import java.util.concurrent.atomic.AtomicLong; +import java.util.stream.Collectors; + +/** + * Paimon 写入性能参数调优测试主类 + * + *

运行方式: + *

+ *   ./run-perf-test.sh [mode]
+ *   mode: basic | buffer | target | bucket | compaction | nosmallfile | format | pkupdate | parallelism | all | auto
+ * 
+ * + *

交互模式(默认):每个用例前后按回车键继续;auto 模式:无需交互,全自动运行。 + */ +public class PerformanceTestRunner { + + // ─── 常量 ───────────────────────────────────────────────────────────────── + + public static final String BASE_TEST_DIR = "/tmp/paimon-perf-test/"; + private static final String DATABASE = "default"; + private static final String TABLE_NAME = "test_table"; + public static final int TOTAL_RECORDS = 5_000_000; // 数据集总大小 + private static final int BATCH_SIZE = 100_000; // 每批次写入记录数,也是PaimonService 累积批次大小 + private static final int INIT_TOTAL_RECORDS = 5_000_000; //模拟初始化阶段全表数据量 + + // ─── S3 测试配置 ────────────────────────────────────────────────────────── + + /** 是否启用 S3 存储(false = 使用本地文件系统) */ + private static final boolean ENABLE_S3 = true; + + /** S3 端点地址 */ + private static final String S3_ENDPOINT = "http://192.168.1.184:9080"; +// private static final String S3_ENDPOINT = "http://113.98.206.142:9080"; + + /** S3 访问密钥 */ + private static final String S3_ACCESS_KEY = "admin"; + + /** S3 密钥 */ + private static final String S3_SECRET_KEY = "admin123"; + + /** S3 区域(可选):MinIO里 几乎没用,随便填一个合法字符串即可,不校验 region,不影响连接、不影响权限、不影响性能 */ + private static final String S3_REGION = "us-east-1"; + + /** S3 仓库路径前缀(bucket 名称) */ + public static final String S3_BUCKET = "s3://luke"; + private static final String S3_WAREHOUSE = S3_BUCKET+"/warehouse-paimon-perf"; + + // ─── 实例变量 ───────────────────────────────────────────────────────────── + + private final String baseDir; + private final String database; + private final String tableName; + private boolean interactive; + private final Log logger; + + // ─── 构造函数 ───────────────────────────────────────────────────────────── + + public PerformanceTestRunner(String baseDir, String database, String tableName) { + this.baseDir = baseDir; + this.database = database; + this.tableName = tableName; + this.interactive = true; + this.logger = buildConsoleLog(); + } + + public void setInteractive(boolean interactive) { + this.interactive = interactive; + } + + // ─── 简单控制台 Log 实现 ────────────────────────────────────────────────── + + private static Log buildConsoleLog() { + return new Log() { + @Override public void debug(String m, Object... p) { print("[DEBUG] ", m, p);} + @Override public void info(String m, Object... p) { print("[INFO] ", m, p); } + @Override public void warn(String m, Object... p) { print("[WARN] ", m, p); } + @Override public void error(String m, Object... p) { print("[ERROR]", m, p); } + @Override public void error(String m, Throwable t) { System.err.println("[ERROR] " + m + (t != null ? ": " + t.getMessage() : "")); } + @Override public void fatal(String m, Object... p) { print("[FATAL]", m, p); } + @Override public void trace(String m, Object... p) {} + private void print(String prefix, String m, Object[] p) { + if (p != null) { for (Object o : p) m = m.replaceFirst("\\{}", String.valueOf(o)); } + System.out.println(prefix + " " + m); + } + }; + } + + // ─── 测试用例目录管理 ──────────────────────────────────────────────────── + + private String warehouseForCase(TestCase tc) { + // 每个用例独立仓库,避免 schema 冲突 + return baseDir + "/" + tc.getId(); + } + + // ─── PaimonService 工厂 ─────────────────────────────────────────────────── + + private PaimonService buildPaimonService(TestCase tc) throws Exception { + PaimonConfig config = new PaimonConfig(); + + // ── 核心参数 → PaimonConfig setters(会自动填充 tableProperties)────────── + applyConfigSetters(config, tc); + applyConfigSettersGlobal(config, tc); + + PaimonService service = new PaimonService(config, logger); + service.init(); + return service; + } + + /** + * 全局参数生效:优先级高于TestCase中 + * + * @param config + * @param tc + */ + private void applyConfigSettersGlobal(PaimonConfig config, TestCase tc) { + // ── 存储类型和仓库路径 ────────────────────────────────────────────── + if (ENABLE_S3) { + // 使用 S3 存储 + config.setStorageType("s3"); + config.setWarehouse(S3_WAREHOUSE + "/" + tc.getId()); + config.setS3Endpoint(S3_ENDPOINT); + config.setS3AccessKey(S3_ACCESS_KEY); + config.setS3SecretKey(S3_SECRET_KEY); + config.setS3Region(S3_REGION); + System.out.println(" >> 存储类型: S3 (" + S3_ENDPOINT + ")"); + } else { + // 使用本地文件系统 + config.setStorageType("local"); + config.setWarehouse(warehouseForCase(tc)); + System.out.println(" >> 存储类型: 本地文件系统,地址:" + config.getWarehouse()); + } + + config.setDatabase(database); +// config.setBatchAccumulationSize(BATCH_SIZE); + //模拟全量+增量模式使用: + config.setCreateAutoInc(true); + config.setDiskTmpDir(BASE_TEST_DIR + "/tmp," + BASE_TEST_DIR + "/tmp2"); + //为了验证Paimon参数的效果,测试中关闭: + config.setEnableAutoCompaction(false); + + } + + /** + * 将参数 Map 中的所有值自动写入到 PaimonConfig 的对应属性中 + * 使用反射机制自动匹配参数名和 setter 方法,支持特殊映射和类型转换 + * + * 支持的参数映射规则: + * 1. 特殊映射:通过 specialSetters 定义复杂映射关系(如单位转换、多属性设置等) + * 2. 反射自动映射:参数名转驼峰命名后匹配 setter 方法 + * 3. 表属性降级:未匹配的参数放入 tableProperties,用于构建 Paimon 表选项 + */ + private void applyConfigSetters(PaimonConfig config, TestCase tc) { + + // 批次累积大小 + config.setBatchAccumulationSize(tc.getBatchSize()); + Map params = tc.getParameters(); + + if (params == null || params.isEmpty()) return; + + // ── 写入线程 / 并行度 ───────────────────────────────────────────────── + String parallelism = params.get("sink.parallelism"); + if (parallelism != null) { + try { config.setWriteThreads(Integer.parseInt(parallelism)); } + catch (NumberFormatException ignored) {} + } + + // 特殊参数映射表:参数名 → Setter 方法调用逻辑 + Map specialSetters = new HashMap<>(); + + // 缓冲区大小(MB) + specialSetters.put("write-buffer-size", (cfg, val) -> cfg.setWriteBufferSize(parseSizeMb(val))); + // 目标文件大小(MB) + specialSetters.put("target-file-size", (cfg, val) -> cfg.setTargetFileSize(parseSizeMb(val))); + // 分桶策略 + specialSetters.put("bucket", (cfg, val) -> { + int bucket = Integer.parseInt(val); + if (bucket > 0) { + cfg.setBucketMode("fixed"); + cfg.setBucketCount(bucket); + } else if (bucket == -1 || bucket == -2) { + cfg.setBucketMode("dynamic"); + cfg.setBucketCount(bucket); + } + }); + // 写入线程/并行度 + specialSetters.put("sink.parallelism", (cfg, val) -> cfg.setWriteThreads(Integer.parseInt(val))); + // 仅写入模式(禁用自动合并):注释掉的原因为可以走下面的反射 +// specialSetters.put("enableAutoCompaction", (cfg, val) -> cfg.setEnableAutoCompaction(!Boolean.parseBoolean(val))); + // 缓冲区溢写 + specialSetters.put("write-buffer-spillable", (cfg, val) -> cfg.setDiskOverflowWrite(Boolean.parseBoolean(val))); + // 溢写最大磁盘大小(GB) + specialSetters.put("write-buffer-spill.max-disk-size", (cfg, val) -> cfg.setDiskMaxSize(parseSizeGb(val))); + // 溢写临时目录 + specialSetters.put("write-buffer-spill.tmp-dirs", (cfg, val) -> cfg.setDiskTmpDir(val)); + // 提交间隔 + specialSetters.put("commit-interval-ms", (cfg, val) -> cfg.setCommitIntervalMs(Integer.parseInt(val))); + // 异步提交 + specialSetters.put("enable-async-commit", (cfg, val) -> cfg.setEnableAsyncCommit(Boolean.parseBoolean(val))); + // 合并间隔 + specialSetters.put("compaction-interval-minutes", (cfg, val) -> cfg.setCompactionIntervalMinutes(Integer.parseInt(val))); + // 主键更新 + specialSetters.put("enable-primary-key-update", (cfg, val) -> cfg.setEnablePrimaryKeyUpdate(Boolean.parseBoolean(val))); + // 分区键(逗号分隔) + specialSetters.put("partition-key", (cfg, val) -> cfg.setPartitionKey(Arrays.asList(val.split(",")))); + + for (Map.Entry entry : params.entrySet()) { + String key = entry.getKey(); + String value = entry.getValue(); + if (value == null || value.isEmpty()) continue; + + try { + // 1. 优先检查特殊映射 + ConfigSetter specialSetter = specialSetters.get(key); + if (specialSetter != null) { + specialSetter.set(config, value); + System.out.println(" [INFO] 参数='" + key + "',值="+ value +",通过Tapdata属性设置"); + continue; + } + + // 2. 尝试通过反射自动设置 + if (applyConfigPropertyByReflection(config, key, value)) { + System.out.println(" [WARN] 参数='" + key + "' ',值="+ value +",尝试通过反射自动设置成功"); + continue; + } + + // 3. 未匹配的参数放入 tableProperties(用于构建 Paimon 表选项) + put2TableProperties(config, key, value); + } catch (Exception e) { + System.err.println(" [ERROR] 设置配置属性失败: " + key + "=" + value + " - " + e.getMessage()); + } + } + } + + /** + * 通过反射自动调用 PaimonConfig 的 setter 方法 + * + * @param config PaimonConfig 实例 + * @param key 参数名(支持横线和下划线分隔,会自动转为驼峰) + * @param value 参数值(String 类型,会自动类型转换) + * @return true 如果成功找到并调用了对应的 setter 方法 + */ + private boolean applyConfigPropertyByReflection(PaimonConfig config, String key, String value) { + // 将参数名转为驼峰命名(例:write-buffer-size → writeBufferSize) + String camelKey = toCamelCase(key); + + // 构造 setter 方法名 + String setterName = "set" + Character.toUpperCase(camelKey.charAt(0)) + camelKey.substring(1); + + try { + // 遍历所有可能的参数类型 + Class[] paramTypes = {String.class, Integer.class, Boolean.class, List.class}; + + for (Class paramType : paramTypes) { + try { + java.lang.reflect.Method method = PaimonConfig.class.getMethod(setterName, paramType); + Object convertedValue = convertValue(value, paramType); + if (convertedValue != null) { + method.invoke(config, convertedValue); + return true; + } + } catch (NoSuchMethodException e) { + // 继续尝试下一个类型 + } + } + } catch (Exception e) { + // 反射调用失败,返回 false 交由上层处理 + } + + return false; + } + + /** + * 将横线/下划线分隔的字符串转为驼峰命名 + * 例:write-buffer-size → writeBufferSize + * s3_endpoint → s3Endpoint + */ + private String toCamelCase(String str) { + StringBuilder result = new StringBuilder(); + boolean nextUpper = false; + + for (int i = 0; i < str.length(); i++) { + char c = str.charAt(i); + if (c == '-' || c == '_' || c == '.') { + nextUpper = true; + } else { + if (nextUpper) { + result.append(Character.toUpperCase(c)); + nextUpper = false; + } else { + result.append(c); + } + } + } + + return result.toString(); + } + + /** + * 将 String 值转换为目标类型 + */ + private Object convertValue(String value, Class targetType) { + if (targetType == String.class) { + return value; + } else if (targetType == Integer.class) { + try { + return Integer.parseInt(value); + } catch (NumberFormatException e) { + return null; + } + } else if (targetType == Boolean.class) { + return Boolean.parseBoolean(value); + } else if (targetType == List.class) { + return Arrays.asList(value.split(",")); + } + return null; + } + + /** + * 配置设置器函数式接口 + */ + @FunctionalInterface + private interface ConfigSetter { + void set(PaimonConfig config, String value); + } + + /** + * 将未匹配的参数放入 PaimonConfig.tableProperties 中 + * 这些属性将直接写入 Paimon 表的 OPTIONS 中,用于构建表配置 + * + * @param config PaimonConfig 实例 + * @param key 参数名 + * @param value 参数值 + */ + private void put2TableProperties(PaimonConfig config, String key, String value) { + List> tableProperties = config.getTableProperties(); + + LinkedHashMap kv = new LinkedHashMap<>(); + // 将参数添加到第一个 LinkedHashMap 中 + kv.put("propKey", key); + kv.put("propValue", value); + tableProperties.add(kv); + System.out.println(" [INFO] 参数='" + key + "' ',值="+ value +",通过paimon属性设置"); + } + + /** + * 解析带单位的大小,返回 MB 数(如 "512mb" → 512,"1gb" → 1024) + */ + private static int parseSizeMb(String s) { + s = s.trim().toLowerCase(); + if (s.endsWith("gb")) return (int) (Double.parseDouble(s.replace("gb", "").trim()) * 1024); + if (s.endsWith("mb")) return Integer.parseInt(s.replace("mb", "").trim()); + if (s.endsWith("kb")) return (int) (Double.parseDouble(s.replace("kb", "").trim()) / 1024); + return Integer.parseInt(s); + } + + /** + * 解析带单位的大小,返回 GB 数 + */ + private static int parseSizeGb(String s) { + s = s.trim().toLowerCase(); + if (s.endsWith("tb")) return (int) (Double.parseDouble(s.replace("tb", "").trim()) * 1024); + if (s.endsWith("gb")) return Integer.parseInt(s.replace("gb", "").trim()); + if (s.endsWith("mb")) return (int) (Double.parseDouble(s.replace("mb", "").trim()) / 1024); + return Integer.parseInt(s); + } + + // ─── 创建测试表 ──────────────────────────────────────────────────────────── + + private TapTable createTapTable() { + DataGenerator dg = new DataGenerator(0, tableName); + return dg.generateTapTable(); + } + + private void createFreshTable(PaimonService service) throws Exception { + try { service.dropTable(tableName); } catch (Exception ignored) {} + TapTable table = createTapTable(); + service.createTable(table); + } + + // ─── 核心执行方法 ────────────────────────────────────────────────────────── + + /** + * 执行单个测试用例 + */ + public TestResult runTestCase(TestCase tc) { + printSeparator("="); + System.out.printf(" 用例 %-8s: %s%n", tc.getId(), tc.getName()); + System.out.printf(" 组 别: %-20s 描述: %s%n", tc.getGroup(), tc.getDescription()); + printSeparator("-"); + printParameters(tc.getParameters()); + + if (interactive) { + System.out.println("\n [按 Enter 开始本用例,Ctrl+C 退出]"); + waitForEnter(); + } + + PaimonService service = null; + long startMs = 0; + long endMs = 0; + AtomicLong written = new AtomicLong(0); + String error = null; + PaimonFileObserver observer; + if (ENABLE_S3) { + // S3 模式的观测器 + observer = new PaimonFileObserver( + S3_WAREHOUSE + "/" + tc.getId(), + database, + tableName, + S3_ENDPOINT, + S3_ACCESS_KEY, + S3_SECRET_KEY, + S3_REGION + ); + } else { + // 本地模式的观测器 + observer = new PaimonFileObserver(warehouseForCase(tc), database, tableName); + } + + try { + // 1. 初始化服务并创建表 + System.out.println("\n >> 初始化 PaimonService..."); + service = buildPaimonService(tc); + createFreshTable(service); + + // 2. 验证表参数是否生效 + System.out.println(" >> 验证表配置参数..."); + validateTableParameters(tc, service); + + TapConnectorContext tapConnectorContext = new TapConnectorContext(Mockito.mock(TapNodeSpecification.class), new DataMap(), new DataMap(), logger); + + System.out.printf(" >> 仓库路径: %s%n", warehouseForCase(tc)); + System.out.printf(" >> 开始写入 %,d 条记录 (主键重复率 %d%%, QPS限制 %s)%n", + tc.getDataSize(), tc.getPrimaryKeyDuplicateRate(), + tc.getQps() > 0 ? tc.getQps() + "" : "无限制"); + + // 显示用例级别的参数覆盖 + if (tc.getBatchSize() != null || tc.getInitTotalRecords() != null) { + System.out.println(" >> 用例级别参数覆盖:"); + if (tc.getBatchSize() != null) { + System.out.printf(" - batchSize = %,d (全局: %,d)%n", tc.getBatchSize(), BATCH_SIZE); + } + if (tc.getInitTotalRecords() != null) { + System.out.printf(" - initTotalRecords = %,d (全局: %,d)%n", tc.getInitTotalRecords(), INIT_TOTAL_RECORDS); + } + } + + // 2. 执行写入 + TapTable tapTable = createTapTable(); + DataGenerator gen = new DataGenerator(tc.getPrimaryKeyDuplicateRate(), tableName); + + // 使用 TestCase 独立的参数(如果设置了),否则使用全局常量 + long totalRecordsToWrite = tc.getDataSize(); + Integer caseBatchSize = tc.getBatchSize(); + int effectiveBatchSize = caseBatchSize != null ? caseBatchSize : BATCH_SIZE; + Integer caseInitTotal = tc.getInitTotalRecords(); + int effectiveInitTotal = caseInitTotal != null ? caseInitTotal : INIT_TOTAL_RECORDS; + + startMs = System.currentTimeMillis(); + long total = totalRecordsToWrite; + long remain = total; + long qpsSlotStartMs = System.currentTimeMillis(); + long qpsSlotWritten = 0; + + while (remain > 0) { + int batchSz = (int) Math.min(effectiveBatchSize, remain); + List batch = new ArrayList<>(batchSz); + for (int i = 0; i < batchSz; i++) { + Map rec = gen.generateRecord(); + TapInsertRecordEvent evt = new TapInsertRecordEvent(); + evt.setAfter(rec); + evt.setTableId(tableName); + evt.setReferenceTime(System.currentTimeMillis()); + Map info = new HashMap<>(1); + info.put("batchOffset",i); + evt.setInfo(info); + batch.add(evt); + if (qpsSlotWritten + effectiveBatchSize >= effectiveInitTotal) { + // 模拟 已写数据量 大于 初始化阶段全表数据量 为增量cdc: + evt.getInfo().put(TapRecordEvent.INFO_KEY_SYNC_STAGE, "CDC"); + } + } + service.writeRecords(batch, tapTable, tapConnectorContext); + remain -= batchSz; + written.addAndGet(batchSz); + qpsSlotWritten += batchSz; + + // QPS throttle + if (tc.getQps() > 0) { + long elapsed = System.currentTimeMillis() - qpsSlotStartMs; + long expected = qpsSlotWritten * 1000L / tc.getQps(); + long sleep = expected - elapsed; + if (sleep > 0) { + try { Thread.sleep(sleep); } catch (InterruptedException ie) { Thread.currentThread().interrupt(); } + } + } + + // 进度打印 + long pct = (written.get() * 100) / total; + if (written.get() % (effectiveBatchSize * 10) == 0 || remain == 0) { + double elapsed = (System.currentTimeMillis() - startMs) / 1000.0; + double throughput = elapsed > 0 ? written.get() / elapsed : 0; + System.out.printf(" >> 进度: %,d/%,d (%d%%) | 吞吐: %.0f 条/秒%n", + written.get(), total, pct, throughput); + } + } + + // 3. 强制 flush + System.out.println(" >> 执行最终 flush..."); + service.flushAll(); + endMs = System.currentTimeMillis(); + + } catch (Throwable e) { + endMs = System.currentTimeMillis(); + error = e.getClass().getSimpleName() + ": " + e.getMessage(); + System.err.println(" [ERROR] 用例执行异常: " + error); + printStackTrace(e); + } finally { + if (service != null) { + try { service.close(); } catch (Exception ignored) {} + } + } + + // 4. 统计文件 + List files = Collections.emptyList(); + try { files = observer.scanAllFiles(); } catch (Exception e) { + System.err.println(" [WARN] 文件扫描失败: " + e.getMessage()); + } + + long durationMs = endMs - startMs; + double throughput = durationMs > 0 ? written.get() * 1000.0 / durationMs : 0; + + TestResult result = new TestResult(tc, written.get(), durationMs, throughput, files, error); + + // 5. 打印结果 + printResult(result, observer); + + if (interactive) { + System.out.println("\n [按 Enter 继续下一个用例]"); + waitForEnter(); + } + return result; + } + + public static void printStackTrace(Throwable e) { + e.printStackTrace(); + } + + /** + * 运行整个测试组 + */ + public List runTestGroup(String groupName) throws Exception { + List cases; + switch (groupName.toLowerCase()) { + case "basic": cases = TestCase.createBasicTests(); break; + case "buffer": cases = TestCase.createWriteBufferTests(); break; + case "target": cases = TestCase.createTargetFileSizeTests(); break; + case "bucket": cases = TestCase.createBucketTests(); break; + case "compaction": cases = TestCase.createCompactionTests(); break; + case "nosmallfile": cases = TestCase.createNoSmallFileTests(); break; + case "format": cases = TestCase.createFormatCompressionTests(); break; + case "pkupdate": cases = TestCase.createPrimaryKeyUpdateTests(); break; + case "parallelism": cases = TestCase.createParallelismTests(); break; + case "all": cases = TestCase.createAllTests(); break; + default: + System.out.println(" [WARN] 未知测试组: " + groupName); + System.out.println(" 可用: " + String.join(", ", TestCase.getGroupDescriptions().keySet())); + return Collections.emptyList(); + } + + printSeparator("="); + System.out.printf(" 运行测试组: %s 共 %d 个用例%n", groupName, cases.size()); + printSeparator("="); + + List results = new ArrayList<>(); + for (int i = 0; i < cases.size(); i++) { + TestCase tc = cases.get(i); + System.out.printf("%n >>> 用例 [%d/%d]%n", i + 1, cases.size()); + results.add(runTestCase(tc)); + } + return results; + } + + // ─── 打印工具 ────────────────────────────────────────────────────────────── + + private static void printSeparator(String ch) { + System.out.println(StringUtils.repeat(ch, 70)); + } + + /** + * 从 Paimon Catalog 中读取表的实际配置参数(支持本地和 S3) + */ + private Map readActualTableOptions(String warehouse, String database, String tableName) { + try { + Options catalogOptions = new Options(); + + if (ENABLE_S3) { + // S3 模式:使用 s3:// 协议(Paimon 原生 S3 FileIO)而非 s3a:// + catalogOptions.set("warehouse", warehouse); + + // Paimon S3 配置 + catalogOptions.set("s3.endpoint", S3_ENDPOINT); + catalogOptions.set("s3.access-key", S3_ACCESS_KEY); + catalogOptions.set("s3.secret-key", S3_SECRET_KEY); + + if (S3_REGION != null && !S3_REGION.isEmpty()) { + catalogOptions.set("s3.region", S3_REGION); + } + + // 路径样式访问(MinIO 需要) + catalogOptions.set("s3.path-style-access", "true"); + + // 禁用 SSL(如果是 http 端点) + if (S3_ENDPOINT.startsWith("http://")) { + catalogOptions.set("s3.ssl.enabled", "false"); + } + } else { + // 本地模式 + catalogOptions.set("warehouse", warehouse); + } + + CatalogContext context = CatalogContext.create(catalogOptions); + Catalog catalog = CatalogFactory.createCatalog(context); + Identifier identifier = Identifier.create(database, tableName); + Table table = catalog.getTable(identifier); + Map options = table.options(); + catalog.close(); + return options; + } catch (org.apache.paimon.fs.UnsupportedSchemeException e) { + // S3 协议不可用,可能是缺少 paimon-s3 依赖 + System.err.println(" [WARN] S3 文件系统不可用: " + e.getMessage()); + System.err.println(" [提示] 请确保项目中包含 paimon-s3 依赖"); + return Collections.emptyMap(); + } catch (Exception e) { + System.err.println(" [WARN] 读取表配置失败: " + e.getMessage()); + if (System.getProperty("perf.verbose", "false").equals("true")) { + e.printStackTrace(); + } + return Collections.emptyMap(); + } + } + + /** + * 验证表参数是否已生效:对比预期参数和实际表配置(支持本地和 S3) + */ + private void validateTableParameters(TestCase tc, PaimonService service) { + Map expectedParams = tc.getParameters(); + + // 根据存储类型构建仓库路径 + String warehousePath; + if (ENABLE_S3) { + warehousePath = S3_WAREHOUSE + "/" + tc.getId(); + System.out.println(" >> 验证存储: S3 (" + S3_ENDPOINT + ")"); + } else { + warehousePath = warehouseForCase(tc); + System.out.println(" >> 验证存储: 本地文件系统"); + } + + Map actualOptions = readActualTableOptions(warehousePath, database, tableName); + + if (actualOptions.isEmpty()) { + System.out.println(" [WARN] 无法读取表配置,跳过参数验证"); + return; + } + + // 分类验证 + List serviceOnlyParams = Arrays.asList( + "write-buffer-spillable", "write-buffer-spill.max-disk-size" + ); + + System.out.println(" 参数验证结果:"); + int validated = 0; + int matched = 0; + int mismatched = 0; + + for (Map.Entry entry : expectedParams.entrySet()) { + String key = entry.getKey(); + String expectedValue = entry.getValue(); + + // Service 专有参数不在表选项中 + if (serviceOnlyParams.contains(key)) { + System.out.printf(" [Service] %-45s = %-20s ✓ 作用于 PaimonService%n", key, expectedValue); + validated++; + continue; + } + + // 检查表选项中是否有该参数 + String actualValue = null; + for (Map.Entry opt : actualOptions.entrySet()) { + if (opt.getKey().equals(key)) { + actualValue = opt.getValue(); + break; + } + } + + validated++; + if (actualValue != null) { + // 标准化后比较(去除单位差异) + String normalizedExpected = normalizeParamValue(key, expectedValue); + String normalizedActual = normalizeParamValue(key, actualValue); + + if (normalizedExpected.equals(normalizedActual)) { + System.out.printf(" [表选项] %-45s = %-20s ✅ 已生效(实际: %s)%n", key, expectedValue, actualValue); + matched++; + } else { + System.out.printf(" [表选项] %-45s = %-20s ⚠️ 值不一致(实际: %s)%n", key, expectedValue, actualValue); + mismatched++; + } + } else { + // 某些参数可能被 PaimonService 的 createTable 硬编码覆盖 + System.out.printf(" [表选项] %-45s = %-20s ❌ 未在表配置中找到%n", key, expectedValue); + mismatched++; + } + } + + System.out.println(); + System.out.printf(" 验证统计: 共验证 %d 个参数,%d 个已生效,%d 个不匹配%n%n", validated, matched, mismatched); + } + + /** + * 标准化参数值以便比较(去除单位差异,如 "256mb" vs "256 MB") + */ + private String normalizeParamValue(String key, String value) { + if (value == null) return ""; + String normalized = value.trim().toLowerCase(); + + // 对于大小相关参数,统一转换为 MB 数值 + if (key.contains("size") || key.contains("buffer")) { + try { + if (normalized.endsWith("gb")) { + int mb = (int) (Double.parseDouble(normalized.replace("gb", "").trim()) * 1024); + return mb + "mb"; + } else if (normalized.endsWith("mb")) { + return normalized; + } else if (normalized.endsWith("kb")) { + double mb = Double.parseDouble(normalized.replace("kb", "").trim()) / 1024.0; + return String.format("%.2fmb", mb); + } else { + // 假设是字节,尝试转换为 MB + try { + long bytes = Long.parseLong(normalized); + double mb = bytes / (1024.0 * 1024.0); + return String.format("%.2fmb", mb); + } catch (NumberFormatException ignored) {} + } + } catch (Exception ignored) {} + } + + return normalized; + } + + /** + * 参数分类信息 + */ + private static class ParamInfo { + String key; + String value; + ParamCategory category; // 参数分类 + String targetComponent; // 作用于哪个组件 + boolean applied; // 是否已生效 + String actualValue; // 实际值(用于验证) + String description; // 参数说明 + + enum ParamCategory { + SERVICE_CONFIG, // PaimonService 配置 + TABLE_OPTION, // Paimon 表选项 + INTERNAL // 内部参数(不直接传递) + } + + ParamInfo(String key, String value, ParamCategory category, String targetComponent, String description) { + this.key = key; + this.value = value; + this.category = category; + this.targetComponent = targetComponent; + this.description = description; + this.applied = false; + this.actualValue = null; + } + } + + /** + * 打印参数(增强版):分类、作用目标、预期效果、验证状态 + */ + private void printParameters(Map params) { + System.out.println(); + System.out.println(" ┌─ 参数配置详情" + "─".repeat(50)); + System.out.println(" │"); + + // 定义参数分组及其元数据 + String[][] paramGroups = { + // 组名 | 作用目标分类 + {"[1/6] 写入缓冲区配置", "Service 配置"}, + {"[2/6] 文件大小与格式", "Paimon 表选项"}, + {"[3/6] 分桶策略", "混合(Service + 表选项)"}, + {"[4/6] Compaction 合并控制", "Paimon 表选项"}, + {"[5/6] 排序与合并优化", "Paimon 表选项"}, + {"[6/6] 并行度与线程", "Service 配置 → 表选项"}, + }; + + String[][][] groupParams = { + { + {"write-buffer-size", "写入缓冲区大小(MB),控制内存缓冲容量", "256", "增大可减少 flush 频率,提升吞吐"}, + {"write-buffer-spillable", "是否允许溢写到磁盘", "false", "true 可避免 OOM,适合大数据量"}, + {"write-buffer-spill.max-disk-size", "溢写磁盘最大大小", "不限", "限制磁盘占用"}, + }, + { + {"target-file-size", "LSM L0 层目标文件大小(MB)", "128", "影响文件碎片化和查询效率"}, + {"file.format", "文件格式(parquet/orc)", "parquet", "Parquet 压缩比更好,查询更快"}, + {"file.compression", "文件压缩算法", "zstd", "zstd 压缩比优于 lz4/snappy"}, + {"spill-compression", "溢写时压缩算法", "lz4", "减少磁盘 I/O"}, + }, + { + {"bucket", "分桶数量(-1=动态,>0=固定)", "-1", "动态分桶适合未知数据分布"}, + {"dynamic-bucket.target-row-num", "动态分桶目标行数", "不限", "控制动态桶粒度"}, + }, + { + {"compaction.async.enabled", "是否启用异步 compaction", "true", "异步可减少写入阻塞"}, + {"num-sorted-run.compaction-trigger", "触发 compaction 的 sorted run 数量", "5", "值越大延迟合并"}, + {"num-sorted-run.stop-trigger", "停止写入的 sorted run 阈值", "8", "防止内存溢出"}, + {"compaction.size-ratio", "Compaction 大小比率", "不限", "影响合并策略"}, + {"commit.force-compact", "提交时强制 compact", "false", "true 可保证读性能"}, + {"write-only", "仅写入模式(跳过 compact)", "false", "true 最大化导入吞吐"}, + }, + { + {"local-merge-buffer-size", "本地合并缓冲区大小", "不限", "影响 merge 性能"}, + {"sort-spill-buffer-size", "排序溢写缓冲区大小", "不限", "控制排序内存占用"}, + }, + { + {"sink.parallelism", "写入并行度(线程数)", "4", "影响并发写入能力"}, + }, + }; + + // 打印每个分组的参数 + for (int g = 0; g < paramGroups.length; g++) { + String[][] currentGroupParams = groupParams[g]; + boolean hasAny = false; + + // 检查该分组是否有任何参数 + for (String[] paramMeta : currentGroupParams) { + if (params.containsKey(paramMeta[0])) { + hasAny = true; + break; + } + } + + if (!hasAny) continue; + + // 打印组名和作用目标 + System.out.printf(" │ %s → %s%n", paramGroups[g][0], paramGroups[g][1]); + + // 打印该组的参数 + for (String[] paramMeta : currentGroupParams) { + String key = paramMeta[0]; + String desc = paramMeta[1]; + String defaultVal = paramMeta[2]; + String effect = paramMeta[3]; + + if (!params.containsKey(key)) continue; + + String value = params.get(key); + + // 判断参数类型和作用目标 + String paramType; + String targetComponent; + + if (key.equals("write-buffer-size") || key.equals("write-buffer-spillable") || + key.equals("write-buffer-spill.max-disk-size")) { + paramType = "Service"; + targetComponent = "PaimonService 运行时配置"; + } else if (key.equals("sink.parallelism")) { + paramType = "混合"; + targetComponent = "Service.writeThreads → 表选项 sink.parallelism"; + } else if (key.equals("bucket")) { + paramType = "混合"; + targetComponent = "Service.bucketMode/bucketCount + 表选项 bucket"; + } else { + paramType = "表选项"; + targetComponent = "Paimon 表 schema OPTIONS"; + } + + // 打印参数值、预期效果和作用目标 + System.out.printf(" │ %-40s = %-15s [%s]%n", key, value, paramType); + System.out.printf(" │ ↳ 作用: %s%n", targetComponent); + System.out.printf(" │ ↳ 预期: %s%n", effect); + } + System.out.println(" │"); + } + + // 打印未分组的参数 + Set allGroupedKeys = new HashSet<>(); + for (String[][] gp : groupParams) { + for (String[] paramMeta : gp) { + allGroupedKeys.add(paramMeta[0]); + } + } + + boolean hasOther = false; + for (String k : params.keySet()) { + if (!allGroupedKeys.contains(k)) { + if (!hasOther) { + System.out.println(" │ [其他参数] → Paimon 表选项"); + hasOther = true; + } + System.out.printf(" │ %-40s = %-15s [表选项]%n", k, params.get(k)); + } + } + + // 打印参数统计 + System.out.println(" │"); + System.out.printf(" │ 参数统计: 共 %d 个参数 | ", params.size()); + + long serviceCount = params.keySet().stream() + .filter(k -> k.equals("write-buffer-size") || k.equals("write-buffer-spillable") || + k.equals("write-buffer-spill.max-disk-size") || k.equals("sink.parallelism")) + .count(); + long tableOptionCount = params.size() - serviceCount; + + System.out.printf("Service 配置: %d 个 | 表选项: %d 个%n", serviceCount, tableOptionCount); + System.out.println(" │"); + System.out.println(" └" + "─".repeat(69)); + System.out.println(); + System.out.println(" 参数分类说明:"); + System.out.println(" • Service 配置 → 通过 PaimonConfig setter 设置,影响运行时行为(缓冲区、线程数等)"); + System.out.println(" • Paimon 表选项 → 通过 CREATE TABLE 的 OPTIONS 设置,定义表的物理存储特性"); + System.out.println(" • 混合类型 → 同时作用于 Service 和表选项(如 bucket、parallelism)"); + System.out.println(" ✓ 参数生效验证将在表创建后自动执行"); + System.out.println(); + } + + /** + * 参数元数据 + */ + private static class ParamMeta { + String groupName; + String targetComponent; + String description; + String key; + String defaultValue; + String validationMethod; + + ParamMeta(String groupName, String targetComponent, String description, + String key, String defaultValue, String validationMethod) { + this.groupName = groupName; + this.targetComponent = targetComponent; + this.description = description; + this.key = key; + this.defaultValue = defaultValue; + this.validationMethod = validationMethod; + } + } + + private void printResult(TestResult r, PaimonFileObserver observer) { + printSeparator("-"); + System.out.println(" ┌─ 测试结果: " + r.testCase.getName()); + if (r.error != null) { + System.out.println(" │ [FAILED] 错误: " + r.error); + } + System.out.printf(" │ 写入记录: %,d 条 用时: %.2f s 吞吐: %.0f 条/秒%n", + r.recordCount, r.durationMs / 1000.0, r.throughput); + System.out.printf(" │ 文件数量: %d 个 总大小: %s 平均: %s%n", + r.fileCount, PaimonFileObserver.formatSize(r.totalFileSize), + r.fileCount > 0 ? PaimonFileObserver.formatSize(r.totalFileSize / r.fileCount) : "N/A"); + System.out.printf(" │ 最小: %s 最大: %s%n", + PaimonFileObserver.formatSize(r.minFileSize), + PaimonFileObserver.formatSize(r.maxFileSize)); + + // 文件大小分布 + System.out.println(" │ 分布:"); + for (Map.Entry e : r.sizeDistribution.entrySet()) { + if (e.getValue() > 0) { + String bar = "█".repeat(Math.min(30, Math.max(1, e.getValue().intValue()))); + System.out.printf(" │ %-15s: %3d %s%n", e.getKey(), e.getValue(), bar); + } + } + System.out.println(" └─"); + + // 展示实际文件列表(最多15个) + try { observer.printCompact(); } catch (Exception ignored) {} + } + + // ─── 报告生成 ────────────────────────────────────────────────────────────── + + /** 转义 Markdown 表元格中的管道符,防止破坏表格结构 */ + private static String escapePipe(String s) { + return s == null ? "" : s.replace("|", "\\|"); + } + + public String generateReport(List results, String reportPath) throws IOException { + StringBuilder sb = new StringBuilder(); + String now = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(new Date()); + + sb.append("# Paimon 写入性能测试报告\n\n"); + sb.append("> 生成时间:").append(now).append(" \n"); + sb.append("> Paimon 版本:1.3.1 ").append("基仓库:").append(baseDir).append("\n\n"); + + // ── 概览 ───────────────────────────────────────────────────────────── + sb.append("## 测试概览\n\n"); + sb.append("| 指标 | 值 |\n|------|-----|\n"); + sb.append("| 测试用例总数 | ").append(results.size()).append(" |\n"); + long successCount = results.stream().filter(r -> r.error == null).count(); + sb.append("| 成功用例数 | ").append(successCount).append(" |\n"); + sb.append("| 失败用例数 | ").append(results.size() - successCount).append(" |\n"); + OptionalDouble avgTp = results.stream().filter(r -> r.error == null).mapToDouble(r -> r.throughput).average(); + OptionalDouble maxTp = results.stream().filter(r -> r.error == null).mapToDouble(r -> r.throughput).max(); + sb.append(String.format("| 平均吞吐量 | %.0f 条/秒 |\n", avgTp.orElse(0))); + sb.append(String.format("| 最高吞吐量 | %.0f 条/秒 |\n", maxTp.orElse(0))); + sb.append("\n"); + + // ── 分组详细结果 ───────────────────────────────────────────────────── + sb.append("## 详细测试结果\n\n"); + Map> grouped = new LinkedHashMap<>(); + for (TestResult r : results) grouped.computeIfAbsent(r.testCase.getGroup(), k -> new ArrayList<>()).add(r); + + for (Map.Entry> entry : grouped.entrySet()) { + sb.append("### ").append(entry.getKey()).append("\n\n"); + sb.append("| 用例ID | 名称 | 吞吐(条/s) | 用时(s) | 文件数 | 总大小 | 平均大小 | 状态 |\n"); + sb.append("|--------|------|-----------|---------|--------|--------|----------|------|\n"); + for (TestResult r : entry.getValue()) { + String status = r.error == null ? "✅" : "❌"; + String name = escapePipe(r.testCase.getName()); + // 失败用例显示 '-' 避免误导 + if (r.error != null) { + sb.append(String.format("| %s | %s | - | - | - | - | - | %s |\n", + r.testCase.getId(), name, status)); + } else { + sb.append(String.format("| %s | %s | %.0f | %.2f | %d | %s | %s | %s |\n", + r.testCase.getId(), name, r.throughput, + r.durationMs / 1000.0, r.fileCount, + PaimonFileObserver.formatSize(r.totalFileSize), + r.fileCount > 0 ? PaimonFileObserver.formatSize(r.totalFileSize / r.fileCount) : "N/A", + status)); + } + } + sb.append("\n"); + } + + // ── 文件大小分布 ────────────────────────────────────────────────────── + sb.append("## 文件大小分布对比\n\n"); + sb.append("| 用例ID | <1KB | 1KB-1MB | 1MB-10MB | 10MB-100MB | 100MB-500MB | >500MB |\n"); + sb.append("|--------|------|---------|----------|-----------|-------------|--------|\n"); + for (TestResult r : results) { + if (r.error != null) { + // 失败用例显示 '-' + sb.append(String.format("| %s | - | - | - | - | - | - |\n", r.testCase.getId())); + } else { + Map d = r.sizeDistribution; + sb.append(String.format("| %s | %d | %d | %d | %d | %d | %d |\n", + r.testCase.getId(), + d.getOrDefault("< 1KB", 0L), + d.getOrDefault("1KB - 1MB", 0L), + d.getOrDefault("1MB - 10MB", 0L), + d.getOrDefault("10MB-100MB", 0L), + d.getOrDefault("100MB-500MB", 0L), + d.getOrDefault("> 500MB", 0L))); + } + } + sb.append("\n"); + + // ── 参数效果分析 ───────────────────────────────────────────────────── + sb.append("## 参数效果分析\n\n"); + appendParamAnalysis(sb, results); + + // ── 推荐配置 ────────────────────────────────────────────────────────── + sb.append("## 生产推荐配置\n\n"); + appendRecommendedConfig(sb, results); + + // ── 核心结论 ────────────────────────────────────────────────────────── + sb.append("## 核心结论\n\n"); + appendConclusions(sb, results); + + // 写入文件 + File f = new File(reportPath); + f.getParentFile().mkdirs(); + try (BufferedWriter w = new BufferedWriter(new FileWriter(f))) { + w.write(sb.toString()); + } + System.out.println("\n ✅ 报告已生成: " + f.getAbsolutePath()); + return sb.toString(); + } + + private void appendParamAnalysis(StringBuilder sb, List results) { + // 按组分析 + Map> grouped = new LinkedHashMap<>(); + for (TestResult r : results) grouped.computeIfAbsent(r.testCase.getGroup(), k -> new ArrayList<>()).add(r); + + for (Map.Entry> entry : grouped.entrySet()) { + if (entry.getValue().size() < 2) continue; + sb.append("### ").append(entry.getKey()).append("\n\n"); + List list = entry.getValue().stream().filter(r -> r.error == null) + .sorted(Comparator.comparingDouble(r -> -r.throughput)).collect(Collectors.toList()); + if (list.isEmpty()) { sb.append("(所有用例失败,无法分析)\n\n"); continue; } + TestResult best = list.get(0); + TestResult worst = list.get(list.size() - 1); + sb.append(String.format("- 最高吞吐:**%s** (%.0f 条/秒)%n", best.testCase.getId(), best.throughput)); + sb.append(String.format("- 最低吞吐:**%s** (%.0f 条/秒)%n", worst.testCase.getId(), worst.throughput)); + if (worst.throughput > 0) { + sb.append(String.format("- 性能差异:**%.1f 倍**%n", best.throughput / worst.throughput)); + } + // 最少文件 + list.stream().min(Comparator.comparingInt(r -> r.fileCount)).ifPresent(r -> + sb.append(String.format("- 最少文件:**%s** (%d 个文件,avg=%s)%n", + r.testCase.getId(), r.fileCount, r.fileCount > 0 ? PaimonFileObserver.formatSize(r.totalFileSize / r.fileCount) : "N/A"))); + sb.append("\n"); + } + } + + /** + * 基于测试结果动态生成推荐配置 + * 分析实际测试数据,从各场景最优用例中提取配置参数 + */ + private void appendRecommendedConfig(StringBuilder sb, List results) { + // 筛选成功的测试结果 + List successResults = results.stream() + .filter(r -> r.error == null) + .collect(Collectors.toList()); + + if (successResults.isEmpty()) { + sb.append("> ⚠️ 所有测试用例均失败,无法生成推荐配置\n\n"); + return; + } + + // 按场景分类获取最优配置 + ScenarioRecommendation importRecommend = findBestForScenario(successResults, "无小文件", + "导入无 Compact 场景", Comparator.comparingDouble(r -> -r.throughput)); + + ScenarioRecommendation realtimeRecommend = findBestForScenario(successResults, "合并策略", + "实时写入允许合并场景", Comparator.comparingDouble(r -> r.throughput / Math.max(1, r.fileCount))); + + ScenarioRecommendation smallFileRecommend = findBestForScenario(successResults, "无小文件", + "最少文件数场景", Comparator.comparingInt(r -> r.fileCount)); + + ScenarioRecommendation globalThroughputRecommend = findBestForScenario(successResults, null, + "全局最高吞吐", Comparator.comparingDouble(r -> -r.throughput)); + + ScenarioRecommendation balancedRecommend = findBestForScenario(successResults, null, + "均衡场景(吞吐/文件数综合)", Comparator.comparingDouble(r -> r.throughput / Math.max(1, r.fileCount))); + + // 场景 1:导入无 Compact + sb.append("### 场景1:大批量导入无 Compact(基于实测最优)\n\n"); + if (importRecommend != null) { + sb.append(String.format("**推荐用例**: %s - %s%n", importRecommend.result.testCase.getId(), + importRecommend.result.testCase.getName())); + sb.append(String.format("**实测吞吐**: %.0f 条/秒 | **文件数**: %d | **总大小**: %s%n%n", + importRecommend.result.throughput, importRecommend.result.fileCount, + PaimonFileObserver.formatSize(importRecommend.result.totalFileSize))); + appendConfigBlock(sb, importRecommend.result.testCase.getParameters(), + "大批量导入优化配置(追求最大吞吐,禁止合并)"); + + sb.append("\n**配置说明**:\n"); + appendConfigExplanation(sb, importRecommend); + } else { + sb.append("> ⚠️ 未找到无小文件测试组结果,请运行 nosmallfile 测试组\n\n"); + } + + // 场景 2:实时写入允许合并 + sb.append("\n### 场景2:实时写入允许合并(基于实测最优)\n\n"); + if (realtimeRecommend != null) { + sb.append(String.format("**推荐用例**: %s - %s%n", realtimeRecommend.result.testCase.getId(), + realtimeRecommend.result.testCase.getName())); + sb.append(String.format("**实测吞吐**: %.0f 条/秒 | **文件数**: %d | **平均大小**: %s%n%n", + realtimeRecommend.result.throughput, realtimeRecommend.result.fileCount, + realtimeRecommend.result.fileCount > 0 ? + PaimonFileObserver.formatSize(realtimeRecommend.result.totalFileSize / realtimeRecommend.result.fileCount) : "N/A")); + appendConfigBlock(sb, realtimeRecommend.result.testCase.getParameters(), + "实时写入优化配置(允许异步合并,平衡读写性能)"); + + sb.append("\n**配置说明**:\n"); + appendConfigExplanation(sb, realtimeRecommend); + } else { + sb.append("> ⚠️ 未找到合并策略测试组结果,请运行 compaction 测试组\n\n"); + } + + // 场景 3:最少文件数 + sb.append("\n### 场景3:最少文件数优化(基于实测最少)\n\n"); + if (smallFileRecommend != null) { + sb.append(String.format("**推荐用例**: %s - %s%n", smallFileRecommend.result.testCase.getId(), + smallFileRecommend.result.testCase.getName())); + sb.append(String.format("**实测吞吐**: %.0f 条/秒 | **文件数**: %d(最少)| **平均大小**: %s%n%n", + smallFileRecommend.result.throughput, smallFileRecommend.result.fileCount, + smallFileRecommend.result.fileCount > 0 ? + PaimonFileObserver.formatSize(smallFileRecommend.result.totalFileSize / smallFileRecommend.result.fileCount) : "N/A")); + appendConfigBlock(sb, smallFileRecommend.result.testCase.getParameters(), + "最少文件数优化配置(减少文件碎片,便于后续查询)"); + + sb.append("\n**配置说明**:\n"); + appendConfigExplanation(sb, smallFileRecommend); + } else { + sb.append("> ⚠️ 未找到相关测试结果\n\n"); + } + + // 场景 4:全局最高吞吐 + sb.append("\n### 场景4:全局最高吞吐(基于实测数据)\n\n"); + if (globalThroughputRecommend != null) { + sb.append(String.format("**推荐用例**: %s - %s%n", globalThroughputRecommend.result.testCase.getId(), + globalThroughputRecommend.result.testCase.getName())); + sb.append(String.format("**实测吞吐**: %.0f 条/秒 | **文件数**: %d | **总大小**: %s%n%n", + globalThroughputRecommend.result.throughput, globalThroughputRecommend.result.fileCount, + PaimonFileObserver.formatSize(globalThroughputRecommend.result.totalFileSize))); + appendConfigBlock(sb, globalThroughputRecommend.result.testCase.getParameters(), + "全局最高吞吐配置(所有测试用例中的最佳表现)"); + + sb.append("\n**配置说明**:\n"); + appendConfigExplanation(sb, globalThroughputRecommend); + } + + // 场景 5:均衡配置 + sb.append("\n### 场景5:吞吐与文件数均衡(综合最优)\n\n"); + if (balancedRecommend != null) { + sb.append(String.format("**推荐用例**: %s - %s%n", balancedRecommend.result.testCase.getId(), + balancedRecommend.result.testCase.getName())); + sb.append(String.format("**实测吞吐**: %.0f 条/秒 | **文件数**: %d | **效能**: %.0f 条/秒/文件%n%n", + balancedRecommend.result.throughput, balancedRecommend.result.fileCount, + balancedRecommend.result.fileCount > 0 ? + balancedRecommend.result.throughput / balancedRecommend.result.fileCount : 0)); + appendConfigBlock(sb, balancedRecommend.result.testCase.getParameters(), + "吞吐与文件数均衡配置(适合一般生产场景)"); + + sb.append("\n**配置说明**:\n"); + appendConfigExplanation(sb, balancedRecommend); + } + + // 参数对比分析 + sb.append("\n### 配置参数对比分析\n\n"); + appendParameterComparison(sb, importRecommend, realtimeRecommend, smallFileRecommend, globalThroughputRecommend); + } + + /** + * 场景推荐结果 + */ + private static class ScenarioRecommendation { + TestResult result; + String scenarioName; + + ScenarioRecommendation(TestResult result, String scenarioName) { + this.result = result; + this.scenarioName = scenarioName; + } + } + + /** + * 根据场景查找最优配置 + */ + private ScenarioRecommendation findBestForScenario(List results, String groupName, + String scenarioName, Comparator comparator) { + return results.stream() + .filter(r -> groupName == null || groupName.equals(r.testCase.getGroup())) + .max(comparator) + .map(r -> new ScenarioRecommendation(r, scenarioName)) + .orElse(null); + } + + /** + * 打印配置参数块 + */ + private void appendConfigBlock(StringBuilder sb, Map params, String comment) { + sb.append("```properties\n"); + sb.append("# " + comment + "\n"); + + // 按分类排序打印 + String[] orderedKeys = { + "write-buffer-size", "write-buffer-spillable", "write-buffer-spill.max-disk-size", + "target-file-size", "file.format", "file.compression", "spill-compression", + "bucket", "dynamic-bucket.target-row-num", + "compaction.async.enabled", "num-sorted-run.compaction-trigger", + "num-sorted-run.stop-trigger", "compaction.size-ratio", + "commit.force-compact", "write-only", + "local-merge-buffer-size", "sort-spill-buffer-size", + "sink.parallelism", "changelog-producer" + }; + + Set printed = new HashSet<>(); + for (String key : orderedKeys) { + if (params.containsKey(key)) { + sb.append(String.format("%-45s = %s%n", key, params.get(key))); + printed.add(key); + } + } + + // 打印剩余参数 + for (Map.Entry entry : params.entrySet()) { + if (!printed.contains(entry.getKey())) { + sb.append(String.format("%-45s = %s%n", entry.getKey(), entry.getValue())); + } + } + + sb.append("```\n"); + } + + /** + * 打印配置说明 + */ + private void appendConfigExplanation(StringBuilder sb, ScenarioRecommendation rec) { + Map params = rec.result.testCase.getParameters(); + + // 缓冲区说明 + if (params.containsKey("write-buffer-size")) { + String size = params.get("write-buffer-size"); + sb.append(String.format("- **write-buffer-size=%s**: 写入缓冲区大小,", size)); + if (size.contains("512") || size.contains("1024")) { + sb.append("大缓冲区可减少 flush 频率,提升大批量导入吞吐\n"); + } else if (size.contains("64") || size.contains("128")) { + sb.append("中小缓冲区适合内存受限场景\n"); + } else { + sb.append("默认配置,平衡吞吐和内存占用\n"); + } + } + + // 溢写说明 + if ("true".equals(params.get("write-buffer-spillable"))) { + sb.append("- **write-buffer-spillable=true**: 允许溢写磁盘,避免 OOM,适合大数据量导入\n"); + } + + // 目标文件大小说明 + if (params.containsKey("target-file-size")) { + String size = params.get("target-file-size"); + sb.append(String.format("- **target-file-size=%s**: 控制生成文件的大小,", size)); + if (size.contains("256") || size.contains("512")) { + sb.append("大文件减少碎片,适合查询优化\n"); + } else { + sb.append("中小文件适合频繁读取场景\n"); + } + } + + // 合并说明 + if (params.containsKey("write-only")) { + if ("true".equals(params.get("write-only"))) { + sb.append("- **write-only=true**: 完全跳过 compaction,最大化写入性能\n"); + } else { + sb.append("- **write-only=false**: 允许后台合并,保证读性能\n"); + } + } + + if (params.containsKey("compaction.async.enabled")) { + if ("true".equals(params.get("compaction.async.enabled"))) { + sb.append("- **compaction.async.enabled=true**: 异步合并不阻塞写入\n"); + } else { + sb.append("- **compaction.async.enabled=false**: 关闭异步合并\n"); + } + } + + // 分桶说明 + if (params.containsKey("bucket")) { + String bucket = params.get("bucket"); + sb.append(String.format("- **bucket=%s**: ", bucket)); + if ("-1".equals(bucket)) { + sb.append("动态分桶,自动适应数据分布\n"); + } else if ("-2".equals(bucket)) { + sb.append("延迟分桶,Paimon 1.3+ 新特性\n"); + } else { + sb.append(String.format("固定 %s 桶,适合已知数据量的场景\n", bucket)); + } + } + + // 并行度说明 + if (params.containsKey("sink.parallelism")) { + sb.append(String.format("- **sink.parallelism=%s**: 写入并行度,影响并发线程数\n", + params.get("sink.parallelism"))); + } + } + + /** + * 打印参数对比分析 + */ + private void appendParameterComparison(StringBuilder sb, ScenarioRecommendation... recommendations) { + // 收集所有出现过的参数 + Set allParams = new LinkedHashSet<>(); + for (ScenarioRecommendation rec : recommendations) { + if (rec != null) { + allParams.addAll(rec.result.testCase.getParameters().keySet()); + } + } + + if (allParams.isEmpty()) return; + + sb.append("| 参数 | "); + for (ScenarioRecommendation rec : recommendations) { + if (rec != null) { + sb.append(rec.result.testCase.getId()).append(" | "); + } + } + sb.append("\n|------|"); + for (ScenarioRecommendation rec : recommendations) { + if (rec != null) sb.append("------|"); + } + sb.append("\n"); + + for (String param : allParams) { + sb.append("| ").append(param).append(" | "); + for (ScenarioRecommendation rec : recommendations) { + if (rec != null) { + String val = rec.result.testCase.getParameters().getOrDefault(param, "-"); + sb.append(val).append(" | "); + } + } + sb.append("\n"); + } + sb.append("\n"); + } + + private void appendConclusions(StringBuilder sb, List results) { + sb.append("1. **write-buffer-size**:缓冲区越大,单次 flush 数据越多,生成文件越大,吞吐越高;" + + "但受 JVM 堆限制,建议 256MB-512MB。\n"); + sb.append("2. **write-buffer-spillable=true**:允许溢写磁盘,是大批量导入的关键保障;" + + "可避免 OOM,同时保证数据一次性落盘。\n"); + sb.append("3. **target-file-size**:控制 LSM 层 L0 文件的目标大小;" + + "256MB+ 可显著减少文件碎片,查询效率更高。\n"); + sb.append("4. **write-only=true**:完全跳过 compaction 和快照过期,最大化写入吞吐;" + + "适合纯导入场景,导入完成后再手动 compact。\n"); + sb.append("5. **bucket=-2 vs -1**:bucket=-2(延迟分桶)在数据分布均匀时性能接近 -1;" + + "实际效果依赖 Paimon 1.3.1 对 -2 的支持情况(如不支持会退回 -1 行为)。\n"); + sb.append("6. **num-sorted-run.compaction-trigger**:增大阈值(如 100)可延迟合并触发," + + "显著降低写入期间的 I/O 压力,但会增加 sorted run 数量(影响查询)。\n"); + sb.append("7. **file.format=parquet + file.compression=zstd**:" + + "压缩比最佳,推荐生产默认配置。\n"); + sb.append("8. **无小文件最优组合**:write-buffer-size=512mb + target-file-size=256mb + " + + "write-only=true + write-buffer-spillable=true。\n"); + sb.append("\n"); + } + + // ─── 交互等待 ────────────────────────────────────────────────────────────── + + private void waitForEnter() { + try { + System.in.read(); + while (System.in.available() > 0) System.in.read(); + } catch (IOException ignored) {} + } + + // ─── 测试结果 ────────────────────────────────────────────────────────────── + + public static class TestResult { + public final TestCase testCase; + public final long recordCount; + public final long durationMs; + public final double throughput; + public final int fileCount; + public final long totalFileSize; + public final long minFileSize; + public final long maxFileSize; + public final Map sizeDistribution; + public final String error; + + public TestResult(TestCase tc, long recordCount, long durationMs, double throughput, + List files, String error) { + this.testCase = tc; + this.recordCount = recordCount; + this.durationMs = durationMs; + this.throughput = throughput; + this.fileCount = files.size(); + this.totalFileSize = files.stream().mapToLong(PaimonFileObserver.FileInfo::getSize).sum(); + this.minFileSize = files.stream().mapToLong(PaimonFileObserver.FileInfo::getSize).min().orElse(0L); + this.maxFileSize = files.stream().mapToLong(PaimonFileObserver.FileInfo::getSize).max().orElse(0L); + this.sizeDistribution = calcDistribution(files); + this.error = error; + } + + private static Map calcDistribution(List files) { + Map d = new LinkedHashMap<>(); + d.put("< 1KB", files.stream().filter(f -> f.getSize() < 1024L).count()); + d.put("1KB - 1MB", files.stream().filter(f -> f.getSize() >= 1024L && f.getSize() < 1024 * 1024L).count()); + d.put("1MB - 10MB", files.stream().filter(f -> f.getSize() >= 1024 * 1024L && f.getSize() < 10 * 1024 * 1024L).count()); + d.put("10MB-100MB", files.stream().filter(f -> f.getSize() >= 10 * 1024 * 1024L && f.getSize() < 100 * 1024 * 1024L).count()); + d.put("100MB-500MB", files.stream().filter(f -> f.getSize() >= 100 * 1024 * 1024L && f.getSize() < 500 * 1024 * 1024L).count()); + d.put("> 500MB", files.stream().filter(f -> f.getSize() >= 500 * 1024 * 1024L).count()); + return d; + } + } + + // ─── main ───────────────────────────────────────────────────────────────── + + /** + * 初始化 Tapdata PDK 运行时,使 PaimonConfig/CommonDbConfig 的静态字段可以正常初始化。 + * 必须在任何 PaimonConfig 创建之前调用。 + */ + private static void initRuntime() { + try { + // 触发 TapRuntime 扫描 io.tapdata 包,注册 JsonParser / BeanUtils 等实现 + Class runtimeClass = Class.forName("io.tapdata.pdk.core.runtime.TapRuntime"); + java.lang.reflect.Method getInstance = runtimeClass.getDeclaredMethod("getInstance"); + getInstance.invoke(null); + } catch (Exception e) { + System.err.println("[WARN] TapRuntime 初始化失败(可能影响 PaimonConfig 创建): " + e.getMessage()); + } + } + + public static void main(String[] args) throws Exception { + // 优先初始化 PDK 运行时,避免 CommonDbConfig 静态初始化失败 + initRuntime(); + + printWelcome(); + + String mode = args.length > 0 ? args[0].trim().toLowerCase() : ""; + + // 如果没有参数则交互式选择 + if (mode.isEmpty()) { + mode = interactiveChooseMode(); + } + + boolean autoMode = "auto".equals(mode); + String group = autoMode ? "all" : resolveMode(mode); + + PerformanceTestRunner runner = new PerformanceTestRunner(ENABLE_S3 ? S3_WAREHOUSE : BASE_TEST_DIR, DATABASE, TABLE_NAME); + runner.setInteractive(!autoMode); + + // 确保基础目录存在 + new File(BASE_TEST_DIR).mkdirs(); + + System.out.printf("%n 运行模式: %s 自动: %s%n", group, autoMode ? "是" : "否"); + + List results; + switch (group) { + case "single": + List basics = TestCase.createBasicTests(); + if (!basics.isEmpty()) { + results = Collections.singletonList(runner.runTestCase(basics.get(0))); + } else { + results = Collections.emptyList(); + } + break; + default: + results = runner.runTestGroup(group); + } + + if (!results.isEmpty()) { + String reportPath = BASE_TEST_DIR + "/test-report-" + + new SimpleDateFormat("yyyyMMdd-HHmmss").format(new Date()) + ".md"; + String report = runner.generateReport(results, reportPath); + + // 同时保存一份固定名称方便脚本引用 + String fixedPath = BASE_TEST_DIR + "/test-report.md"; + try (BufferedWriter w = new BufferedWriter(new FileWriter(fixedPath))) { + w.write(report); + } + + printSummaryTable(results); + + printSeparator("="); + System.out.println(" 全部测试完成!"); + System.out.println(" 报告路径: " + fixedPath); + System.out.println(" 数据目录: " + BASE_TEST_DIR); + System.out.println(" 查看文件: ls -lhR " + BASE_TEST_DIR + "/TC-*/"); + printSeparator("="); + } + } + + private static String resolveMode(String mode) { + // 委托给 TestModeConfig 统一处理 + String groupKey = TestModeConfig.resolveToGroupKey(mode); + if (groupKey != null) { + return groupKey; + } + + // 未找到匹配的模式,给出警告并返回默认 + System.out.println(" [WARN] 未知模式: " + mode + ",使用默认 basic"); + return TestModeConfig.DEFAULT_MODE_GROUP; + } + + private static String interactiveChooseMode() throws IOException { + // 使用 TestModeConfig 生成的菜单文本 + System.out.print(TestModeConfig.getInteractiveMenuText()); + System.out.flush(); + + BufferedReader br = new BufferedReader(new InputStreamReader(System.in)); + String line = br.readLine(); + String choice = (line == null || line.trim().isEmpty()) ? + TestModeConfig.DEFAULT_MODE_ID : line.trim(); + + // 解析选择并返回 group key + String groupKey = TestModeConfig.resolveToGroupKey(choice); + if (groupKey == null) { + System.out.println(" [WARN] 无效选项: " + choice + ",使用默认 basic"); + return TestModeConfig.DEFAULT_MODE_GROUP; + } + + return groupKey; + } + + private static void printWelcome() { + printSeparator("═"); + System.out.println(" Paimon 1.3.1 写入性能参数调优测试"); + System.out.printf(" 时 间: %s%n", new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(new Date())); + + // 根据存储类型显示不同的仓库信息 + if (ENABLE_S3) { + System.out.printf(" 仓 库: S3 - %s/%s%n", S3_ENDPOINT, S3_WAREHOUSE); + System.out.printf(" 模 式: S3 对象存储%n"); + } else { + System.out.printf(" 仓 库: %s%n", BASE_TEST_DIR); + System.out.printf(" 模 式: 本地文件系统%n"); + } + + System.out.printf(" Java : %s 堆: %dMB CPU: %d 核%n", + System.getProperty("java.version"), + Runtime.getRuntime().maxMemory() / 1024 / 1024, + Runtime.getRuntime().availableProcessors()); + printSeparator("═"); + } + + private static void printSummaryTable(List results) { + printSeparator("─"); + System.out.println(" 汇总表"); + printSeparator("─"); + System.out.printf(" %-8s %-30s %12s %8s %6s %10s%n", + "用例", "名称", "吞吐(条/s)", "用时(s)", "文件数", "总大小"); + printSeparator("-"); + for (TestResult r : results) { + String status = r.error == null ? "✅" : "❌"; + System.out.printf(" %-8s %-30s %12.0f %8.2f %6d %10s %s%n", + r.testCase.getId(), + truncate(r.testCase.getName(), 28), + r.throughput, + r.durationMs / 1000.0, + r.fileCount, + PaimonFileObserver.formatSize(r.totalFileSize), + status); + } + printSeparator("─"); + // 最佳配置 + results.stream().filter(r -> r.error == null).max(Comparator.comparingDouble(r -> r.throughput)) + .ifPresent(r -> System.out.printf(" 最高吞吐: %s (%.0f 条/秒)%n", r.testCase.getId(), r.throughput)); + results.stream().filter(r -> r.error == null && r.fileCount > 0).min(Comparator.comparingInt(r -> r.fileCount)) + .ifPresent(r -> System.out.printf(" 最少文件: %s (%d 个,avg %s)%n", + r.testCase.getId(), r.fileCount, + PaimonFileObserver.formatSize(r.totalFileSize / r.fileCount))); + } + + private static String truncate(String s, int max) { + return s.length() <= max ? s : s.substring(0, max - 1) + "…"; + } +} diff --git a/connectors/connector-perf-test/src/main/java/io/tapdata/connector/paimon/perf/TestCase.java b/connectors/connector-perf-test/src/main/java/io/tapdata/connector/paimon/perf/TestCase.java new file mode 100644 index 000000000..de8e83cf9 --- /dev/null +++ b/connectors/connector-perf-test/src/main/java/io/tapdata/connector/paimon/perf/TestCase.java @@ -0,0 +1,799 @@ +package io.tapdata.connector.paimon.perf; + +import java.util.*; + +import static io.tapdata.connector.paimon.perf.PerformanceTestRunner.TOTAL_RECORDS; +import static io.tapdata.connector.paimon.perf.PerformanceTestRunner.BASE_TEST_DIR; + +/** + * 性能测试用例定义 + * 参数键直接对应 Paimon CoreOptions 表选项名称,可通过 tableProperties 直接注入 + * + *

用例级别的全局参数覆盖

+ *

+ * 默认情况下,所有测试用例使用 PerformanceTestRunner 中定义的全局常量: + *

    + *
  • {@code TOTAL_RECORDS} = 100,000,000(数据集总大小)
  • + *
  • {@code BATCH_SIZE} = 100,000(每批次写入记录数)
  • + *
  • {@code INIT_TOTAL_RECORDS} = 95,000,000(初始化阶段全表数据量)
  • + *
+ *

+ * 如果单个 TestCase 需要灵活控制这些值,可以使用 builder 方法覆盖: + *

{@code
+ * new TestCase("TC-XX", "用例名称", "测试组",
+ *     params(...),
+ *     0, 0,
+ *     "用例描述")
+ *     .totalRecords(5_000_000)        // 覆盖全局 TOTAL_RECORDS
+ *     .batchSize(50_000)              // 覆盖全局 BATCH_SIZE
+ *     .initTotalRecords(4_500_000);   // 覆盖全局 INIT_TOTAL_RECORDS
+ * }
+ * + *

优先级规则

+ *
    + *
  1. 如果 TestCase 中设置了值,以 TestCase 中的值为准
  2. + *
  3. 如果未设置,使用 PerformanceTestRunner 中的全局常量
  4. + *
+ * + *

使用场景

+ *
    + *
  • 快速验证:用小数据量快速验证配置是否正确
  • + *
  • 对比测试:不同数据量下的性能表现对比
  • + *
  • 边界测试:测试极端数据量下的系统行为
  • + *
+ */ +public class TestCase { + private final String id; + private final String name; + private final String group; + private final Map parameters; + private final int primaryKeyDuplicateRate; // 0-100 + private final int qps; // 0 = unlimited + private final String description; + + // ─── 用例级别的全局参数覆盖 ───────────────────────────────────────────── + // 如果设置了这些值,以 TestCase 中的值为准;否则使用 PerformanceTestRunner 的全局常量 + + /** 用例独立的数据集总大小(覆盖 PerformanceTestRunner.TOTAL_RECORDS) */ + private Integer totalRecords = null; + + /** 用例独立的批次大小(覆盖 PerformanceTestRunner.BATCH_SIZE) */ + private Integer batchSize = null; + + /** 用例独立的初始化阶段全表数据量(覆盖 PerformanceTestRunner.INIT_TOTAL_RECORDS) */ + private Integer initTotalRecords = null; + + public TestCase(String id, String name, String group, + Map parameters, + int primaryKeyDuplicateRate, int qps, + String description) { + this.id = id; + this.name = name; + this.group = group; + this.parameters = parameters; + this.primaryKeyDuplicateRate = primaryKeyDuplicateRate; + this.qps = qps; + this.description = description; + } + + // ─── Accessors ──────────────────────────────────────────────────────────── + + public String getId() { return id; } + public String getName() { return name; } + public String getGroup() { return group; } + public Map getParameters() { return parameters; } + + /** + * 获取数据量:默认使用 PerformanceTestRunner.TOTAL_RECORDS, + * 但如果当前 TestCase 设置了 totalRecords,则以用例中的值为准 + */ + public long getDataSize() { + return totalRecords != null ? totalRecords.longValue() : TOTAL_RECORDS; + } + + /** + * 获取当前用例的批次大小 + * 如果未设置,返回 null,调用方应使用 PerformanceTestRunner.BATCH_SIZE + */ + public Integer getBatchSize() { + return batchSize; + } + + /** + * 获取当前用例的初始化阶段全表数据量 + * 如果未设置,返回 null,调用方应使用 PerformanceTestRunner.INIT_TOTAL_RECORDS + */ + public Integer getInitTotalRecords() { + return initTotalRecords; + } + + public int getPrimaryKeyDuplicateRate() { return primaryKeyDuplicateRate; } + public int getQps() { return qps; } + public String getDescription() { return description; } + + @Override + public String toString() { + StringBuilder sb = new StringBuilder(); + sb.append(String.format("[%s] %s (%s) - %s [数据量: %,d", + id, name, group, description, getDataSize())); + + if (batchSize != null) { + sb.append(", batchSize: ").append(batchSize); + } + if (initTotalRecords != null) { + sb.append(", initTotal: ").append(initTotalRecords); + } + sb.append("]"); + return sb.toString(); + } + + // ─── Builder helper ─────────────────────────────────────────────────────── + + private static Map params(String... kvs) { + Map m = new LinkedHashMap<>(); + for (int i = 0; i + 1 < kvs.length; i += 2) m.put(kvs[i], kvs[i + 1]); + return m; + } + + // ─── 用例级别全局参数覆盖的 Builder 方法 ───────────────────────────────── + + /** + * 设置当前用例的数据集总大小(覆盖 PerformanceTestRunner.TOTAL_RECORDS) + */ + public TestCase totalRecords(int records) { + this.totalRecords = records; + return this; + } + + /** + * 设置当前用例的批次大小(覆盖 PerformanceTestRunner.BATCH_SIZE) + */ + public TestCase batchSize(int size) { + this.batchSize = size; + return this; + } + + /** + * 设置当前用例的初始化阶段全表数据量(覆盖 PerformanceTestRunner.INIT_TOTAL_RECORDS) + */ + public TestCase initTotalRecords(int records) { + this.initTotalRecords = records; + return this; + } + + // ─── 5.1 基础测试用例组 ──────────────────────────────────────────────────── + + public static List createBasicTests() { + return Arrays.asList( + new TestCase("TC-01", "基准测试(默认配置)", "基础测试", + params( + "write-buffer-size", "256mb", + "target-file-size", "128mb", + "file.format", "parquet", + "file.compression", "zstd", + "bucket", "-1", + "compaction.async.enabled", "true", + "num-sorted-run.compaction-trigger", "5", + "num-sorted-run.stop-trigger", "8", + "write-only", "false" + ), + 0, 0, + "默认配置基线,建立性能参考点"), + + new TestCase("TC-02", "大数据量测试", "基础测试", + params( + "write-buffer-size", "512mb", + "write-buffer-spillable", "true", + "target-file-size", "256mb", + "file.format", "parquet", + "file.compression", "zstd", + "bucket", "-1", + "compaction.async.enabled", "false", + "write-only", "true" + ), + 0, 0, + "大数据量写入,验证稳定性和文件大小"), + + new TestCase("TC-03", "小批量测试", "基础测试", + params( + "write-buffer-size", "64mb", + "target-file-size", "64mb", + "file.format", "parquet", + "bucket", "-1", + "compaction.async.enabled", "true" + ), + 0, 0, + "小数据量,验证最小写入场景"), + + // 示例:使用用例级别的参数覆盖全局值 + new TestCase("TC-04", "用例独立参数覆盖", "基础测试", + params( + "write-buffer-size", "256mb", + "target-file-size", "128mb", + "file.format", "parquet", + "file.compression", "zstd", + "bucket", "-1", + "compaction.async.enabled", "true", + "write-only", "false" + ), + 0, 0, + "示例:使用 totalRecords()/batchSize()/initTotalRecords() 覆盖全局参数") + .totalRecords(5_000_000) // 覆盖全局 TOTAL_RECORDS(1亿) + .batchSize(50_000) // 覆盖全局 BATCH_SIZE(10万) + .initTotalRecords(4_500_000) // 覆盖全局 INIT_TOTAL_RECORDS(9500万) + ); + } + + // ─── 5.2 写入缓冲区测试组 ───────────────────────────────────────────────── + + public static List createWriteBufferTests() { + return Arrays.asList( + new TestCase("TC-10", "缓冲区-64MB", "写入缓冲区", + params("write-buffer-size", "64mb", "write-buffer-spillable", "true", + "target-file-size", "128mb", "bucket", "-1", + "compaction.async.enabled", "false", "write-only", "true"), + 0, 0, "64MB缓冲区,验证小缓冲区写入行为和文件数量"), + + new TestCase("TC-11", "缓冲区-128MB", "写入缓冲区", + params("write-buffer-size", "128mb", "write-buffer-spillable", "true", + "target-file-size", "128mb", "bucket", "-1", + "compaction.async.enabled", "false", "write-only", "true"), + 0, 0, "128MB缓冲区,中等缓冲区写入行为"), + + new TestCase("TC-12", "缓冲区-256MB(默认)", "写入缓冲区", + params("write-buffer-size", "256mb", "write-buffer-spillable", "true", + "target-file-size", "128mb", "bucket", "-1", + "compaction.async.enabled", "false", "write-only", "true"), + 0, 0, "256MB缓冲区(默认值)"), + + new TestCase("TC-13", "缓冲区-512MB", "写入缓冲区", + params("write-buffer-size", "512mb", "write-buffer-spillable", "true", + "target-file-size", "256mb", "bucket", "-1", + "compaction.async.enabled", "false", "write-only", "true"), + 0, 0, "512MB缓冲区,大缓冲区减少flush次数"), + + new TestCase("TC-14", "缓冲区-1024MB", "写入缓冲区", + params("write-buffer-size", "1024mb", "write-buffer-spillable", "true", + "target-file-size", "512mb", "bucket", "-1", + "compaction.async.enabled", "false", "write-only", "true"), + 0, 0, "1GB缓冲区,超大缓冲区验证极限"), + + new TestCase("TC-15", "缓冲区-不可溢出", "写入缓冲区", + params("write-buffer-size", "256mb", "write-buffer-spillable", "false", + "target-file-size", "128mb", "bucket", "-1", + "compaction.async.enabled", "false", "write-only", "true"), + 0, 0, "关闭溢写,缓冲区满时触发flush,验证OOM风险"), + + new TestCase("TC-16", "缓冲区-可溢出", "写入缓冲区", + params("write-buffer-size", "64mb", "write-buffer-spillable", "true", + "write-buffer-spill.max-disk-size", "1gb", + "target-file-size", "128mb", "bucket", "-1", + "compaction.async.enabled", "false", "write-only", "true"), + 0, 0, "小缓冲区+溢写磁盘,验证溢写行为和最终文件大小") + ); + } + + // ─── 5.3 目标文件大小测试组 ─────────────────────────────────────────────── + + public static List createTargetFileSizeTests() { + return Arrays.asList( + new TestCase("TC-20", "目标文件-64MB", "目标文件大小", + params("write-buffer-size", "512mb", "write-buffer-spillable", "true", + "target-file-size", "64mb", "bucket", "-1", + "compaction.async.enabled", "false", "write-only", "true"), + 0, 0, "64MB目标文件,验证小目标文件的文件数量"), + + new TestCase("TC-21", "目标文件-128MB(默认)", "目标文件大小", + params("write-buffer-size", "512mb", "write-buffer-spillable", "true", + "target-file-size", "128mb", "bucket", "-1", + "compaction.async.enabled", "false", "write-only", "true"), + 0, 0, "128MB目标文件(默认值)"), + + new TestCase("TC-22", "目标文件-256MB", "目标文件大小", + params("write-buffer-size", "512mb", "write-buffer-spillable", "true", + "target-file-size", "256mb", "bucket", "-1", + "compaction.async.enabled", "false", "write-only", "true"), + 0, 0, "256MB目标文件,减少文件数量"), + + new TestCase("TC-23", "目标文件-512MB", "目标文件大小", + params("write-buffer-size", "1024mb", "write-buffer-spillable", "true", + "target-file-size", "512mb", "bucket", "-1", + "compaction.async.enabled", "false", "write-only", "true"), + 0, 0, "512MB目标文件,超大目标文件") + ); + } + + // ─── 5.4 分桶策略测试组 ─────────────────────────────────────────────────── + + public static List createBucketTests() { + return Arrays.asList( + new TestCase("TC-30", "分桶-bucket=-2(延迟分桶)", "分桶策略", + params("write-buffer-size", "512mb", "write-buffer-spillable", "true", + "target-file-size", "256mb", "bucket", "-2", + "compaction.async.enabled", "false", "write-only", "true"), + 0, 0, "bucket=-2 延迟分桶,验证自动调整分桶性能和文件布局"), + + new TestCase("TC-31", "分桶-动态(bucket=-1)", "分桶策略", + params("write-buffer-size", "512mb", "write-buffer-spillable", "true", + "target-file-size", "256mb", "bucket", "-1", + "compaction.async.enabled", "false", "write-only", "true"), + 0, 0, "bucket=-1 动态分桶,基准对比"), + + new TestCase("TC-32", "分桶-固定4桶", "分桶策略", + params("write-buffer-size", "512mb", "write-buffer-spillable", "true", + "target-file-size", "256mb", "bucket", "4", + "compaction.async.enabled", "false", "write-only", "true"), + 0, 0, "固定4桶,验证固定分桶文件分布"), + + new TestCase("TC-33", "分桶-固定8桶", "分桶策略", + params("write-buffer-size", "512mb", "write-buffer-spillable", "true", + "target-file-size", "256mb", "bucket", "8", + "compaction.async.enabled", "false", "write-only", "true"), + 0, 0, "固定8桶,验证更多分桶的文件分布"), + + new TestCase("TC-34", "分桶-固定16桶", "分桶策略", + params("write-buffer-size", "512mb", "write-buffer-spillable", "true", + "target-file-size", "256mb", "bucket", "16", + "compaction.async.enabled", "false", "write-only", "true"), + 0, 0, "固定16桶"), + + new TestCase("TC-35", "分桶-固定32桶", "分桶策略", + params("write-buffer-size", "512mb", "write-buffer-spillable", "true", + "target-file-size", "256mb", "bucket", "32", + "compaction.async.enabled", "false", "write-only", "true"), + 0, 0, "固定32桶,桶数过多时的文件碎片化"), + new TestCase("TC-36", "分桶-固定1桶", "分桶策略", + params("write-buffer-size", "512mb", "write-buffer-spillable", "true", + "target-file-size", "256mb", "bucket", "1", + "compaction.async.enabled", "false", "write-only", "true"), + 0, 0, "固定1桶,桶数少时的文件碎片化") + ); + } + + // ─── 5.5 合并(Compaction)策略测试组 ────────────────────────────────────── + + public static List createCompactionTests() { + return Arrays.asList( + new TestCase("TC-40", "无小合并|定期大合并|增量写入", "合并策略对比测试", + params("write-buffer-size", "128mb", + "write-buffer-spillable", "true", + "target-file-size", "128mb", + "bucket", "1", + "diskMaxSize", "10", + //sorted runs 过多时 merge 防 OOM: + "sort-spill-threshold", "100", + "sort-spill-buffer-size", "128mb", + "write-only", "false", + "compaction.optimization-interval", "5min",//10sec + //保持默认即可:因为新生成的文件不可能是大文件,当前最小的几个 sorted run 加在一起,是否比下一个 sorted run 小到一定比例 + "compaction.size-ratio", "0", + //文件写放大倍数:即使太多小文件也不触发Full compaction: + "compaction.max-size-amplification-percent", "50000", + "full-compaction.delta-commits", "200", +// "compaction.total-size-threshold", "100", +// "compaction.force-rewrite-all-files", "true", + "num-sorted-run.compaction-trigger", "200", + //# 写入永不因 sorted runs 过多而暂停(默认 = trigger+3 = 8) + "num-sorted-run.stop-trigger", "2147483647"), + 0, 0, + "无小合并|定期大合并|增量写入"), + new TestCase("TC-41", "小合并-默认(trigger=5)|定期大合并|增量写入", "合并策略对比测试", + params("write-buffer-size", "128mb", + "write-buffer-spillable", "true", + "target-file-size", "128mb", + "bucket", "1", + "diskMaxSize", "10", + //sorted runs 过多时 merge 防 OOM: + "sort-spill-threshold", "100", + "sort-spill-buffer-size", "128mb", + "write-only", "false", + "compaction.optimization-interval", "5min",//10sec + //保持默认即可:因为新生成的文件不可能是大文件,当前最小的几个 sorted run 加在一起,是否比下一个 sorted run 小到一定比例 + "compaction.size-ratio", "0", + //文件写放大倍数:即使太多小文件也不触发Full compaction: + "compaction.max-size-amplification-percent", "50000", + "full-compaction.delta-commits", "200", +// "compaction.total-size-threshold", "100", +// "compaction.force-rewrite-all-files", "true", + "num-sorted-run.compaction-trigger", "5", + //# 写入永不因 sorted runs 过多而暂停(默认 = trigger+3 = 8) + "num-sorted-run.stop-trigger", "8"), + 0, 0, + "小合并-默认(trigger=5)|定期大合并|增量写入:合并最频繁,验证合并开销"), + new TestCase("TC-42", "小合并10|定期大合并|增量写入", "合并策略对比测试", + params("write-buffer-size", "128mb", + "write-buffer-spillable", "true", + "target-file-size", "128mb", + "bucket", "1", + "diskMaxSize", "10", + //sorted runs 过多时 merge 防 OOM: + "sort-spill-threshold", "100", + "sort-spill-buffer-size", "128mb", + "write-only", "false", + "compaction.optimization-interval", "5min",//10sec + //保持默认即可:因为新生成的文件不可能是大文件,当前最小的几个 sorted run 加在一起,是否比下一个 sorted run 小到一定比例 + "compaction.size-ratio", "0", + //文件写放大倍数:即使太多小文件也不触发Full compaction: + "compaction.max-size-amplification-percent", "50000", + "full-compaction.delta-commits", "200", +// "compaction.total-size-threshold", "100", +// "compaction.force-rewrite-all-files", "true", + "num-sorted-run.compaction-trigger", "10", + //# 写入永不因 sorted runs 过多而暂停(默认 = trigger+3 = 8) + "num-sorted-run.stop-trigger", "20"), + 0, 0, + "10个文件一次小合并|定期大合并|增量写入"), + new TestCase("TC-43", "保守小合并20|定期大合并|增量写入", "合并策略对比测试", + params("write-buffer-size", "128mb", + "write-buffer-spillable", "true", + "target-file-size", "128mb", + "bucket", "1", + "diskMaxSize", "10", + //sorted runs 过多时 merge 防 OOM: + "sort-spill-threshold", "100", + "sort-spill-buffer-size", "128mb", + "write-only", "false", + "compaction.optimization-interval", "5min",//10sec + //保持默认即可:因为新生成的文件不可能是大文件,当前最小的几个 sorted run 加在一起,是否比下一个 sorted run 小到一定比例 + "compaction.size-ratio", "0", + //文件写放大倍数:即使太多小文件也不触发Full compaction: + "compaction.max-size-amplification-percent", "50000", + "full-compaction.delta-commits", "200", +// "compaction.total-size-threshold", "100", +// "compaction.force-rewrite-all-files", "true", + "num-sorted-run.compaction-trigger", "20", + //# 写入永不因 sorted runs 过多而暂停(默认 = trigger+3 = 8) + "num-sorted-run.stop-trigger", "30"), + 0, 0, + "保守小合并20|定期大合并|增量写入"), + new TestCase("TC-44", "合并-write-only模式|增量写入", "合并策略对比测试", + params("write-buffer-size", "128mb", + "write-buffer-spillable", "true", + "target-file-size", "128mb", + "bucket", "1", + "diskMaxSize", "10", + //sorted runs 过多时 merge 防 OOM: + "sort-spill-threshold", "100", + "sort-spill-buffer-size", "128mb", + "write-only", "true", + "compaction.optimization-interval", "5min",//10sec + //保持默认即可:因为新生成的文件不可能是大文件,当前最小的几个 sorted run 加在一起,是否比下一个 sorted run 小到一定比例 + "compaction.size-ratio", "0", + //文件写放大倍数:即使太多小文件也不触发Full compaction: + "compaction.max-size-amplification-percent", "50000", + "full-compaction.delta-commits", "200", +// "compaction.total-size-threshold", "100", +// "compaction.force-rewrite-all-files", "true", + "num-sorted-run.compaction-trigger", "200", + //# 写入永不因 sorted runs 过多而暂停(默认 = trigger+3 = 8) + "num-sorted-run.stop-trigger", "2147483647"), + 0, 0, + "write-only=true:完全跳过合并,最大化写入吞吐"), + new TestCase("TC-45", "参数最佳实践:30小合并|1h大合并|增量10亿数据写入", "合并策略对比测试", + params("write-buffer-size", "128mb", + "write-buffer-spillable", "true", + "target-file-size", "128mb", + //bucket越少越好: + "bucket", "1", + "diskMaxSize", "10", + //sorted runs 过多时 merge 防 OOM: + "sort-spill-threshold","100", + "sort-spill-buffer-size","128mb", + "write-only", "false", + "compaction.optimization-interval","60min",//10sec + //保持默认即可:因为新生成的文件(L0)不可能是大文件,只要保证合并都是L0的就没写放大:当前最小的几个 sorted run 加在一起,是否比下一个 sorted run 小到一定比例 + "compaction.size-ratio","1", + //文件写放大倍数:即使太多小文件也不触发Full compaction: + "compaction.max-size-amplification-percent","400", +// "full-compaction.delta-commits", "200", + //只合并本次待合并的文件总大小没达到158m就合并,而不是所有文件总大小达到158mb才合并: +// "compaction.total-size-threshold", "158mb", +// "compaction.force-rewrite-all-files", "true", + "num-sorted-run.compaction-trigger", "30", + //# 写入永不因 sorted runs 过多而暂停(默认 = trigger+3 = 8) + "num-sorted-run.stop-trigger", "200", + "snapshot.num-retained.min", "1", + "snapshot.num-retained.max", "1", + "snapshot.time-retained", "30min"), + 0, 0, + "30小合并|1h大合并|增量10亿增量写入") +// new TestCase("TC-45", "参数最佳实践:30小合并|1h大合并|增量10亿数据写入", "合并策略对比测试", +// params("write-buffer-size", "128mb", +// "write-buffer-spillable", "true", +// "target-file-size", "128mb", +// //bucket越少越好: +// "bucket", "1", +// "diskMaxSize", "10", +// //sorted runs 过多时 merge 防 OOM: +// "sort-spill-threshold","100", +// "sort-spill-buffer-size","128mb", +// "write-only", "false", +// "compaction.optimization-interval","60min",//10sec +// //保持默认即可:因为新生成的文件(L0)不可能是大文件,只要保证合并都是L0的就没写放大:当前最小的几个 sorted run 加在一起,是否比下一个 sorted run 小到一定比例 +// "compaction.size-ratio","1", +// //文件写放大倍数:即使太多小文件也不触发Full compaction: +// "compaction.max-size-amplification-percent","50000", +//// "full-compaction.delta-commits", "200", +// //只合并本次待合并的文件总大小没达到158m就合并,而不是所有文件总大小达到158mb才合并: +//// "compaction.total-size-threshold", "158mb", +//// "compaction.force-rewrite-all-files", "true", +// "num-sorted-run.compaction-trigger", "30", +// //# 写入永不因 sorted runs 过多而暂停(默认 = trigger+3 = 8) +// "num-sorted-run.stop-trigger", "2147483647", +// "snapshot.num-retained.min", "1", +// "snapshot.num-retained.max", "1", +// "snapshot.time-retained", "30min"), +// 0, 0, +// "30小合并|1h大合并|增量10亿增量写入") +// .batchSize(100_000) +// .initTotalRecords(0) +// .totalRecords(1_000_000_000) + ); + } + + // ─── 5.6 无小文件写入测试组(重点) ──────────────────────────────────────── + + public static List createNoSmallFileTests() { + return Arrays.asList( + new TestCase("TC-50", "无小文件-大缓冲|无小合并|定期大合并|一次性写入", "无小文件", + params("write-buffer-size", "2gb", + "write-buffer-spillable", "true", + "target-file-size", "128mb", + "bucket", "1", + "write-buffer-spillable", "true", + "diskMaxSize", "10", + "compaction.async.enabled111", "true", + "write-only", "false", + "compaction.optimization-interval","5min",//10sec + //保持默认即可:因为新生成的文件不可能是大文件,当前最小的几个 sorted run 加在一起,是否比下一个 sorted run 小到一定比例 + "compaction.size-ratio","0", + //文件写放大倍数:即使太多小文件也不触发Full compaction: + "compaction.max-size-amplification-percent","50000", + //sorted runs 过多时 merge 防 OOM: + "sort-spill-threshold","100", + "sort-spill-buffer-size","128mb", + "num-sorted-run.compaction-trigger", "200", + //# 写入永不因 sorted runs 过多而暂停(默认 = trigger+3 = 8) + "num-sorted-run.stop-trigger", "2147483647"), + 0, 0, + "大缓冲(512MB)+大目标文件(128MB)+禁止小合并,一次性写入验证") + .batchSize(100_000) + .initTotalRecords(100_000_000) + .totalRecords(100_000_000), + + new TestCase("TC-51", ":无小文件-小缓冲|无小合并|定期大合并|全量&增量", "无小文件", + params("write-buffer-size", "128mb", + "write-buffer-spillable", "true", + "target-file-size", "128mb", + "bucket", "1", + "write-buffer-spillable", "true", + "diskMaxSize", "1", + "write-only", "false", + "compaction.optimization-interval","5min",//10sec + //保持默认即可:因为新生成的文件不可能是大文件,当前最小的几个 sorted run 加在一起,是否比下一个 sorted run 小到一定比例 + "compaction.size-ratio","0", + //文件写放大倍数:即使太多小文件也不触发Full compaction: + "compaction.max-size-amplification-percent","50000", + //sorted runs 过多时 merge 防 OOM: + "sort-spill-threshold","100", + "sort-spill-buffer-size","128mb", + "num-sorted-run.compaction-trigger", "200", + //# 写入永不因 sorted runs 过多而暂停(默认 = trigger+3 = 8) + "num-sorted-run.stop-trigger", "2147483647"), + 0, 0, + "小缓冲(128MB)+大目标文件(128MB)+禁止小合并,全量&增量") + .batchSize(100_000) + .initTotalRecords(90_000_000) + .totalRecords(100_000_000), + + new TestCase("TC-52", ":无小文件-小缓冲|无小合并|定期大合并|全量&增量|bucket=-2", "无小文件", + params("write-buffer-size", "128mb", + "write-buffer-spillable", "true", + "target-file-size", "128mb", + //性能最好: + "bucket", "1", + "write-buffer-spillable", "true", + "diskMaxSize", "1", + "write-only", "false", + "compaction.optimization-interval","5min",//10sec + //保持默认即可:因为新生成的文件不可能是大文件,当前最小的几个 sorted run 加在一起,是否比下一个 sorted run 小到一定比例 + "compaction.size-ratio","0", + //文件写放大倍数:即使太多小文件也不触发Full compaction: + "compaction.max-size-amplification-percent","50000", + //sorted runs 过多时 merge 防 OOM: + "sort-spill-threshold","100", + "sort-spill-buffer-size","128mb", + "num-sorted-run.compaction-trigger", "200", + //# 写入永不因 sorted runs 过多而暂停(默认 = trigger+3 = 8) + "num-sorted-run.stop-trigger", "2147483647"), + 0, 0, + "小缓冲(128MB)+大目标文件(128MB)+禁止小合并,全量&增量,bucket=-2,验证延迟分桶的无小文件效果,但需要Full compaction后才能可见") + .batchSize(100_000) + .initTotalRecords(90_000_000) + .totalRecords(100_000_000), + + new TestCase("TC-53", ":无小文件-小缓冲|无小合并|定期大合并|全量&增量|增加local-merge-buffer", "无小文件", + params("local-merge-buffer-size", "64mb", + "write-buffer-size", "128mb", + "write-buffer-spillable", "true", + "target-file-size", "128mb", + "bucket", "1", + "write-buffer-spillable", "true", + "diskMaxSize", "1", + "write-only", "false", + "compaction.optimization-interval","5min",//10sec + //保持默认即可:因为新生成的文件不可能是大文件,当前最小的几个 sorted run 加在一起,是否比下一个 sorted run 小到一定比例 + "compaction.size-ratio","0", + //文件写放大倍数:即使太多小文件也不触发Full compaction: + "compaction.max-size-amplification-percent","50000", + //sorted runs 过多时 merge 防 OOM: + "sort-spill-threshold","100", + "sort-spill-buffer-size","128mb", + "num-sorted-run.compaction-trigger", "200", + //# 写入永不因 sorted runs 过多而暂停(默认 = trigger+3 = 8) + "num-sorted-run.stop-trigger", "2147483647"), + 0, 0, + "小缓冲(128MB)+大目标文件(128MB)+禁止小合并,全量&增量|增加local-merge-buffer") + .batchSize(100_000) + .initTotalRecords(90_000_000) + .totalRecords(100_000_000), + + new TestCase("TC-54", ":无小文件-小缓冲|无小合并|定期大合并|全量&增量|增加local-merge-buffer|changelog-producer", "无小文件", + params("local-merge-buffer-size", "64mb", + "changelog-producer", "input", + "write-buffer-size", "128mb", + "write-buffer-spillable", "true", + "target-file-size", "128mb", + "bucket", "1", + "write-buffer-spillable", "true", + "diskMaxSize", "1", + "write-only", "false", + "compaction.optimization-interval","5min",//10sec + //保持默认即可:因为新生成的文件不可能是大文件,当前最小的几个 sorted run 加在一起,是否比下一个 sorted run 小到一定比例 + "compaction.size-ratio","0", + //文件写放大倍数:即使太多小文件也不触发Full compaction: + "compaction.max-size-amplification-percent","50000", + //sorted runs 过多时 merge 防 OOM: + "sort-spill-threshold","100", + "sort-spill-buffer-size","128mb", + "num-sorted-run.compaction-trigger", "200", + //# 写入永不因 sorted runs 过多而暂停(默认 = trigger+3 = 8) + "num-sorted-run.stop-trigger", "2147483647"), + 0, 0, + "小缓冲(128MB)+大目标文件(128MB)+禁止小合并,全量&增量|增加local-merge-buffer|changelog-producer") + .batchSize(100_000) + .initTotalRecords(90_000_000) + .totalRecords(100_000_000) + ); + } + + // ─── 5.7 文件格式和压缩测试组 ───────────────────────────────────────────── + + public static List createFormatCompressionTests() { + return Arrays.asList( + new TestCase("TC-60", "格式-Parquet+ZSTD(默认)", "文件格式", + params("write-buffer-size", "256mb", "target-file-size", "128mb", + "file.format", "parquet", "file.compression", "zstd", + "bucket", "-1", "write-only", "true"), + 0, 0, "Parquet+ZSTD,默认配置基准"), + + new TestCase("TC-61", "格式-Parquet+LZ4", "文件格式", + params("write-buffer-size", "256mb", "target-file-size", "128mb", + "file.format", "parquet", "file.compression", "lz4", + "bucket", "-1", "write-only", "true"), + 0, 0, "Parquet+LZ4,更快压缩速度但压缩率稍低"), + + new TestCase("TC-62", "格式-Parquet+无压缩", "文件格式", + params("write-buffer-size", "256mb", "target-file-size", "128mb", + "file.format", "parquet", "file.compression", "gzip", + "bucket", "-1", "write-only", "true"), + 0, 0, "Parquet+无压缩,最快写入速度但文件最大"), + + new TestCase("TC-63", "格式-ORC+ZSTD", "文件格式", + params("write-buffer-size", "256mb", "target-file-size", "128mb", + "file.format", "orc", "file.compression", "zstd", + "bucket", "-1", "write-only", "true"), + 0, 0, "ORC+ZSTD,列存格式写入性能对比"), + + new TestCase("TC-64", "格式-ORC+LZ4", "文件格式", + params("write-buffer-size", "256mb", "target-file-size", "128mb", + "file.format", "orc", "file.compression", "lz4", + "bucket", "-1", "write-only", "true"), + 0, 0, "ORC+LZ4") + ); + } + + // ─── 5.8 主键更新测试组 ─────────────────────────────────────────────────── + + public static List createPrimaryKeyUpdateTests() { + return Arrays.asList( + new TestCase("TC-70", "主键-纯插入(0%重复)", "主键更新", + params("write-buffer-size", "256mb", "target-file-size", "128mb", + "bucket", "-1", "compaction.async.enabled", "true", + "write-only", "false"), + 0, 0, "0%主键重复,纯INSERT性能基准"), + + new TestCase("TC-71", "主键-10%重复", "主键更新", + params("write-buffer-size", "256mb", "target-file-size", "128mb", + "bucket", "-1", "compaction.async.enabled", "true", + "write-only", "false"), + 10, 0, "10%主键重复(低更新率),触发部分UPDATE合并"), + + new TestCase("TC-72", "主键-30%重复", "主键更新", + params("write-buffer-size", "256mb", "target-file-size", "128mb", + "bucket", "-1", "compaction.async.enabled", "true", + "write-only", "false"), + 30, 0, "30%主键重复(中等更新率)"), + + new TestCase("TC-73", "主键-50%重复", "主键更新", + params("write-buffer-size", "256mb", "target-file-size", "128mb", + "bucket", "-1", "compaction.async.enabled", "true", + "write-only", "false"), + 50, 0, "50%主键重复(高更新率),大量合并开销") + ); + } + + // ─── 5.9 并行度测试组 ───────────────────────────────────────────────────── + + public static List createParallelismTests() { + return Arrays.asList( + new TestCase("TC-80", "并行度-1线程", "写入并行度", + params("write-buffer-size", "512mb", "target-file-size", "256mb", + "bucket", "1", "sink.parallelism", "1", + "compaction.async.enabled", "false", "write-only", "true"), + 0, 0, "单线程写入,最少文件数"), + + new TestCase("TC-81", "并行度-2线程", "写入并行度", + params("write-buffer-size", "512mb", "target-file-size", "256mb", + "bucket", "2", "sink.parallelism", "2", + "compaction.async.enabled", "false", "write-only", "true"), + 0, 0, "2线程写入"), + + new TestCase("TC-82", "并行度-4线程", "写入并行度", + params("write-buffer-size", "512mb", "target-file-size", "256mb", + "bucket", "4", "sink.parallelism", "4", + "compaction.async.enabled", "false", "write-only", "true"), + 0, 0, "4线程写入(默认)"), + + new TestCase("TC-83", "并行度-8线程", "写入并行度", + params("write-buffer-size", "512mb", "target-file-size", "256mb", + "bucket", "8", "sink.parallelism", "8", + "compaction.async.enabled", "false", "write-only", "true"), + 0, 0, "8线程写入") + ); + } + + // ─── 组合工厂方法 ───────────────────────────────────────────────────────── + + public static List createAllTests() { + List all = new ArrayList<>(); + all.addAll(createBasicTests()); + all.addAll(createWriteBufferTests()); + all.addAll(createTargetFileSizeTests()); + all.addAll(createBucketTests()); + all.addAll(createCompactionTests()); + all.addAll(createNoSmallFileTests()); + all.addAll(createFormatCompressionTests()); + all.addAll(createPrimaryKeyUpdateTests()); + all.addAll(createParallelismTests()); + return all; + } + + /** + * 获取所有支持的测试组名称 + */ + public static Map getGroupDescriptions() { + Map m = new LinkedHashMap<>(); + m.put("basic", "基础测试用例(TC-01~03) - 默认配置基线"); + m.put("buffer", "写入缓冲区测试(TC-10~16) - write-buffer-size / spillable"); + m.put("target", "目标文件大小测试(TC-20~23) - target-file-size"); + m.put("bucket", "分桶策略测试(TC-30~35) - bucket=-2/-1/固定"); + m.put("compaction", "合并策略测试(TC-40~45) - compaction trigger/stop/write-only"); + m.put("nosmallfile", "无小文件写入测试(TC-50~53) - 生产最优配置"); + m.put("format", "文件格式&压缩测试(TC-60~64) - parquet/orc + zstd/lz4"); + m.put("pkupdate", "主键更新测试(TC-70~73) - 重复率 0%/10%/30%/50%"); + m.put("parallelism", "写入并行度测试(TC-80~83) - sink.parallelism"); + m.put("all", "全量测试(所有组)"); + return m; + } +} diff --git a/connectors/connector-perf-test/src/main/java/io/tapdata/connector/paimon/perf/TestModeConfig.java b/connectors/connector-perf-test/src/main/java/io/tapdata/connector/paimon/perf/TestModeConfig.java new file mode 100644 index 000000000..ad497a946 --- /dev/null +++ b/connectors/connector-perf-test/src/main/java/io/tapdata/connector/paimon/perf/TestModeConfig.java @@ -0,0 +1,252 @@ +package io.tapdata.connector.paimon.perf; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * 测试模式统一配置 + * + *

集中定义所有测试模式的定义、别名、描述和映射关系 + * 确保 Java 代码和 Shell 脚本使用同一份配置源 + * + *

新增测试模式只需在此类中添加一个 TestModeEntry 即可 + */ +public class TestModeConfig { + + /** + * 测试模式条目 + */ + public static class TestModeEntry { + private final String id; // 数字编号(用于交互式菜单) + private final String primaryAlias; // 主别名(用于命令行参数) + private final String groupKey; // 对应的 TestCase 组 key + private final String description; // 描述信息 + private final String testCaseRange; // 测试用例范围说明 + + public TestModeEntry(String id, String primaryAlias, String groupKey, + String description, String testCaseRange) { + this.id = id; + this.primaryAlias = primaryAlias; + this.groupKey = groupKey; + this.description = description; + this.testCaseRange = testCaseRange; + } + + public String getId() { return id; } + public String getPrimaryAlias() { return primaryAlias; } + public String getGroupKey() { return groupKey; } + public String getDescription() { return description; } + public String getTestCaseRange() { return testCaseRange; } + + /** + * 获取所有可用的别名(包括 id 和 primaryAlias) + */ + public List getAllAliases() { + List aliases = new ArrayList<>(); + aliases.add(id); + aliases.add(primaryAlias); + return Collections.unmodifiableList(aliases); + } + + @Override + public String toString() { + return String.format("%s %-14s - %s (%s)", id, primaryAlias, description, testCaseRange); + } + } + + // ═══════════════════════════════════════════════════════════════════════ + // 统一测试模式配置列表 + // 新增测试模式请在此处添加,无需修改其他地方 + // ═══════════════════════════════════════════════════════════════════════ + + /** 所有测试模式定义 */ + public static final List ALL_MODES; + + static { + List modes = new ArrayList<>(); + + // 基础测试 + modes.add(new TestModeEntry( + "1", "basic", "basic", + "基础用例组", "TC-01~03" + )); + + // 全量测试 + modes.add(new TestModeEntry( + "2", "all", "all", + "全量测试(所有组)", "所有用例" + )); + + // 无小文件测试 + modes.add(new TestModeEntry( + "3", "nosmallfile", "nosmallfile", + "无小文件测试", "TC-50~54" + )); + + // 单个基准用例 + modes.add(new TestModeEntry( + "4", "single", "single", + "单个基准用例", "TC-01" + )); + + // 分桶策略测试 + modes.add(new TestModeEntry( + "5", "bucket", "bucket", + "分桶策略测试", "TC-30~35" + )); + + // 合并策略测试 + modes.add(new TestModeEntry( + "6", "compaction", "compaction", + "合并策略测试", "TC-40~45" + )); + + // 写入缓冲区测试 + modes.add(new TestModeEntry( + "7", "buffer", "buffer", + "写入缓冲区测试", "TC-10~16" + )); + + // 目标文件大小测试 + modes.add(new TestModeEntry( + "8", "target", "target", + "目标文件大小测试", "TC-20~23" + )); + + // 文件格式压缩测试 + modes.add(new TestModeEntry( + "9", "format", "format", + "文件格式压缩测试", "TC-60~64" + )); + + // 主键更新测试 + modes.add(new TestModeEntry( + "10", "pkupdate", "pkupdate", + "主键更新测试", "TC-70~73" + )); + + // 并行度测试 + modes.add(new TestModeEntry( + "11", "parallelism", "parallelism", + "写入并行度测试", "TC-80~83" + )); + + ALL_MODES = Collections.unmodifiableList(modes); + } + + /** + * 特殊模式:自动模式(无需交互,全自动运行) + */ + public static final String AUTO_MODE_KEY = "auto"; + + /** + * 默认模式(用户未指定时使用) + */ + public static final String DEFAULT_MODE_ID = "1"; + public static final String DEFAULT_MODE_ALIAS = "basic"; + public static final String DEFAULT_MODE_GROUP = "basic"; + + /** + * 根据输入(id 或别名)查找对应的组 key + * + * @param input 用户输入(如 "1", "basic", "nosmallfile") + * @return 组 key(如 "basic", "nosmallfile"),如果未找到返回 null + */ + public static String resolveToGroupKey(String input) { + if (input == null || input.trim().isEmpty()) { + return null; + } + + String normalized = input.trim().toLowerCase(); + + // 特殊处理 auto 模式 + if (AUTO_MODE_KEY.equals(normalized)) { + return AUTO_MODE_KEY; // auto 模式默认运行全量 + } + + // 遍历所有模式查找匹配 + for (TestModeEntry mode : ALL_MODES) { + if (mode.getId().equals(normalized) || + mode.getPrimaryAlias().equals(normalized)) { + return mode.getGroupKey(); + } + } + + return null; // 未找到 + } + + /** + * 获取交互式菜单的显示文本 + * + * @return 菜单文本(多行) + */ + public static String getInteractiveMenuText() { + StringBuilder sb = new StringBuilder(); + sb.append("\n 请选择测试模式(直接回车默认 ").append(DEFAULT_MODE_ID).append("):\n"); + + for (TestModeEntry mode : ALL_MODES) { + sb.append(" ").append(mode.toString()).append("\n"); + } + + sb.append(" auto - 全自动运行(无需交互)\n"); + sb.append("\n > "); + + return sb.toString(); + } + + /** + * 获取 Shell 脚本用的菜单文本(用于 run-perf-test.sh) + * + * @return 菜单文本(多行) + */ + public static String getShellMenuText() { + StringBuilder sb = new StringBuilder(); + sb.append("请选择测试模式 (直接回车默认 ").append(DEFAULT_MODE_ID).append("):\n"); + + for (TestModeEntry mode : ALL_MODES) { + sb.append(" ").append(mode.toString()).append("\n"); + } + + sb.append(" auto - 全自动运行(无需按回车)\n"); + + return sb.toString(); + } + + /** + * 获取所有可用的命令行参数别名 + * 用于 Shell 脚本的参数验证和提示 + * + * @return 别名字符串(空格分隔) + */ + public static String getAllAliasesForShell() { + StringBuilder sb = new StringBuilder(); + for (TestModeEntry mode : ALL_MODES) { + sb.append(mode.getPrimaryAlias()).append(" "); + } + sb.append(AUTO_MODE_KEY); + return sb.toString().trim(); + } + + /** + * 获取所有模式的帮助信息(用于 --help 参数) + * + * @return 帮助文本 + */ + public static String getHelpText() { + StringBuilder sb = new StringBuilder(); + sb.append("Paimon 写入性能参数调优测试\n\n"); + sb.append("用法: ./run-perf-test.sh [模式]\n\n"); + sb.append("可用模式:\n"); + + for (TestModeEntry mode : ALL_MODES) { + sb.append(String.format(" %-14s %s (%s)\n", + mode.getPrimaryAlias(), mode.getDescription(), mode.getTestCaseRange())); + } + + sb.append(String.format("\n默认模式: %s (%s)\n", DEFAULT_MODE_ALIAS, DEFAULT_MODE_ID)); + sb.append("自动模式: auto(无需交互,运行全量测试)\n"); + + return sb.toString(); + } +} diff --git a/connectors/connector-perf-test/src/main/java/io/tapdata/connector/paimon/perf/UnifiedFileSystem.java b/connectors/connector-perf-test/src/main/java/io/tapdata/connector/paimon/perf/UnifiedFileSystem.java new file mode 100644 index 000000000..34eecf881 --- /dev/null +++ b/connectors/connector-perf-test/src/main/java/io/tapdata/connector/paimon/perf/UnifiedFileSystem.java @@ -0,0 +1,301 @@ +package io.tapdata.connector.paimon.perf; + +import com.amazonaws.ClientConfiguration; +import com.amazonaws.auth.AWSStaticCredentialsProvider; +import com.amazonaws.auth.BasicAWSCredentials; +import com.amazonaws.client.builder.AwsClientBuilder; +import com.amazonaws.services.s3.AmazonS3; +import com.amazonaws.services.s3.AmazonS3ClientBuilder; +import com.amazonaws.services.s3.model.ObjectMetadata; +import com.amazonaws.services.s3.model.S3ObjectSummary; +import org.apache.hadoop.fs.FileStatus; +import org.apache.hadoop.fs.Path; + +import java.io.IOException; +import java.net.URI; +import java.util.ArrayList; +import java.util.List; + +/** + * 统一文件系统接口 - 支持本地和 S3 + */ +public interface UnifiedFileSystem { + + boolean exists(String path) throws IOException; + boolean isDirectory(String path) throws IOException; + FileStatus[] listStatus(String path) throws IOException; + List listFilesRecursive(String path) throws IOException; + URI getUri(); + void close() throws IOException; + + static UnifiedFileSystem createLocal() { + return new LocalFileSystemImpl(); + } + + static UnifiedFileSystem createS3(String endpoint, String accessKey, String secretKey, String region, String bucket) { + return new S3FileSystemImpl(endpoint, accessKey, secretKey, region, bucket); + } + + /** + * 本地文件系统实现 + */ + class LocalFileSystemImpl implements UnifiedFileSystem { + private final org.apache.hadoop.fs.FileSystem fs; + + LocalFileSystemImpl() { + try { + this.fs = org.apache.hadoop.fs.FileSystem.getLocal(new org.apache.hadoop.conf.Configuration()); + } catch (IOException e) { + throw new RuntimeException("Failed to create local filesystem", e); + } + } + + @Override + public boolean exists(String path) throws IOException { + return fs.exists(new org.apache.hadoop.fs.Path(path)); + } + + @Override + public boolean isDirectory(String path) throws IOException { + return fs.isDirectory(new org.apache.hadoop.fs.Path(path)); + } + + @Override + public FileStatus[] listStatus(String path) throws IOException { + return fs.listStatus(new org.apache.hadoop.fs.Path(path)); + } + + @Override + public List listFilesRecursive(String path) throws IOException { + List files = new ArrayList<>(); + org.apache.hadoop.fs.Path rootPath = new org.apache.hadoop.fs.Path(path); + if (!fs.exists(rootPath)) return files; + + org.apache.hadoop.fs.RemoteIterator iterator = + fs.listFiles(rootPath, true); + while (iterator.hasNext()) { + files.add(iterator.next()); + } + return files; + } + + @Override + public URI getUri() { + return fs.getUri(); + } + + @Override + public void close() throws IOException { + fs.close(); + } + } + + /** + * S3 文件系统实现(使用 AWS S3 SDK) + */ + class S3FileSystemImpl implements UnifiedFileSystem { + private final AmazonS3 s3Client; + private final String endpoint; + private final String bucket; + private final URI endpointUri; + + S3FileSystemImpl(String endpoint, String accessKey, String secretKey, String region, String bucket) { + try { + this.endpoint = endpoint; + this.bucket = bucket; + this.endpointUri = URI.create(endpoint); + + System.out.println(" [DEBUG] S3 配置 - Endpoint: " + endpoint + ", Bucket: " + bucket); + + // 创建 AWS 凭证 + BasicAWSCredentials awsCreds = new BasicAWSCredentials(accessKey, secretKey); + + // 创建 S3 客户端 + this.s3Client = AmazonS3ClientBuilder.standard() + .withEndpointConfiguration(new AwsClientBuilder.EndpointConfiguration(endpoint, region)) + .withCredentials(new AWSStaticCredentialsProvider(awsCreds)) + .withPathStyleAccessEnabled(true) // MinIO 需要路径样式访问 + .disableChunkedEncoding() // 某些 MinIO 版本需要禁用 + .build(); + + System.out.println(" [DEBUG] S3 客户端创建成功"); + + } catch (Exception e) { + throw new RuntimeException("Failed to create S3 filesystem: " + e.getMessage(), e); + } + } + + @Override + public boolean exists(String path) throws IOException { + try { + String key = extractKey(path); + if (key == null || key.isEmpty()) { + // 检查 bucket 是否存在 + return s3Client.doesBucketExistV2(bucket); + } + + // 检查对象是否存在 + return s3Client.doesObjectExist(bucket, key + "/schema/schema-0"); + } catch (Exception e) { + System.err.println(" [WARN] 检查路径存在失败: " + path + " - " + e.getMessage()); + e.printStackTrace(); + return false; + } + } + + @Override + public boolean isDirectory(String path) throws IOException { + try { + String key = extractKey(path); + if (key == null || key.isEmpty() || key.endsWith("/")) { + return true; // bucket 或目录 + } + + // 检查是否是对象(文件) + if (s3Client.doesObjectExist(bucket, key)) { + ObjectMetadata metadata = s3Client.getObjectMetadata(bucket, key); + // S3 中目录通常以 / 结尾且内容为空 + return key.endsWith("/") || metadata.getContentLength() == 0; + } + + // 检查是否有以此键为前缀的对象(说明是目录) + com.amazonaws.services.s3.model.ObjectListing listing = s3Client.listObjects( + new com.amazonaws.services.s3.model.ListObjectsRequest() + .withBucketName(bucket) + .withPrefix(key.endsWith("/") ? key : key + "/") + .withMaxKeys(1) + ); + + return !listing.getObjectSummaries().isEmpty(); + } catch (Exception e) { + System.err.println(" [WARN] 检查目录失败: " + path + " - " + e.getMessage()); + return false; + } + } + + @Override + public FileStatus[] listStatus(String path) throws IOException { + try { + String key = extractKey(path); + String prefix = key.endsWith("/") ? key : (key.isEmpty() ? "" : key + "/"); + + List statuses = new ArrayList<>(); + com.amazonaws.services.s3.model.ListObjectsRequest request = new com.amazonaws.services.s3.model.ListObjectsRequest() + .withBucketName(bucket) + .withPrefix(prefix) + .withDelimiter("/") + .withMaxKeys(1000); + + com.amazonaws.services.s3.model.ObjectListing listing = s3Client.listObjects(request); + + // 添加目录(CommonPrefixes) + for (String commonPrefix : listing.getCommonPrefixes()) { + String dirPath = commonPrefix; + statuses.add(new FileStatus( + 0, + true, // isDir + 1, + 128 * 1024 * 1024, + 0, + new Path("s3://" + bucket + "/" + dirPath) + )); + } + + // 添加文件 + for (S3ObjectSummary summary : listing.getObjectSummaries()) { + // 跳过目录标记对象 + if (summary.getKey().endsWith("/") && summary.getSize() == 0) { + continue; + } + statuses.add(new FileStatus( + summary.getSize(), + false, // isDir + 1, + 128 * 1024 * 1024, + summary.getLastModified().getTime(), + new Path("s3://" + bucket + "/" + summary.getKey()) + )); + } + + return statuses.toArray(new FileStatus[0]); + } catch (Exception e) { + throw new IOException("Failed to list status: " + path, e); + } + } + + @Override + public List listFilesRecursive(String path) throws IOException { + List files = new ArrayList<>(); + String key = extractKey(path); + String prefix = key.endsWith("/") ? key : (key.isEmpty() ? "" : key + "/"); + + try { + // 递归列出所有对象 + com.amazonaws.services.s3.model.ObjectListing listing = null; + do { + if (listing == null) { + listing = s3Client.listObjects(bucket, prefix); + } else { + listing = s3Client.listNextBatchOfObjects(listing); + } + + for (S3ObjectSummary summary : listing.getObjectSummaries()) { + // 跳过目录标记 + if (summary.getKey().endsWith("/") && summary.getSize() == 0) { + continue; + } + files.add(new FileStatus( + summary.getSize(), + false, + 1, + 128 * 1024 * 1024, + summary.getLastModified().getTime(), + new Path("s3://" + bucket + "/" + summary.getKey()) + )); + } + } while (listing.isTruncated()); + + } catch (Exception e) { + throw new IOException("Failed to list files recursively: " + path, e); + } + + return files; + } + + /** + * 从 S3 路径提取 key(去掉 bucket 部分) + * 输入: s3://bucket/path/to/file 或 /bucket/path/to/file + * 输出: path/to/file + */ + private String extractKey(String path) { + if (path == null || path.isEmpty()) { + return ""; + } + + // 去掉 s3:// 前缀 + if (path.startsWith("s3://")) { + path = path.substring(5); + } + + // 去掉 bucket 名称 + int slashIdx = path.indexOf('/'); + if (slashIdx >= 0) { + return path.substring(slashIdx + 1); + } + + return ""; + } + + @Override + public URI getUri() { + return endpointUri; + } + + @Override + public void close() throws IOException { + if (s3Client != null) { + s3Client.shutdown(); + } + } + } +} diff --git a/connectors/connector-perf-test/src/main/resources/logback.xml b/connectors/connector-perf-test/src/main/resources/logback.xml new file mode 100644 index 000000000..e613d2a6c --- /dev/null +++ b/connectors/connector-perf-test/src/main/resources/logback.xml @@ -0,0 +1,58 @@ + + + + %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ${LOG_PATH}/app.log + + + + ${LOG_PATH}/app.%d{yyyy-MM-dd}.log + + 15 + + + + ${LOG_PATTERN} + UTF-8 + + + !-- 日志输出格式 --> + + + + + + + diff --git a/connectors/connector-perf-test/src/test/java/io/tapdata/connector/paimon/perf/DataGeneratorTest.java b/connectors/connector-perf-test/src/test/java/io/tapdata/connector/paimon/perf/DataGeneratorTest.java new file mode 100644 index 000000000..de65fd421 --- /dev/null +++ b/connectors/connector-perf-test/src/test/java/io/tapdata/connector/paimon/perf/DataGeneratorTest.java @@ -0,0 +1,667 @@ +package io.tapdata.connector.paimon.perf; + +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.Timeout; + +import java.util.*; +import java.util.concurrent.*; +import java.util.stream.Collectors; + +import io.tapdata.entity.event.dml.TapInsertRecordEvent; +import io.tapdata.entity.event.dml.TapRecordEvent; +import io.tapdata.entity.event.dml.TapUpdateRecordEvent; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * DataGenerator 单元测试 + * + *

验证目标: + *

    + *
  • ID 生成正确性(连续性、唯一性)
  • + *
  • 重复率合理性(实际重复率 ≈ 配置重复率)
  • + *
  • 重复 ID 分布均匀性(无热点)
  • + *
  • 大规模数据生成(10亿+ 内存恒定)
  • + *
  • 确定性伪随机可重现性
  • + *
+ */ +@DisplayName("DataGenerator 单元测试") +class DataGeneratorTest { + + // ═══════════════════════════════════════════════════════════ + // 1. 新 ID 生成测试 + // ═══════════════════════════════════════════════════════════ + + @Nested + @DisplayName("新 ID 生成") + class NewIdGeneration { + + @Test + @DisplayName("0%重复率 - 所有ID唯一且递增") + void allIdsUniqueAndIncremental() { + DataGenerator gen = new DataGenerator(0, "test_table"); + Set seenIds = new HashSet<>(); + + for (int i = 0; i < 100_000; i++) { + Map record = gen.generateRecord(); + String id = (String) record.get("id"); + + assertNotNull(id, "ID 不应为 null"); + assertFalse(seenIds.contains(id), "ID 不应重复: " + id); + seenIds.add(id); + } + + assertEquals(100_000, seenIds.size(), "应生成 100K 唯一 ID"); + assertEquals(100_000, gen.getUniqueIdsCount(), "唯一 ID 计数应匹配"); + } + + @Test + @DisplayName("ID 内容为数字字符串") + void idsAreNumericStrings() { + DataGenerator gen = new DataGenerator(0); + + for (int i = 0; i < 1000; i++) { + Map record = gen.generateRecord(); + String id = (String) record.get("id"); + assertDoesNotThrow(() -> Long.parseLong(id), "ID 应为合法数字: " + id); + } + } + + @Test + @DisplayName("记录包含所有必需字段") + void recordHasAllFields() { + DataGenerator gen = new DataGenerator(0); + Map record = gen.generateRecord(); + + assertTrue(record.containsKey("id"), "应包含 id 字段"); + assertTrue(record.containsKey("name"), "应包含 name 字段"); + assertTrue(record.containsKey("value"), "应包含 value 字段"); + assertTrue(record.containsKey("ts"), "应包含 ts 字段"); + + assertNotNull(record.get("name")); + assertNotNull(record.get("value")); + assertNotNull(record.get("ts")); + } + } + + // ═══════════════════════════════════════════════════════════ + // 2. 重复率合理性测试 + // ═══════════════════════════════════════════════════════════ + + @Nested + @DisplayName("重复率验证") + class DuplicateRateValidation { + + @Test + @DisplayName("0%重复率 - 无重复ID") + void zeroPercentDuplicate() { + DataGenerator gen = new DataGenerator(0); + Set ids = new HashSet<>(); + int count = 50_000; + + for (int i = 0; i < count; i++) { + ids.add((String) gen.generateRecord().get("id")); + } + + assertEquals(count, ids.size(), "0% 重复率下不应有重复 ID"); + } + + @Test + @DisplayName("50%重复率 - 实际重复率应在 45%-55% 范围内") + void fiftyPercentDuplicate() { + DataGenerator gen = new DataGenerator(50); + Map idCounts = new HashMap<>(); + int count = 100_000; + int warmup = 1000; // 预热期,不统计 + + // 预热阶段 + for (int i = 0; i < warmup; i++) { + gen.generateRecord(); + } + + // 统计阶段 + for (int i = 0; i < count; i++) { + String id = (String) gen.generateRecord().get("id"); + idCounts.put(id, idCounts.getOrDefault(id, 0) + 1); + } + + long duplicateCount = idCounts.values().stream() + .filter(c -> c > 1) + .mapToLong(c -> c - 1) + .sum(); + + double actualRate = (double) duplicateCount / count * 100; + + // 允许 ±5% 误差(统计学波动) + assertTrue(actualRate >= 45 && actualRate <= 55, + String.format("实际重复率 %.2f%% 应在 45%%-55%% 范围内", actualRate)); + } + + @Test + @DisplayName("100%重复率 - 所有ID都来自重复池") + void hundredPercentDuplicate() { + DataGenerator gen = new DataGenerator(100); + Set ids = new HashSet<>(); + int count = 10_000; + int warmup = 100; + + // 预热:生成一些初始 ID + for (int i = 0; i < warmup; i++) { + ids.add((String) gen.generateRecord().get("id")); + } + + int initialUniqueCount = ids.size(); + + // 统计:100% 重复率下,新 ID 应全部来自重复池 + for (int i = 0; i < count; i++) { + String id = (String) gen.generateRecord().get("id"); + ids.add(id); + } + + int finalUniqueCount = ids.size(); + int newUniqueIds = finalUniqueCount - initialUniqueCount; + + // 100% 重复率下,新 ID 应极少(统计误差范围内) + // 允许少量误差(预热期后仍有新 ID 生成的边界情况) + assertTrue(newUniqueIds < count * 0.05, + String.format("100%% 重复率下新唯一 ID 数 %d 应 < 5%%", newUniqueIds)); + } + + @Test + @DisplayName("10%重复率 - 低重复场景") + void tenPercentDuplicate() { + DataGenerator gen = new DataGenerator(10); + Map idCounts = new HashMap<>(); + int count = 50_000; + int warmup = 500; + + // 预热 + for (int i = 0; i < warmup; i++) { + gen.generateRecord(); + } + + // 统计 + for (int i = 0; i < count; i++) { + String id = (String) gen.generateRecord().get("id"); + idCounts.put(id, idCounts.getOrDefault(id, 0) + 1); + } + + long duplicateCount = idCounts.values().stream() + .filter(c -> c > 1) + .mapToLong(c -> c - 1) + .sum(); + + double actualRate = (double) duplicateCount / count * 100; + + // 允许 ±3% 误差 + assertTrue(actualRate >= 7 && actualRate <= 13, + String.format("实际重复率 %.2f%% 应在 7%%-13%% 范围内", actualRate)); + } + } + + // ═══════════════════════════════════════════════════════════ + // 3. 重复 ID 分布均匀性测试 + // ═══════════════════════════════════════════════════════════ + + @Nested + @DisplayName("重复 ID 分布均匀性") + class DuplicateDistribution { + + @Test + @DisplayName("50%重复率 - ID访问分布均匀") + void evenDistributionAcrossIdSpace() { + DataGenerator gen = new DataGenerator(50); + int warmup = 10_000; + int count = 100_000; + int bucketCount = 100; + int[] buckets = new int[bucketCount]; + + // 预热 + for (int i = 0; i < warmup; i++) { + gen.generateRecord(); + } + + // 统计重复 ID 的分布 + for (int i = 0; i < count; i++) { + String idStr = (String) gen.generateRecord().get("id"); + long id = Long.parseLong(idStr); + int bucket = (int) (id % bucketCount); + buckets[bucket]++; + } + + // 计算每个桶的期望值和实际偏差 + double expected = (double) count / bucketCount; + double maxDeviation = 0; + + for (int bucket : buckets) { + double deviation = Math.abs(bucket - expected) / expected * 100; + maxDeviation = Math.max(maxDeviation, deviation); + } + + // 最大偏差不应超过 20%(哈希函数保证均匀分布) + assertTrue(maxDeviation < 20, + String.format("ID 分布最大偏差 %.2f%% 应 < 20%%", maxDeviation)); + } + + @Test + @DisplayName("重复ID不集中在某个小区间") + void noHotspotInDuplicateIds() { + DataGenerator gen = new DataGenerator(80); + Map idCounts = new HashMap<>(); + int warmup = 5000; + int count = 50_000; + + // 预热 + for (int i = 0; i < warmup; i++) { + gen.generateRecord(); + } + + // 统计 + for (int i = 0; i < count; i++) { + String id = (String) gen.generateRecord().get("id"); + idCounts.put(id, idCounts.getOrDefault(id, 0) + 1); + } + + // 找出最频繁的 ID + int maxFreq = idCounts.values().stream().max(Integer::compareTo).orElse(0); + double maxFreqRate = (double) maxFreq / count * 100; + + // 单个 ID 不应占总数的 > 1%(证明无热点) + assertTrue(maxFreqRate < 1.0, + String.format("最频繁 ID 占比 %.2f%% 应 < 1%%", maxFreqRate)); + } + + @Test + @DisplayName("100%重复率 - 所有ID均匀分布") + void uniformDistributionAtHundredPercent() { + DataGenerator gen = new DataGenerator(100); + int warmup = 1000; + int count = 20_000; + Map idCounts = new LinkedHashMap<>(); + + // 预热 + for (int i = 0; i < warmup; i++) { + gen.generateRecord(); + } + + // 统计 + for (int i = 0; i < count; i++) { + String id = (String) gen.generateRecord().get("id"); + idCounts.put(id, idCounts.getOrDefault(id, 0) + 1); + } + + // 卡方检验简化版:各桶计数应接近期望 + int uniqueIds = idCounts.size(); + double expected = (double) count / uniqueIds; + double chiSquare = 0; + + for (int observed : idCounts.values()) { + double diff = observed - expected; + chiSquare += (diff * diff) / expected; + } + + // 自由度 = uniqueIds - 1,p=0.01 临界值约为 uniqueIds + 3*sqrt(2*uniqueIds) + double criticalValue = uniqueIds + 3 * Math.sqrt(2 * uniqueIds); + assertTrue(chiSquare < criticalValue, + String.format("卡方值 %.2f 应 < 临界值 %.2f(分布均匀)", chiSquare, criticalValue)); + } + } + + // ═══════════════════════════════════════════════════════════ + // 4. 确定性伪随机可重现性测试 + // ═══════════════════════════════════════════════════════════ + + @Nested + @DisplayName("确定性伪随机") + class DeterministicPseudoRandom { + + @Test + @DisplayName("确定性伪随机可重现性验证") + void deterministicMappingReproducible() { + // MurmurHash3 确定性验证:相同输入产生相同输出 + // 由于 random 是 private,我们验证 ID 生成逻辑的确定性部分 + + // 验证 deterministicDuplicateId 的确定性 + // 通过生成相同模式的记录,验证 ID 分布一致性 + DataGenerator gen1 = new DataGenerator(50, "test_table"); + DataGenerator gen2 = new DataGenerator(50, "test_table"); + + // 生成相同数量的预热记录 + for (int i = 0; i < 1000; i++) { + gen1.generateRecord(); + gen2.generateRecord(); + } + + // 统计后续生成中重复 ID 的分布特征 + Map dist1 = new HashMap<>(); + Map dist2 = new HashMap<>(); + + for (int i = 0; i < 5000; i++) { + String id1 = (String) gen1.generateRecord().get("id"); + String id2 = (String) gen2.generateRecord().get("id"); + dist1.put(id1, dist1.getOrDefault(id1, 0) + 1); + dist2.put(id2, dist2.getOrDefault(id2, 0) + 1); + } + + // 两个生成器应有相似的重复率(统计意义上) + double rate1 = dist1.values().stream().filter(c -> c > 1).mapToInt(Integer::intValue).sum() / 50.0; + double rate2 = dist2.values().stream().filter(c -> c > 1).mapToInt(Integer::intValue).sum() / 50.0; + + // 两者重复率应接近(误差 < 10%) + assertTrue(Math.abs(rate1 - rate2) < 10, + String.format("重复率差异 %.2f%% 应 < 10%%", Math.abs(rate1 - rate2))); + } + } + + // ═══════════════════════════════════════════════════════════ + // 5. 大规模数据生成测试 + // ═══════════════════════════════════════════════════════════ + + @Nested + @DisplayName("大规模数据生成") + class LargeScaleGeneration { + + @Test + @Timeout(value = 60, unit = TimeUnit.SECONDS) + @DisplayName("1000万条生成 - 验证性能") + void tenMillionRecords() { + DataGenerator gen = new DataGenerator(30); + int count = 10_000_000; + + long start = System.currentTimeMillis(); + for (int i = 0; i < count; i++) { + gen.generateRecord(); + } + long elapsed = System.currentTimeMillis() - start; + + double throughput = (double) count / elapsed * 1000; + System.out.printf("1000万条生成耗时: %d ms, 吞吐: %.0f 条/秒%n", elapsed, throughput); + + assertTrue(elapsed < 30_000, "1000万条应在 30s 内完成,实际: " + elapsed + "ms"); + assertTrue(throughput > 100_000, "吞吐应 > 100K 条/秒,实际: " + throughput); + } + + @Test + @DisplayName("内存占用恒定 - 1亿条无OOM") + @Timeout(value = 120, unit = TimeUnit.SECONDS) + void constantMemoryUsage() { + DataGenerator gen = new DataGenerator(50); + int count = 100_000_000; // 1 亿条 + + Runtime rt = Runtime.getRuntime(); + long startMem = rt.totalMemory() - rt.freeMemory(); + + // 生成数据 + for (int i = 0; i < count; i++) { + gen.generateRecord(); + } + + System.gc(); // 提示 GC + long endMem = rt.totalMemory() - rt.freeMemory(); + long memIncrease = endMem - startMem; + + // 内存增长应 < 100 MB(证明无累积) + assertTrue(memIncrease < 100_000_000, + String.format("1亿条后内存增长 %d MB 应 < 100 MB", memIncrease / 1_000_000)); + } + } + + // ═══════════════════════════════════════════════════════════ + // 6. generateUpdateEvent 测试 + // ═══════════════════════════════════════════════════════════ + + @Nested + @DisplayName("generateUpdateEvent") + class UpdateEventGeneration { + + @Test + @DisplayName("无预热时返回null") + void returnsNullWithoutWarmup() { + DataGenerator gen = new DataGenerator(0); + assertNull(gen.generateUpdateEvent(), "无历史记录时应返回 null"); + } + + @Test + @DisplayName("预热后生成有效更新事件") + void generatesValidUpdateAfterWarmup() { + DataGenerator gen = new DataGenerator(0); + + // 预热 + for (int i = 0; i < 100; i++) { + gen.generateRecord(); + } + + // 生成更新事件 + for (int i = 0; i < 50; i++) { + var event = gen.generateUpdateEvent(); + assertNotNull(event, "应生成更新事件"); + assertNotNull(event.getBefore(), "应包含 before 数据"); + assertNotNull(event.getAfter(), "应包含 after 数据"); + assertEquals("test_table", event.getTableId(), "表名应匹配"); + + // before 和 after 的 ID 应相同 + assertEquals( + event.getBefore().get("id"), + event.getAfter().get("id"), + "更新前后 ID 应一致" + ); + } + } + + @Test + @DisplayName("更新事件的ID来自历史生成范围") + void updateEventIdsFromHistory() { + DataGenerator gen = new DataGenerator(0); + int warmup = 10_000; + Set historicalIds = new HashSet<>(); + + // 预热并记录历史 ID + for (int i = 0; i < warmup; i++) { + String id = (String) gen.generateRecord().get("id"); + historicalIds.add(id); + } + + // 生成更新事件,验证 ID 来自历史范围 + for (int i = 0; i < 1000; i++) { + var event = gen.generateUpdateEvent(); + String id = (String) event.getBefore().get("id"); + long idNum = Long.parseLong(id); + + // ID 应在 [1, warmup] 范围内 + assertTrue(idNum >= 1 && idNum <= warmup, + String.format("更新事件 ID %d 应在历史范围内 [1, %d]", idNum, warmup)); + } + } + } + + // ═══════════════════════════════════════════════════════════ + // 7. 边界条件测试 + // ═══════════════════════════════════════════════════════════ + + @Nested + @DisplayName("边界条件") + class BoundaryConditions { + + @Test + @DisplayName("重复率限制在0-100范围") + void duplicateRateClamped() { + // 负值应被限制为 0 + DataGenerator gen1 = new DataGenerator(-10); + // 通过生成大量记录验证无重复 + Set ids = new HashSet<>(); + for (int i = 0; i < 1000; i++) { + ids.add((String) gen1.generateRecord().get("id")); + } + assertEquals(1000, ids.size(), "负重复率应被限制为 0"); + + // 超100应被限制为 100 + DataGenerator gen2 = new DataGenerator(150); + // 预热后应全部重复 + for (int i = 0; i < 100; i++) gen2.generateRecord(); + Set ids2 = new HashSet<>(); + for (int i = 0; i < 1000; i++) { + ids2.add((String) gen2.generateRecord().get("id")); + } + assertTrue(ids2.size() < 100, "150% 重复率应被限制为 100%"); + } + + @Test + @DisplayName("批量生成方法正确性") + void batchGeneration() { + DataGenerator gen = new DataGenerator(0); + int batchSize = 500; + + List events = gen.generateRecordEvents(batchSize); + + assertEquals(batchSize, events.size(), "批量大小应匹配"); + for (TapRecordEvent event : events) { + // generateRecordEvents 生成的是 TapInsertRecordEvent + assertTrue(event instanceof TapInsertRecordEvent, "事件类型应为 TapInsertRecordEvent"); + TapInsertRecordEvent insertEvent = (TapInsertRecordEvent) event; + assertNotNull(insertEvent.getAfter(), "事件应包含 after 数据"); + assertEquals("test_table", event.getTableId(), "表名应匹配"); + } + } + + @Test + @DisplayName("generateTapTable 生成有效表结构") + void generateValidTapTable() { + DataGenerator gen = new DataGenerator(0); + var table = gen.generateTapTable(); + + assertNotNull(table); + assertEquals("test_table", table.getName()); + assertEquals("test_table", table.getId()); + assertTrue(table.primaryKeys().contains("id"), "id 应为主键"); + } + + @Test + @DisplayName("generateRecordsWithRate 回调正确性") + void generateRecordsWithRateCallback() { + DataGenerator gen = new DataGenerator(0); + List receivedBatchSizes = new ArrayList<>(); + int totalRecords = 5000; + int qps = 0; // 不限制 QPS + + gen.generateRecordsWithRate(totalRecords, qps, events -> { + receivedBatchSizes.add(events.size()); + }); + + int totalReceived = receivedBatchSizes.stream().mapToInt(Integer::intValue).sum(); + assertEquals(totalRecords, totalReceived, "总接收记录数应匹配"); + } + } + + // ═══════════════════════════════════════════════════════════ + // 8. 统计分析辅助测试 + // ═══════════════════════════════════════════════════════════ + + @Nested + @DisplayName("统计分析") + class StatisticalAnalysis { + + @Test + @DisplayName("getUniqueIdsCount 准确性") + void uniqueIdsCountAccuracy() { + DataGenerator gen = new DataGenerator(0); + int count = 50_000; + + for (int i = 0; i < count; i++) { + gen.generateRecord(); + } + + assertEquals(count, gen.getUniqueIdsCount(), + "唯一 ID 计数应等于生成次数(0% 重复)"); + } + + @Test + @DisplayName("getTotalGenerated 返回唯一ID数(非调用次数)") + void totalGeneratedAccuracy() { + DataGenerator gen = new DataGenerator(0); + int count = 10_000; + + for (int i = 0; i < count; i++) { + gen.generateRecord(); + } + + // 0% 重复率下,getTotalGenerated 应等于调用次数 + assertEquals(count, gen.getTotalGenerated(), + "0%% 重复率下总生成计数应等于调用次数"); + + // 测试 50% 重复率 + DataGenerator gen2 = new DataGenerator(50); + int warmup = 1000; + int statCount = 10_000; + + // 预热 + for (int i = 0; i < warmup; i++) { + gen2.generateRecord(); + } + + long uniqueBefore = gen2.getTotalGenerated(); + + // 统计 + for (int i = 0; i < statCount; i++) { + gen2.generateRecord(); + } + + long uniqueAfter = gen2.getTotalGenerated(); + long uniqueAdded = uniqueAfter - uniqueBefore; + + // 50% 重复率下,新增唯一 ID 数应约为 statCount * 0.5 + double expectedUnique = statCount * 0.5; + double tolerance = statCount * 0.15; // 允许 15% 误差 + assertTrue(Math.abs(uniqueAdded - expectedUnique) < tolerance, + String.format("50%% 重复率下新增唯一 ID %d 应接近 %.0f (±%.0f)", + uniqueAdded, expectedUnique, tolerance)); + } + + @Test + @DisplayName("重复ID的ID值分布统计") + void duplicateIdValueDistribution() { + DataGenerator gen = new DataGenerator(70); + int warmup = 10000; + int count = 50000; + Map idCounts = new HashMap<>(); + + // 预热 + for (int i = 0; i < warmup; i++) { + gen.generateRecord(); + } + + // 统计 + for (int i = 0; i < count; i++) { + String id = (String) gen.generateRecord().get("id"); + idCounts.put(id, idCounts.getOrDefault(id, 0) + 1); + } + + // 分析重复 ID 的频次分布 + Map freqDistribution = new HashMap<>(); + for (int freq : idCounts.values()) { + freqDistribution.merge(freq, 1, Integer::sum); + } + + System.out.println("\n=== 重复 ID 频次分布 ==="); + freqDistribution.entrySet().stream() + .sorted(Map.Entry.comparingByKey()) + .forEach(e -> System.out.printf( + "出现 %d 次的 ID 有 %d 个%n", e.getKey(), e.getValue())); + + // 计算实际重复率:基于唯一 ID 数 vs 总记录数 + int uniqueIds = idCounts.size(); + int totalRecords = idCounts.values().stream().mapToInt(Integer::intValue).sum(); + int duplicateRecords = totalRecords - uniqueIds; + double actualDuplicateRate = (double) duplicateRecords / totalRecords * 100; + + System.out.printf("%n实际重复率分析:%n"); + System.out.printf(" 总记录数: %,d%n", totalRecords); + System.out.printf(" 唯一 ID 数: %,d%n", uniqueIds); + System.out.printf(" 重复记录数: %,d%n", duplicateRecords); + System.out.printf(" 实际重复率: %.2f%%%n", actualDuplicateRate); + + // 70% 重复率下,实际重复率应在 60%-80% 范围 + assertTrue(actualDuplicateRate >= 60 && actualDuplicateRate <= 80, + String.format("实际重复率 %.2f%% 应在 60%%-80%% 范围内(配置 70%%)", actualDuplicateRate)); + } + } +} diff --git a/connectors/connector-perf-test/src/test/java/io/tapdata/connector/paimon/perf/S3FileScanDebugTest.java b/connectors/connector-perf-test/src/test/java/io/tapdata/connector/paimon/perf/S3FileScanDebugTest.java new file mode 100644 index 000000000..a5c2efa0a --- /dev/null +++ b/connectors/connector-perf-test/src/test/java/io/tapdata/connector/paimon/perf/S3FileScanDebugTest.java @@ -0,0 +1,176 @@ +package io.tapdata.connector.paimon.perf; + +import com.amazonaws.services.s3.AmazonS3; +import com.amazonaws.services.s3.AmazonS3ClientBuilder; +import com.amazonaws.services.s3.model.ObjectMetadata; +import com.amazonaws.services.s3.model.S3ObjectSummary; +import com.amazonaws.auth.AWSStaticCredentialsProvider; +import com.amazonaws.auth.BasicAWSCredentials; +import com.amazonaws.client.builder.AwsClientBuilder; + +/** + * S3 文件扫描调试测试(使用 AWS S3 SDK) + */ +public class S3FileScanDebugTest { + + // S3 配置(根据你的实际情况修改) + private static final String S3_ENDPOINT = "http://192.168.1.184:9081"; + private static final String S3_ACCESS_KEY = "admin"; + private static final String S3_SECRET_KEY = "admin123"; + private static final String S3_REGION = "us-east-1"; + private static final String S3_BUCKET = "luke"; + private static final String S3_WAREHOUSE = "warehouse-paimon-perf"; + + public static void main(String[] args) { + System.out.println("=== S3 文件扫描调试测试(AWS S3 SDK) ===\n"); + + try { + test1_S3Connection(); + test2_ListBucket(); + test3_ScanWarehouse(); + + System.out.println("\n=== ✅ 所有测试通过 ==="); + } catch (Exception e) { + System.err.println("\n❌ 测试失败: " + e.getMessage()); + e.printStackTrace(); + } + } + + /** + * 测试 1: S3 连接验证 + */ + private static void test1_S3Connection() { + System.out.println("[测试 1] 验证 S3 连接..."); + System.out.println(" Endpoint: " + S3_ENDPOINT); + System.out.println(" Bucket: " + S3_BUCKET); + System.out.println(" Region: " + S3_REGION); + + AmazonS3 s3Client = createS3Client(); + + try { + // 验证 bucket 是否存在 + boolean exists = s3Client.doesBucketExistV2(S3_BUCKET); + System.out.println(" Bucket 存在: " + exists); + + if (exists) { + System.out.println(" ✅ S3 连接成功"); + } else { + System.out.println(" ⚠️ Bucket 不存在,请检查配置"); + } + } catch (Exception e) { + System.out.println(" ❌ 连接失败: " + e.getMessage()); + } finally { + s3Client.shutdown(); + } + } + + /** + * 测试 2: 列出 Bucket 内容 + */ + private static void test2_ListBucket() { + System.out.println("\n[测试 2] 列出 Bucket 内容..."); + AmazonS3 s3Client = createS3Client(); + + try { + com.amazonaws.services.s3.model.ObjectListing listing = s3Client.listObjects(S3_BUCKET); + + System.out.println(" 对象总数: " + listing.getObjectSummaries().size()); + + if (!listing.getObjectSummaries().isEmpty()) { + System.out.println(" 前 10 个对象:"); + int count = 0; + for (S3ObjectSummary summary : listing.getObjectSummaries()) { + if (count >= 10) break; + System.out.printf(" - %s (%s)%n", summary.getKey(), formatSize(summary.getSize())); + count++; + } + } + + System.out.println(" ✅ Bucket 列出成功"); + } catch (Exception e) { + System.out.println(" ❌ 列出失败: " + e.getMessage()); + } finally { + s3Client.shutdown(); + } + } + + /** + * 测试 3: 扫描 Warehouse 目录 + */ + private static void test3_ScanWarehouse() { + System.out.println("\n[测试 3] 扫描 Warehouse 目录..."); + String warehousePath = S3_WAREHOUSE; + System.out.println(" Warehouse: " + warehousePath); + + AmazonS3 s3Client = createS3Client(); + + try { + // 递归列出所有对象 + int fileCount = 0; + long totalSize = 0; + int parquetCount = 0; + + com.amazonaws.services.s3.model.ObjectListing listing = null; + do { + if (listing == null) { + listing = s3Client.listObjects(S3_BUCKET, warehousePath); + } else { + listing = s3Client.listNextBatchOfObjects(listing); + } + + for (S3ObjectSummary summary : listing.getObjectSummaries()) { + String key = summary.getKey(); + fileCount++; + totalSize += summary.getSize(); + + if (key.endsWith(".parquet")) { + parquetCount++; + } + + // 显示前 10 个 parquet 文件 + if (parquetCount <= 10 && key.endsWith(".parquet")) { + System.out.printf(" 📄 %s (%s)%n", key, formatSize(summary.getSize())); + } + } + } while (listing.isTruncated()); + + System.out.println("\n 统计信息:"); + System.out.printf(" 总文件数: %d%n", fileCount); + System.out.printf(" Parquet 文件: %d%n", parquetCount); + System.out.printf(" 总大小: %s%n", formatSize(totalSize)); + + if (fileCount > 0) { + System.out.println(" ✅ Warehouse 扫描成功"); + } else { + System.out.println(" ⚠️ 未找到文件"); + } + + } catch (Exception e) { + System.out.println(" ❌ 扫描失败: " + e.getMessage()); + e.printStackTrace(); + } finally { + s3Client.shutdown(); + } + } + + /** + * 创建 S3 客户端 + */ + private static AmazonS3 createS3Client() { + BasicAWSCredentials awsCreds = new BasicAWSCredentials(S3_ACCESS_KEY, S3_SECRET_KEY); + + return AmazonS3ClientBuilder.standard() + .withEndpointConfiguration(new AwsClientBuilder.EndpointConfiguration(S3_ENDPOINT, S3_REGION)) + .withCredentials(new AWSStaticCredentialsProvider(awsCreds)) + .withPathStyleAccessEnabled(true) // MinIO 需要 + .disableChunkedEncoding() + .build(); + } + + private static String formatSize(long size) { + if (size < 1024) return size + " B"; + if (size < 1024 * 1024) return String.format("%.2f KB", size / 1024.0); + if (size < 1024 * 1024 * 1024) return String.format("%.2f MB", size / (1024.0 * 1024)); + return String.format("%.2f GB", size / (1024.0 * 1024 * 1024)); + } +} diff --git a/connectors/csv-connector/pom.xml b/connectors/csv-connector/pom.xml index 61da7fb9a..75ee841b4 100644 --- a/connectors/csv-connector/pom.xml +++ b/connectors/csv-connector/pom.xml @@ -15,7 +15,7 @@ jar - 2.0.1-SNAPSHOT + 2.0.5-SNAPSHOT 8 diff --git a/connectors/csv-connector/src/main/resources/spec_csv.json b/connectors/csv-connector/src/main/resources/spec_csv.json index 9267945d1..f0a27412b 100644 --- a/connectors/csv-connector/src/main/resources/spec_csv.json +++ b/connectors/csv-connector/src/main/resources/spec_csv.json @@ -1,6 +1,7 @@ { "properties": { "name": "CSV", + "realName": "CSV", "icon": "icons/csv.png", "doc": "${doc}", "id": "csv", diff --git a/connectors/custom-connector/pom.xml b/connectors/custom-connector/pom.xml index ab2ecc17a..862e7fb6f 100644 --- a/connectors/custom-connector/pom.xml +++ b/connectors/custom-connector/pom.xml @@ -12,7 +12,7 @@ custom-connector - 2.0.0-SNAPSHOT + 2.0.5-SNAPSHOT diff --git a/connectors/custom-connector/src/main/java/io/tapdata/connector/custom/CustomConnector.java b/connectors/custom-connector/src/main/java/io/tapdata/connector/custom/CustomConnector.java index f556d0748..5e66a0ce0 100644 --- a/connectors/custom-connector/src/main/java/io/tapdata/connector/custom/CustomConnector.java +++ b/connectors/custom-connector/src/main/java/io/tapdata/connector/custom/CustomConnector.java @@ -39,9 +39,7 @@ import org.apache.commons.collections4.CollectionUtils; import org.apache.commons.lang3.StringUtils; -import javax.script.Invocable; -import javax.script.ScriptEngine; -import javax.script.ScriptException; +import javax.script.*; import java.io.Closeable; import java.io.IOException; import java.util.*; @@ -424,6 +422,17 @@ private void writeRecord(TapConnectorContext connectorContext, List globalMap = new ConcurrentHashMap<>(); + scriptEngine.put("globalMap", globalMap); + + scriptEngine.getBindings(ScriptContext.GLOBAL_SCOPE).put("globalMap", globalMap); + } writeEnginePool.put(threadName, scriptEngine); } WriteListResult result = new WriteListResult<>(); @@ -454,8 +463,7 @@ private void writeRecord(TapConnectorContext connectorContext, List31.0.1-jre 1.0-SNAPSHOT - 2.0.0-SNAPSHOT + 2.0.6-SNAPSHOT diff --git a/connectors/doris-connector/src/main/java/io/tapdata/connector/doris/DorisConnector.java b/connectors/doris-connector/src/main/java/io/tapdata/connector/doris/DorisConnector.java index d5cd02510..74da793b2 100644 --- a/connectors/doris-connector/src/main/java/io/tapdata/connector/doris/DorisConnector.java +++ b/connectors/doris-connector/src/main/java/io/tapdata/connector/doris/DorisConnector.java @@ -63,7 +63,10 @@ public class DorisConnector extends CommonDbConnector { @Override public void onStart(TapConnectionContext tapConnectionContext) { this.dorisConfig = new DorisConfig().load(tapConnectionContext.getConnectionConfig()); - isConnectorStarted(tapConnectionContext, connectorContext -> dorisConfig.load(connectorContext.getNodeConfig())); + isConnectorStarted(tapConnectionContext, connectorContext -> { + dorisConfig.load(connectorContext.getNodeConfig()); + dorisConfig.setTableConfig(tapConnectionContext.getTableNodeConfig()); + }); dorisJdbcContext = new DorisJdbcContext(dorisConfig); // if (!dorisJdbcContext.queryVersion().contains("2.")) { // dorisConfig.setUpdateSpecific(false); @@ -219,16 +222,16 @@ protected CreateTableOptions createDorisTable(TapConnectorContext connectorConte return createTableOptions; } Collection primaryKeys = tapTable.primaryKeys(true); - DorisTableType uniqueType = DorisTableType.valueOf(dorisConfig.getUniqueKeyType()); + DorisTableType uniqueType = DorisTableType.valueOf(dorisConfig.getUniqueKeyType(tapTable.getId())); StringBuilder stringBuilder = new StringBuilder(); stringBuilder.append("CREATE TABLE IF NOT EXISTS ").append(getSchemaAndTable(tapTable.getId())).append("("); //generate column definition if (uniqueType == DorisTableType.Duplicate) { if (EmptyKit.isEmpty(primaryKeys)) { - if (EmptyKit.isEmpty(dorisConfig.getDuplicateKey())) { + if (EmptyKit.isEmpty(dorisConfig.getDuplicateKey(tapTable.getId()))) { stringBuilder.append(commonSqlMaker.buildColumnDefinition(tapTable, true)); } else { - stringBuilder.append(((DorisSqlMaker) commonSqlMaker).buildColumnDefinitionByOrder(tapTable, dorisConfig.getDuplicateKey(), false)); + stringBuilder.append(((DorisSqlMaker) commonSqlMaker).buildColumnDefinitionByOrder(tapTable, dorisConfig.getDuplicateKey(tapTable.getId()), false)); } } else { stringBuilder.append(((DorisSqlMaker) commonSqlMaker).buildColumnDefinitionByOrder(tapTable, primaryKeys, false)); @@ -243,29 +246,29 @@ protected CreateTableOptions createDorisTable(TapConnectorContext connectorConte //generate key definition stringBuilder.append(") ").append(uniqueType).append(" KEY (`"); if (EmptyKit.isEmpty(primaryKeys)) { - if (EmptyKit.isEmpty(dorisConfig.getDuplicateKey())) { + if (EmptyKit.isEmpty(dorisConfig.getDuplicateKey(tapTable.getId()))) { stringBuilder.append(String.join("`,`", tapTable.getNameFieldMap().keySet())); } else { - stringBuilder.append(String.join("`,`", dorisConfig.getDuplicateKey())); + stringBuilder.append(String.join("`,`", dorisConfig.getDuplicateKey(tapTable.getId()))); } } else { stringBuilder.append(String.join("`,`", primaryKeys)); } stringBuilder.append("`) DISTRIBUTED BY HASH(`"); //generate distributed key - if (EmptyKit.isEmpty(dorisConfig.getDistributedKey())) { + if (EmptyKit.isEmpty(dorisConfig.getDistributedKey(tapTable.getId()))) { if (EmptyKit.isEmpty(primaryKeys)) { stringBuilder.append(String.join("`,`", tapTable.getNameFieldMap().keySet())); } else { stringBuilder.append(String.join("`,`", primaryKeys)); } } else { - stringBuilder.append(String.join("`,`", dorisConfig.getDistributedKey())); + stringBuilder.append(String.join("`,`", dorisConfig.getDistributedKey(tapTable.getId()))); } //generate bucket - stringBuilder.append("`) BUCKETS ").append(dorisConfig.getBucket()).append(" PROPERTIES("); + stringBuilder.append("`) BUCKETS ").append(dorisConfig.getBucket(tapTable.getId())).append(" PROPERTIES("); //generate properties - stringBuilder.append(dorisConfig.getTableProperties().stream().map(v -> "\"" + v.get("propKey") + "\"=\"" + v.get("propValue") + "\"").collect(Collectors.joining(", "))); + stringBuilder.append(dorisConfig.getTableProperties(tapTable.getId()).stream().map(v -> "\"" + v.get("propKey") + "\"=\"" + v.get("propValue") + "\"").collect(Collectors.joining(", "))); stringBuilder.append(")"); createTableOptions.setTableExists(false); try { diff --git a/connectors/doris-connector/src/main/java/io/tapdata/connector/doris/bean/DorisConfig.java b/connectors/doris-connector/src/main/java/io/tapdata/connector/doris/bean/DorisConfig.java index 72d41748b..16e05e976 100644 --- a/connectors/doris-connector/src/main/java/io/tapdata/connector/doris/bean/DorisConfig.java +++ b/connectors/doris-connector/src/main/java/io/tapdata/connector/doris/bean/DorisConfig.java @@ -24,7 +24,7 @@ public class DorisConfig extends CommonDbConfig { private List> tableProperties = new ArrayList<>(); private Boolean jdbcCompletion = false; - private Boolean useHTTPS =false; + private Boolean useHTTPS = false; private Integer backendNum; @@ -38,9 +38,9 @@ public DorisConfig() { public DorisConfig load(Map map) { DorisConfig config = (DorisConfig) super.load(map); config.setSchema(config.getDatabase()); - if(Boolean.TRUE.equals(useHTTPS)){ + if (Boolean.TRUE.equals(useHTTPS)) { config.setDorisHttp(getDorisHttp().replace("https://", "")); - }else{ + } else { config.setDorisHttp(getDorisHttp().replace("http://", "")); } return config; @@ -77,6 +77,10 @@ public List getDuplicateKey() { return duplicateKey; } + public List getDuplicateKey(String key) { + return getTableConfigValue(key, "duplicateKey", duplicateKey); + } + public void setDuplicateKey(List duplicateKey) { this.duplicateKey = duplicateKey; } @@ -85,6 +89,10 @@ public List getDistributedKey() { return distributedKey; } + public List getDistributedKey(String key) { + return getTableConfigValue(key, "distributedKey", distributedKey); + } + public void setDistributedKey(List distributedKey) { this.distributedKey = distributedKey; } @@ -109,6 +117,10 @@ public String getUniqueKeyType() { return uniqueKeyType; } + public String getUniqueKeyType(String key) { + return getTableConfigValue(key, "uniqueKeyType", uniqueKeyType); + } + public void setUniqueKeyType(String uniqueKeyType) { this.uniqueKeyType = uniqueKeyType; } @@ -117,6 +129,10 @@ public int getBucket() { return bucket; } + public int getBucket(String key) { + return getTableConfigValue(key, "bucket", bucket); + } + public Integer getBackendNum() { return backendNum; } @@ -141,6 +157,10 @@ public List> getTableProperties() { return tableProperties; } + public List> getTableProperties(String key) { + return getTableConfigValue(key, "tableProperties", tableProperties); + } + public void setTableProperties(List> tableProperties) { this.tableProperties = tableProperties; } diff --git a/connectors/doris-connector/src/main/java/io/tapdata/connector/doris/streamload/CsvSerializer.java b/connectors/doris-connector/src/main/java/io/tapdata/connector/doris/streamload/CsvSerializer.java index 8b2b228e4..52c4bbf72 100644 --- a/connectors/doris-connector/src/main/java/io/tapdata/connector/doris/streamload/CsvSerializer.java +++ b/connectors/doris-connector/src/main/java/io/tapdata/connector/doris/streamload/CsvSerializer.java @@ -6,6 +6,7 @@ import io.tapdata.entity.event.dml.TapUpdateRecordEvent; import io.tapdata.entity.schema.TapField; import io.tapdata.entity.schema.TapTable; +import io.tapdata.entity.schema.type.TapDateTime; import org.apache.commons.collections4.MapUtils; import java.io.IOException; @@ -56,6 +57,16 @@ private String buildCSVString(TapTable table, Map values, boolea value = values.get(entry.getKey()); if (value == null) { value = Constants.NULL_VALUE; + } else { + // Check if field is TapDateTime type and value is ISO 8601 format string + TapField tapField = entry.getValue(); + if (tapField != null && tapField.getTapType() instanceof TapDateTime && value instanceof String) { + String dateTimeStr = (String) value; + if (dateTimeStr.contains("T") && dateTimeStr.endsWith("Z")) { + dateTimeStr = dateTimeStr.replace("T", " ").substring(0, 19); + value = dateTimeStr; + } + } } joiner.add(value.toString()); } @@ -70,4 +81,4 @@ private String buildCSVString(TapTable table, Map values, boolea public byte[] lineEnd() { return Constants.LINE_DELIMITER_DEFAULT.getBytes(StandardCharsets.UTF_8); } -} +} \ No newline at end of file diff --git a/connectors/doris-connector/src/main/java/io/tapdata/connector/doris/streamload/DorisStreamLoader.java b/connectors/doris-connector/src/main/java/io/tapdata/connector/doris/streamload/DorisStreamLoader.java index 2e25d4e30..7ec97c4a3 100644 --- a/connectors/doris-connector/src/main/java/io/tapdata/connector/doris/streamload/DorisStreamLoader.java +++ b/connectors/doris-connector/src/main/java/io/tapdata/connector/doris/streamload/DorisStreamLoader.java @@ -88,7 +88,7 @@ public void writeRecord(final List tapRecordEvents, final TapTab TapLogger.debug(TAG, "Batch events length is: {}", tapRecordEvents.size()); WriteListResult listResult = writeListResult(); this.tapTable = table; - boolean isAgg = DorisTableType.Aggregate.toString().equals(dorisConfig.getUniqueKeyType()); + boolean isAgg = DorisTableType.Aggregate.toString().equals(dorisConfig.getUniqueKeyType(table.getId())); for (TapRecordEvent tapRecordEvent : tapRecordEvents) { byte[] bytes = messageSerializer.serialize(table, tapRecordEvent, isAgg); if (needFlush(tapRecordEvent, bytes.length, isAgg)) { @@ -145,7 +145,7 @@ public RespContent put(final TapTable table) throws StreamLoadException, DorisRe String label = prefix + "-" + UUID.randomUUID(); List columns = new ArrayList<>(); for (String col : tapTable.getNameFieldMap().keySet()) { - if (dataColumns.get().contains(col) || DorisTableType.Aggregate.toString().equals(dorisConfig.getUniqueKeyType())) { + if (dataColumns.get().contains(col) || DorisTableType.Aggregate.toString().equals(dorisConfig.getUniqueKeyType(table.getId()))) { columns.add("`" + col + "`"); } } @@ -167,7 +167,7 @@ public RespContent put(final TapTable table) throws StreamLoadException, DorisRe if (CollectionUtils.isEmpty(primaryKeys)) { putBuilder.enableAppend(); } else { - if (DorisTableType.Unique.toString().equals(dorisConfig.getUniqueKeyType())) { + if (DorisTableType.Unique.toString().equals(dorisConfig.getUniqueKeyType(table.getId()))) { putBuilder.enableDelete(); putBuilder.addPartialHeader(); } else { diff --git a/connectors/doris-connector/src/main/java/io/tapdata/connector/doris/streamload/JsonSerializer.java b/connectors/doris-connector/src/main/java/io/tapdata/connector/doris/streamload/JsonSerializer.java index 99c1aaa9e..e025e71a1 100644 --- a/connectors/doris-connector/src/main/java/io/tapdata/connector/doris/streamload/JsonSerializer.java +++ b/connectors/doris-connector/src/main/java/io/tapdata/connector/doris/streamload/JsonSerializer.java @@ -6,7 +6,9 @@ import io.tapdata.entity.event.dml.TapInsertRecordEvent; import io.tapdata.entity.event.dml.TapRecordEvent; import io.tapdata.entity.event.dml.TapUpdateRecordEvent; +import io.tapdata.entity.schema.TapField; import io.tapdata.entity.schema.TapTable; +import io.tapdata.entity.schema.type.TapDateTime; import java.nio.charset.StandardCharsets; import java.util.LinkedHashMap; @@ -73,6 +75,15 @@ private String toJsonString(TapTable tapTable, Map record, boole if (null == value) { linkedRecord.put(field, null); } else { + // Check if field is TapDateTime type and value is ISO 8601 format string + TapField tapField = tapTable.getNameFieldMap().get(field); + if (tapField != null && tapField.getTapType() instanceof TapDateTime && value instanceof String) { + String dateTimeStr = (String) value; + if (dateTimeStr.contains("T") && dateTimeStr.endsWith("Z")) { + dateTimeStr = dateTimeStr.replace("T", " ").substring(0, 19); + value = dateTimeStr; + } + } linkedRecord.put(field, value.toString()); } } @@ -80,4 +91,4 @@ private String toJsonString(TapTable tapTable, Map record, boole linkedRecord.put(Constants.DORIS_DELETE_SIGN, delete ? 1 : 0); return objectMapper.writeValueAsString(linkedRecord); } -} +} \ No newline at end of file diff --git a/connectors/doris-connector/src/main/resources/spec_doris.json b/connectors/doris-connector/src/main/resources/spec_doris.json index a3a9f6ba7..2540935df 100644 --- a/connectors/doris-connector/src/main/resources/spec_doris.json +++ b/connectors/doris-connector/src/main/resources/spec_doris.json @@ -1,6 +1,7 @@ { "properties": { "name": "Doris", + "realName": "Apache Doris", "icon": "icons/doris.png", "id": "doris", "docs": "${docs}", @@ -261,6 +262,15 @@ }, "node": { "type": "object", + "x-tableConfigScope": ["target"], + "x-reactions": { + "target": "nodeConfig.*(duplicateKey,distributedKey)", + "fulfill": { + "state": { + "display": "{{$settings.syncType===\"migrate\"?\"hidden\":\"visible\"}}" + } + } + }, "properties": { "uniqueKeyType": { "type": "string", @@ -272,6 +282,7 @@ }, "x-component": "Select", "x-index": 1, + "x-perTable": true, "enum": [ { "label": "Aggregate", @@ -301,6 +312,7 @@ "title": "${duplicateKey}", "type": "array", "x-index": 2, + "x-perTable": true, "required": true, "default": null, "x-decorator": "FormItem", @@ -314,12 +326,12 @@ "filterable": true }, "x-reactions": [ - "{{useAsyncDataSourceByConfig({service: loadNodeFieldOptions, withoutField: true}, $values.$inputs[0])}}", + "{{useAsyncDataSourceByConfig({service: loadNodeTableFields, withoutField: true}, $values.id, $settings.syncType===\"migrate\"?$self.path.pop().segments.slice(-1)[0]:$values.tableName)}}", { - "dependencies":["writeStrategy", ".uniqueKeyType", "type"], + "dependencies":["writeStrategy", ".uniqueKeyType"], "fulfill":{ "state":{ - "display": "{{$deps[0] === \"appendWrite\" && $deps[1] === \"Duplicate\" && $deps[2] !== 'database' ? \"visible\":\"hidden\"}}" + "display": "{{$deps[0] === \"appendWrite\" && $deps[1] === \"Duplicate\" ? \"visible\":\"hidden\"}}" } } } @@ -329,6 +341,7 @@ "title": "${distributedKey}", "type": "array", "x-index": 3, + "x-perTable": true, "x-decorator": "FormItem", "x-decorator-props": { "tooltip": "${distributedKeyTip}" @@ -340,12 +353,12 @@ "filterable": true }, "x-reactions": [ - "{{useAsyncDataSourceByConfig({service: loadNodeFieldOptions, withoutField: true}, $values.$inputs[0])}}", + "{{useAsyncDataSourceByConfig({service: loadNodeTableFields, withoutField: true}, $values.id, $settings.syncType===\"migrate\"?$self.path.pop().segments.slice(-1)[0]:$values.tableName)}}", { - "dependencies":["$inputs", "type"], + "dependencies":["$inputs"], "fulfill":{ "state":{ - "display": "{{$deps[0].length > 0 && $deps[1] !== 'database' ? \"visible\":\"hidden\"}}" + "display": "{{$deps[0].length > 0 ? \"visible\":\"hidden\"}}" } } } @@ -358,6 +371,7 @@ "x-decorator": "FormItem", "x-component": "InputNumber", "x-index": 4, + "x-perTable": true, "x-decorator-props": { "min": 1, "max": 100 @@ -404,6 +418,7 @@ } ], "x-index": 5, + "x-perTable": true, "items": { "type": "object", "properties": { @@ -501,6 +516,7 @@ "title": "${applyDefault}", "default": false, "x-index": 8, + "x-perTable": true, "x-decorator": "FormItem", "x-component": "Switch", "x-decorator-props": { diff --git a/connectors/dummy-connector/pom.xml b/connectors/dummy-connector/pom.xml index 5bb75e7ff..76eee9718 100644 --- a/connectors/dummy-connector/pom.xml +++ b/connectors/dummy-connector/pom.xml @@ -15,7 +15,7 @@ 8 - 2.0.0-SNAPSHOT + 2.0.5-SNAPSHOT diff --git a/connectors/dummy-connector/src/main/resources/spec.json b/connectors/dummy-connector/src/main/resources/spec.json index 87ee7c139..dc6f88f19 100644 --- a/connectors/dummy-connector/src/main/resources/spec.json +++ b/connectors/dummy-connector/src/main/resources/spec.json @@ -1,6 +1,7 @@ { "properties": { "name": "Dummy", + "realName": "Dummy", "icon": "icons/dummy.png", "doc" : "${doc}", "id": "dummy", diff --git a/connectors/dws-connector/src/main/resources/spec_dws.json b/connectors/dws-connector/src/main/resources/spec_dws.json index b13a456eb..b25f9bbb7 100644 --- a/connectors/dws-connector/src/main/resources/spec_dws.json +++ b/connectors/dws-connector/src/main/resources/spec_dws.json @@ -1,6 +1,7 @@ { "properties": { "name": "GaussDB(DWS)", + "realName": "GaussDB(DWS)", "icon": "icons/dws.png", "doc" : "${doc}", "id": "dws", diff --git a/connectors/elasticsearch-connector/pom.xml b/connectors/elasticsearch-connector/pom.xml index 851e28db7..0aad41f3b 100644 --- a/connectors/elasticsearch-connector/pom.xml +++ b/connectors/elasticsearch-connector/pom.xml @@ -12,7 +12,7 @@ elasticsearch-connector - 2.0.0-SNAPSHOT + 2.0.5-SNAPSHOT diff --git a/connectors/elasticsearch-connector/src/main/resources/spec_elasticsearch.json b/connectors/elasticsearch-connector/src/main/resources/spec_elasticsearch.json index 1a3f2504d..f32ee066b 100644 --- a/connectors/elasticsearch-connector/src/main/resources/spec_elasticsearch.json +++ b/connectors/elasticsearch-connector/src/main/resources/spec_elasticsearch.json @@ -1,6 +1,7 @@ { "properties": { "name": "Elasticsearch", + "realName": "Elasticsearch", "icon": "icons/elasticsearch.png", "doc" : "${doc}", "id": "elasticsearch", diff --git a/connectors/excel-connector/pom.xml b/connectors/excel-connector/pom.xml index 3663faa97..e33160a78 100644 --- a/connectors/excel-connector/pom.xml +++ b/connectors/excel-connector/pom.xml @@ -15,7 +15,7 @@ jar - 2.0.1-SNAPSHOT + 2.0.5-SNAPSHOT 8 diff --git a/connectors/excel-connector/src/main/resources/spec_excel.json b/connectors/excel-connector/src/main/resources/spec_excel.json index b30a7effc..228029e5d 100644 --- a/connectors/excel-connector/src/main/resources/spec_excel.json +++ b/connectors/excel-connector/src/main/resources/spec_excel.json @@ -1,6 +1,7 @@ { "properties": { "name": "EXCEL", + "realName": "Excel", "icon": "icons/excel.png", "doc": "${doc}", "id": "excel", diff --git a/connectors/file-stream-connector/pom.xml b/connectors/file-stream-connector/pom.xml index d1fe101c4..b2b99aed5 100644 --- a/connectors/file-stream-connector/pom.xml +++ b/connectors/file-stream-connector/pom.xml @@ -15,7 +15,7 @@ jar - 2.0.1-SNAPSHOT + 2.0.5-SNAPSHOT 8 diff --git a/connectors/file-stream-connector/src/main/resources/spec_filestream.json b/connectors/file-stream-connector/src/main/resources/spec_filestream.json index c8f6a2cf3..bb57de4ba 100644 --- a/connectors/file-stream-connector/src/main/resources/spec_filestream.json +++ b/connectors/file-stream-connector/src/main/resources/spec_filestream.json @@ -1,6 +1,7 @@ { "properties": { "name": "File Stream", + "realName": "File Stream", "icon": "icons/filestream.png", "doc": "${doc}", "id": "file-stream", diff --git a/connectors/greenplum-connector/src/main/resources/spec_greenplum.json b/connectors/greenplum-connector/src/main/resources/spec_greenplum.json index ad195e3e0..f2732aa79 100644 --- a/connectors/greenplum-connector/src/main/resources/spec_greenplum.json +++ b/connectors/greenplum-connector/src/main/resources/spec_greenplum.json @@ -1,6 +1,7 @@ { "properties": { "name": "GreenPlum", + "realName": "Greenplum", "icon": "icons/greenplum.png", "doc" : "${doc}", "id": "greenplum", diff --git a/connectors/hazelcast-connector/src/main/resources/spec_hazelcast.json b/connectors/hazelcast-connector/src/main/resources/spec_hazelcast.json index 9271eeb2e..6d534223c 100644 --- a/connectors/hazelcast-connector/src/main/resources/spec_hazelcast.json +++ b/connectors/hazelcast-connector/src/main/resources/spec_hazelcast.json @@ -1,6 +1,7 @@ { "properties": { "name": "Hazelcast", + "realName": "Hazelcast", "icon": "icons/hazelcast.png", "doc": "${doc}", "id": "hazelcast" diff --git a/connectors/highgo-connector/src/main/resources/spec_highgo.json b/connectors/highgo-connector/src/main/resources/spec_highgo.json index 220e21c8e..b7289c47e 100644 --- a/connectors/highgo-connector/src/main/resources/spec_highgo.json +++ b/connectors/highgo-connector/src/main/resources/spec_highgo.json @@ -1,6 +1,7 @@ { "properties": { "name": "Highgo", + "realName": "HighGo", "icon": "icons/highgo.svg", "doc" : "${doc}", "id": "highgo", diff --git a/connectors/http-receiver-connector/pom.xml b/connectors/http-receiver-connector/pom.xml index abe2bf506..decb2a0fb 100644 --- a/connectors/http-receiver-connector/pom.xml +++ b/connectors/http-receiver-connector/pom.xml @@ -16,7 +16,7 @@ 8 - 2.0.0-SNAPSHOT + 2.0.5-SNAPSHOT diff --git a/connectors/http-receiver-connector/src/main/resources/spec.json b/connectors/http-receiver-connector/src/main/resources/spec.json index 018f733a1..2791abce9 100644 --- a/connectors/http-receiver-connector/src/main/resources/spec.json +++ b/connectors/http-receiver-connector/src/main/resources/spec.json @@ -1,6 +1,7 @@ { "properties": { "name": "Http Receiver", + "realName": "HTTP Receiver", "icon": "icons/http.png", "doc": "${doc}", "id": "http-receiver", diff --git a/connectors/huawei-cloud-gaussdb-connector/pom.xml b/connectors/huawei-cloud-gaussdb-connector/pom.xml index 9cbb3ed7b..7e000cfa3 100644 --- a/connectors/huawei-cloud-gaussdb-connector/pom.xml +++ b/connectors/huawei-cloud-gaussdb-connector/pom.xml @@ -15,7 +15,7 @@ jar - 2.0.0-SNAPSHOT + 2.0.5-SNAPSHOT 8 42.3.4 diff --git a/connectors/huawei-cloud-gaussdb-connector/src/main/resources/spec_gauss_db.json b/connectors/huawei-cloud-gaussdb-connector/src/main/resources/spec_gauss_db.json index 481420852..5cd412579 100644 --- a/connectors/huawei-cloud-gaussdb-connector/src/main/resources/spec_gauss_db.json +++ b/connectors/huawei-cloud-gaussdb-connector/src/main/resources/spec_gauss_db.json @@ -1,6 +1,7 @@ { "properties": { "name": "HuaWei'Cloud GaussDB", + "realName": "Huawei Cloud GaussDB", "icon": "icons/gauss.png", "doc": "${doc}", "id": "huawei-gauss-db", diff --git a/connectors/hudi-connector/pom.xml b/connectors/hudi-connector/pom.xml index 1fd885635..799ac0725 100644 --- a/connectors/hudi-connector/pom.xml +++ b/connectors/hudi-connector/pom.xml @@ -12,7 +12,7 @@ hudi-connector - 2.0.0-SNAPSHOT + 2.0.5-SNAPSHOT 1.0-SNAPSHOT 3.1.1-hw-ei-312005 8 diff --git a/connectors/hudi-connector/src/main/resources/spec_hudi.json b/connectors/hudi-connector/src/main/resources/spec_hudi.json index 620415832..d44f3c5e9 100644 --- a/connectors/hudi-connector/src/main/resources/spec_hudi.json +++ b/connectors/hudi-connector/src/main/resources/spec_hudi.json @@ -1,6 +1,7 @@ { "properties": { "name": "hudi", + "realName": "Apache Hudi", "icon": "icons/hudi.png", "id": "hudi", "doc": "${doc}", diff --git a/connectors/json-connector/pom.xml b/connectors/json-connector/pom.xml index a9321b10a..17a51a0e1 100644 --- a/connectors/json-connector/pom.xml +++ b/connectors/json-connector/pom.xml @@ -15,7 +15,7 @@ jar - 2.0.1-SNAPSHOT + 2.0.5-SNAPSHOT 8 diff --git a/connectors/json-connector/src/main/resources/spec_json.json b/connectors/json-connector/src/main/resources/spec_json.json index 1be69ea7f..991602dd5 100644 --- a/connectors/json-connector/src/main/resources/spec_json.json +++ b/connectors/json-connector/src/main/resources/spec_json.json @@ -1,6 +1,7 @@ { "properties": { "name": "JSON", + "realName": "JSON", "icon": "icons/json.png", "doc": "${doc}", "id": "json", diff --git a/connectors/kafka-connector/pom.xml b/connectors/kafka-connector/pom.xml index 9073eaaed..738b0b445 100644 --- a/connectors/kafka-connector/pom.xml +++ b/connectors/kafka-connector/pom.xml @@ -12,7 +12,7 @@ kafka-connector - 2.0.0-SNAPSHOT + 2.0.5-SNAPSHOT diff --git a/connectors/kafka-connector/src/main/resources/spec_kafka.json b/connectors/kafka-connector/src/main/resources/spec_kafka.json index a57dc1f37..5ca40f6cf 100644 --- a/connectors/kafka-connector/src/main/resources/spec_kafka.json +++ b/connectors/kafka-connector/src/main/resources/spec_kafka.json @@ -1,6 +1,7 @@ { "properties": { "name": "Kafka", + "realName": "Apache Kafka(Deprecated)", "icon": "icons/kafka.png", "doc": "${doc}", "id": "kafka", diff --git a/connectors/kafka-enhanced-connector/pom.xml b/connectors/kafka-enhanced-connector/pom.xml index f967847c6..375df9a8c 100644 --- a/connectors/kafka-enhanced-connector/pom.xml +++ b/connectors/kafka-enhanced-connector/pom.xml @@ -15,7 +15,7 @@ 17 - 2.0.0-SNAPSHOT + 2.0.5-SNAPSHOT 2.8.2 1.7.25 3.2.0 diff --git a/connectors/kafka-enhanced-connector/src/main/java/io/tapdata/connector/config/ConnectionClusterURI.java b/connectors/kafka-enhanced-connector/src/main/java/io/tapdata/connector/config/ConnectionClusterURI.java index 5a9894ef0..b2c89960c 100644 --- a/connectors/kafka-enhanced-connector/src/main/java/io/tapdata/connector/config/ConnectionClusterURI.java +++ b/connectors/kafka-enhanced-connector/src/main/java/io/tapdata/connector/config/ConnectionClusterURI.java @@ -1,12 +1,6 @@ package io.tapdata.connector.config; import io.tapdata.connector.IConfigWithContext; -import io.tapdata.connector.tester.IStep; -import io.tapdata.connector.tester.items.ClusterURITesterItem; -import io.tapdata.pdk.apis.entity.ConnectionOptions; -import io.tapdata.pdk.apis.entity.TestItem; - -import java.util.function.Consumer; /** * 测试项-集群地址 @@ -21,15 +15,4 @@ default String getConnectionClusterURI() { return connectionConfigGet(KEY_CLUSTER_URI, null); } - default boolean testClusterURI(TestItem item, Consumer consumer, ConnectionOptions options) { - String clusterURI = getConnectionClusterURI(); - if (null == clusterURI) { - item.setResult(TestItem.RESULT_FAILED); - item.setInformation(String.format("not configured '%s' yet", KEY_CLUSTER_URI)); - } else { - options.connectionString(clusterURI); - ClusterURITesterItem.test(clusterURI, item, consumer); - } - return IStep.CHECK_ITEM_APPLY; - } } diff --git a/connectors/kafka-enhanced-connector/src/main/java/io/tapdata/connector/config/ConnectionDatasourceInstanceInfo.java b/connectors/kafka-enhanced-connector/src/main/java/io/tapdata/connector/config/ConnectionDatasourceInstanceInfo.java deleted file mode 100644 index 7279d4547..000000000 --- a/connectors/kafka-enhanced-connector/src/main/java/io/tapdata/connector/config/ConnectionDatasourceInstanceInfo.java +++ /dev/null @@ -1,56 +0,0 @@ -package io.tapdata.connector.config; - -import io.tapdata.connector.IConfigWithContext; -import io.tapdata.connector.tester.IStep; -import io.tapdata.kit.StringKit; -import io.tapdata.pdk.apis.entity.ConnectionOptions; -import io.tapdata.pdk.apis.entity.TestItem; - -import java.util.HashMap; -import java.util.Map; -import java.util.function.Consumer; - -/** - * 检测项-数据源实例信息 - * - * @author Harsen - * @version v1.0 2024/9/25 15:05 Create - */ -public interface ConnectionDatasourceInstanceInfo extends IConfigWithContext { - - default String getDatasourceInstanceTag() { - if (this instanceof ConnectionClusterURI) { - return ((ConnectionClusterURI) this).getConnectionClusterURI(); - } else if (this instanceof ConnectionHostPort) { - return String.format("%s:%d", ((ConnectionHostPort) this).getConnectionHost(), ((ConnectionHostPort) this).getConnectionPort()); - } - return null; - } - - default String toDatasourceInstanceId(String tag) { - return StringKit.md5(tag); - } - - default String getDatasourceInstanceId() { - String tag = getDatasourceInstanceTag(); - if (null != tag) { - return toDatasourceInstanceId(tag); - } - return null; - } - - default boolean testDatasourceInstanceInfo(TestItem item, Consumer consumer, ConnectionOptions options) { - String tag = getDatasourceInstanceTag(); - if (null != tag) { - Map datasourceInstanceInfo = new HashMap<>(); - datasourceInstanceInfo.put("tag", tag); - datasourceInstanceInfo.put("id", toDatasourceInstanceId(tag)); - options.setDatasourceInstanceInfo(datasourceInstanceInfo); - - item.setResult(TestItem.RESULT_SUCCESSFULLY); - item.setInformation(tag); - } - return IStep.CHECK_ITEM_APPLY; - } -} - diff --git a/connectors/kafka-enhanced-connector/src/main/java/io/tapdata/connector/config/ConnectionHostPort.java b/connectors/kafka-enhanced-connector/src/main/java/io/tapdata/connector/config/ConnectionHostPort.java deleted file mode 100644 index 89209f6d0..000000000 --- a/connectors/kafka-enhanced-connector/src/main/java/io/tapdata/connector/config/ConnectionHostPort.java +++ /dev/null @@ -1,52 +0,0 @@ -package io.tapdata.connector.config; - -import io.tapdata.connector.IConfigWithContext; -import io.tapdata.connector.tester.IStep; -import io.tapdata.connector.tester.items.HostPortTesterItem; -import io.tapdata.pdk.apis.entity.ConnectionOptions; -import io.tapdata.pdk.apis.entity.TestItem; -import io.tapdata.pdk.apis.exception.testItem.TapTestHostPortEx; -import org.apache.commons.lang3.StringUtils; - -import java.util.function.Consumer; - -/** - * 检测项-主机端口 - * - * @author Harsen - * @version v1.0 2024/8/28 11:53 Create - */ -public interface ConnectionHostPort extends IConfigWithContext { - String KEY_HOST = "host"; - String KEY_PORT = "port"; - - default String getConnectionHost() { - return connectionConfigGet(KEY_HOST, null); - } - - default Integer getConnectionPort() { - return connectionConfigGet(KEY_PORT, null); - } - - default boolean testHostPort(TestItem item, Consumer consumer, ConnectionOptions options) { - String host = getConnectionHost(); - Integer port = getConnectionPort(); - if (StringUtils.isBlank(host)) { - item.setInformation("not configured 'host' yet"); - } else if (null == port || port <= 0) { - item.setInformation("not configured 'port' yet"); - } else { - try { - String address = HostPortTesterItem.test(host, port); - item.setResult(TestItem.RESULT_SUCCESSFULLY); - item.setInformation(address); - } catch (TapTestHostPortEx e) { - item.setResult(TestItem.RESULT_FAILED); - item.setTapTestItemException(e); - } - } - - return IStep.CHECK_ITEM_APPLY; - } -} - diff --git a/connectors/kafka-enhanced-connector/src/main/java/io/tapdata/connector/error/KafkaErrorCodes.java b/connectors/kafka-enhanced-connector/src/main/java/io/tapdata/connector/error/KafkaErrorCodes.java index 4d3cb033b..74b7abcc6 100644 --- a/connectors/kafka-enhanced-connector/src/main/java/io/tapdata/connector/error/KafkaErrorCodes.java +++ b/connectors/kafka-enhanced-connector/src/main/java/io/tapdata/connector/error/KafkaErrorCodes.java @@ -38,4 +38,15 @@ public interface KafkaErrorCodes { dynamicDescriptionCN = "事件类型: {}" ) String DEBEZIUM_NOT_SUPPORT_EVENT = "40002"; + + @TapExCode( + describe = "Kafka Connection Common Error", + describeCN = "Kafka连接通用错误", + solution = "Please carefully read the error exception stack information and investigate whether there are any abnormalities in the Kafka connection information", + solutionCN = "请仔细阅读错误异常栈信息,排查kafka的连接信息是否有异常", + dynamicDescription = "Error: {}", + dynamicDescriptionCN = "错误信息: {}" + ) + String KAFKA_COMMON_ERROR = "40003"; + } diff --git a/connectors/kafka-enhanced-connector/src/main/java/io/tapdata/connector/tester/AbsStep.java b/connectors/kafka-enhanced-connector/src/main/java/io/tapdata/connector/tester/AbsStep.java deleted file mode 100644 index 770a6a882..000000000 --- a/connectors/kafka-enhanced-connector/src/main/java/io/tapdata/connector/tester/AbsStep.java +++ /dev/null @@ -1,36 +0,0 @@ -package io.tapdata.connector.tester; - -import io.tapdata.connector.IConfigWithContext; -import io.tapdata.pdk.apis.entity.ConnectionOptions; -import io.tapdata.pdk.apis.entity.TestItem; - -import java.util.function.Consumer; - -/** - * 测试步骤 - * - * @author Harsen - * @version v1.0 2024/9/5 15:58 Create - */ -public abstract class AbsStep> implements IStep { - protected final T tester; - - protected AbsStep(T tester) { - this.tester = tester; - } - - @Override - public C config() { - return tester.getConfig(); - } - - @Override - public Consumer itemConsumer() { - return tester.getConsumer(); - } - - @Override - public ConnectionOptions options() { - return tester.getOptions(); - } -} diff --git a/connectors/kafka-enhanced-connector/src/main/java/io/tapdata/connector/tester/ICommonStep.java b/connectors/kafka-enhanced-connector/src/main/java/io/tapdata/connector/tester/ICommonStep.java deleted file mode 100644 index 7d0a57430..000000000 --- a/connectors/kafka-enhanced-connector/src/main/java/io/tapdata/connector/tester/ICommonStep.java +++ /dev/null @@ -1,59 +0,0 @@ -package io.tapdata.connector.tester; - -import io.tapdata.connector.IConfigWithContext; -import io.tapdata.connector.config.ConnectionClusterURI; -import io.tapdata.connector.config.ConnectionDatasourceInstanceInfo; -import io.tapdata.connector.config.ConnectionHostPort; -import io.tapdata.pdk.apis.entity.TestItem; - -/** - * 基础测试项 - * - * @author Harsen - * @version v1.0 2024/8/28 19:00 Create - */ -public interface ICommonStep extends IStep { - - default boolean testVersion(TestItem testItem) { - return CHECK_ITEM_APPLY; - } - - default boolean testConnection(TestItem testItem) { - C config = config(); - if (config instanceof ConnectionClusterURI) { - return ((ConnectionClusterURI) config).testClusterURI(testItem, itemConsumer(), options()); - } else if (config instanceof ConnectionHostPort) { - return ((ConnectionHostPort) config).testHostPort(testItem, itemConsumer(), options()); - } - return CHECK_ITEM_APPLY; - } - - default boolean testRegistryConnection(TestItem testItem) { - return CHECK_ITEM_APPLY; - } - - default boolean testInstanceUniqueId(TestItem testItem) { - return CHECK_ITEM_APPLY; - } - - default boolean testDatasourceInstanceInfo(TestItem testItem) { - C config = config(); - if (config instanceof ConnectionDatasourceInstanceInfo) { - return ((ConnectionDatasourceInstanceInfo) config).testDatasourceInstanceInfo(testItem, itemConsumer(), options()); - } - return CHECK_ITEM_APPLY; - } - - default boolean testLogin(TestItem testItem) { - return CHECK_ITEM_APPLY; - } - - default boolean test() { - return checkItem(TestItem.ITEM_VERSION, this::testVersion) - && checkItem(TestItem.ITEM_CONNECTION, this::testConnection) - && checkItem(TestItem.ITEM_CONNECTION, this::testRegistryConnection) - && checkItem(TestItem.ITEM_INSTANCE_UNIQUE_ID, this::testInstanceUniqueId) - && checkItem(TestItem.ITEM_DATASOURCE_INSTANCE_INFO, this::testDatasourceInstanceInfo) - && checkItem(TestItem.ITEM_LOGIN, this::testLogin); - } -} diff --git a/connectors/kafka-enhanced-connector/src/main/java/io/tapdata/connector/tester/ISourceStep.java b/connectors/kafka-enhanced-connector/src/main/java/io/tapdata/connector/tester/ISourceStep.java deleted file mode 100644 index f586d059e..000000000 --- a/connectors/kafka-enhanced-connector/src/main/java/io/tapdata/connector/tester/ISourceStep.java +++ /dev/null @@ -1,27 +0,0 @@ -package io.tapdata.connector.tester; - -import io.tapdata.connector.IConfigWithContext; -import io.tapdata.pdk.apis.entity.TestItem; - -/** - * 源测试项 - * - * @author Harsen - * @version v1.0 2024/8/28 18:59 Create - */ -public interface ISourceStep extends IStep { - - default boolean testRead(TestItem item) { - return CHECK_ITEM_APPLY; - } - - default boolean testReadLog(TestItem item) { - return CHECK_ITEM_APPLY; - } - - @Override - default boolean test() { - return checkItem(TestItem.ITEM_READ, this::testRead) - && checkItem(TestItem.ITEM_READ_LOG, this::testReadLog); - } -} diff --git a/connectors/kafka-enhanced-connector/src/main/java/io/tapdata/connector/tester/IStep.java b/connectors/kafka-enhanced-connector/src/main/java/io/tapdata/connector/tester/IStep.java deleted file mode 100644 index 242e08265..000000000 --- a/connectors/kafka-enhanced-connector/src/main/java/io/tapdata/connector/tester/IStep.java +++ /dev/null @@ -1,49 +0,0 @@ -package io.tapdata.connector.tester; - -import io.tapdata.connector.IConfigWithContext; -import io.tapdata.pdk.apis.entity.ConnectionOptions; -import io.tapdata.pdk.apis.entity.TestItem; - -import java.util.function.Consumer; -import java.util.function.Predicate; - -/** - * 测试步骤接口定义 - * - * @author Harsen - * @version v1.0 2024/9/5 18:52 Create - */ -public interface IStep extends AutoCloseable { - - boolean CHECK_ITEM_APPLY = true; - boolean CHECK_ITEM_SKIPPED = false; - - C config(); - - Consumer itemConsumer(); - - ConnectionOptions options(); - - /** - * 执行测试项 - * - * @param item 测试项名称 - * @param predicate 测试逻辑 - * @return 是否应用测试结果 - */ - default boolean checkItem(String item, Predicate predicate) { - TestItem testItem = new TestItem(item, TestItem.RESULT_SUCCESSFULLY_WITH_WARN, "unsupported"); - if (predicate.test(testItem)) { - itemConsumer().accept(testItem); - return TestItem.RESULT_FAILED != testItem.getResult(); - } - return CHECK_ITEM_APPLY; - } - - /** - * 执行测试步骤 - * - * @return 是否执行下一个步骤 - */ - boolean test(); -} diff --git a/connectors/kafka-enhanced-connector/src/main/java/io/tapdata/connector/tester/ITargetStep.java b/connectors/kafka-enhanced-connector/src/main/java/io/tapdata/connector/tester/ITargetStep.java deleted file mode 100644 index 13665093b..000000000 --- a/connectors/kafka-enhanced-connector/src/main/java/io/tapdata/connector/tester/ITargetStep.java +++ /dev/null @@ -1,22 +0,0 @@ -package io.tapdata.connector.tester; - -import io.tapdata.connector.IConfigWithContext; -import io.tapdata.pdk.apis.entity.TestItem; - -/** - * 目标测试项 - * - * @author Harsen - * @version v1.0 2024/8/28 18:59 Create - */ -public interface ITargetStep extends IStep { - - default boolean testWrite(TestItem item) { - return CHECK_ITEM_APPLY; - } - - @Override - default boolean test() { - return checkItem(TestItem.ITEM_WRITE, this::testWrite); - } -} diff --git a/connectors/kafka-enhanced-connector/src/main/java/io/tapdata/connector/tester/Tester.java b/connectors/kafka-enhanced-connector/src/main/java/io/tapdata/connector/tester/Tester.java deleted file mode 100644 index faffc38e7..000000000 --- a/connectors/kafka-enhanced-connector/src/main/java/io/tapdata/connector/tester/Tester.java +++ /dev/null @@ -1,84 +0,0 @@ -package io.tapdata.connector.tester; - -import io.tapdata.connector.IConfigWithContext; -import io.tapdata.constant.ConnectionTypeEnum; -import io.tapdata.pdk.apis.context.TapConnectionContext; -import io.tapdata.pdk.apis.entity.ConnectionOptions; -import io.tapdata.pdk.apis.entity.TestItem; - -import java.util.Optional; -import java.util.function.Consumer; - -/** - * 连接器测试流程 - * - * @author Harsen - * @version v1.0 2024/8/28 16:05 Create - */ -public abstract class Tester { - - protected C config; - protected Consumer consumer; - protected ConnectionOptions options; - protected ConnectionTypeEnum connectionType; - - protected Tester(C config, Consumer consumer) { - this.consumer = consumer; - this.config = config; - init(); - } - - protected void init() { - options = new ConnectionOptions(); - connectionType = ConnectionTypeEnum.fromValue(Optional.ofNullable(config) - .map(IConfigWithContext::tapConnectionContext) - .map(TapConnectionContext::getConnectionConfig) - .map(m -> (String) m.get("__connectionType")) - .orElse(null) - ); - } - - protected abstract ICommonStep openCommon(); - - protected abstract ISourceStep openSource(); - - protected abstract ITargetStep openTarget(); - - public Consumer getConsumer() { - return consumer; - } - - public C getConfig() { - return config; - } - - public ConnectionOptions getOptions() { - return options; - } - - public ConnectionTypeEnum getConnectionType() { - return connectionType; - } - - public ConnectionOptions start() throws Exception { - try (ICommonStep common = this.openCommon()) { - if (!common.test()) { - return getOptions(); - } - } - - if (connectionType.hasSource()) { - try (ISourceStep source = this.openSource()) { - if (!source.test()) return getOptions(); - } - } - - if (connectionType.hasTarget()) { - try (ITargetStep target = this.openTarget()) { - if (!target.test()) return getOptions(); - } - } - - return getOptions(); - } -} diff --git a/connectors/kafka-enhanced-connector/src/main/java/io/tapdata/connector/tester/items/ClusterURITesterItem.java b/connectors/kafka-enhanced-connector/src/main/java/io/tapdata/connector/tester/items/ClusterURITesterItem.java deleted file mode 100644 index aa720949b..000000000 --- a/connectors/kafka-enhanced-connector/src/main/java/io/tapdata/connector/tester/items/ClusterURITesterItem.java +++ /dev/null @@ -1,60 +0,0 @@ -package io.tapdata.connector.tester.items; - -import io.tapdata.pdk.apis.entity.TestItem; -import io.tapdata.pdk.apis.exception.testItem.TapTestHostPortEx; - -import java.util.ArrayList; -import java.util.List; -import java.util.function.Consumer; - -/** - * 测试项-集群地址 - * - * @author Harsen - * @version v1.0 2024/8/29 10:39 Create - */ -public interface ClusterURITesterItem { - - static void test(String clusterURI, TestItem item, Consumer consumer) { - boolean hasFailed = false; - List results = new ArrayList<>(); - String errorItemName = "Invalid Host Port"; - for (String server : clusterURI.split(",")) { - String[] arr = server.split(":"); - switch (arr.length) { - case 1: - hasFailed = true; - consumer.accept(new TestItem(errorItemName, TestItem.RESULT_FAILED, String.format("not configured 'port' yet: %s", server))); - break; - case 2: - String host = arr[0].trim(); - String port = arr[1].trim(); - try { - results.add(HostPortTesterItem.test(host, Integer.parseInt(port))); - } catch (TapTestHostPortEx e) { - hasFailed = true; - consumer.accept(new TestItem(errorItemName, e, TestItem.RESULT_FAILED)); - } catch (NumberFormatException e) { - hasFailed = true; - consumer.accept(new TestItem(errorItemName, new TapTestHostPortEx(e, host, port), TestItem.RESULT_FAILED)); - } - break; - default: - hasFailed = true; - consumer.accept(new TestItem(errorItemName, TestItem.RESULT_FAILED, "illegal argument: " + server)); - break; - } - } - - if (results.isEmpty()) { - item.setResult(TestItem.RESULT_FAILED); - item.setInformation("all host ports are abnormal"); - } else if (hasFailed) { - item.setResult(TestItem.RESULT_SUCCESSFULLY_WITH_WARN); - item.setInformation(String.format("valid list: %s", String.join(",", results))); - } else { - item.setResult(TestItem.RESULT_SUCCESSFULLY); - item.setInformation(null); - } - } -} diff --git a/connectors/kafka-enhanced-connector/src/main/java/io/tapdata/connector/tester/items/HostPortTesterItem.java b/connectors/kafka-enhanced-connector/src/main/java/io/tapdata/connector/tester/items/HostPortTesterItem.java deleted file mode 100644 index 402568598..000000000 --- a/connectors/kafka-enhanced-connector/src/main/java/io/tapdata/connector/tester/items/HostPortTesterItem.java +++ /dev/null @@ -1,39 +0,0 @@ -package io.tapdata.connector.tester.items; - -import io.tapdata.connector.tester.IStep; -import io.tapdata.pdk.apis.entity.TestItem; -import io.tapdata.pdk.apis.exception.testItem.TapTestHostPortEx; -import io.tapdata.util.NetUtil; - -import java.util.function.Consumer; - -/** - * 检测项-主机端口 - * - * @author Harsen - * @version v1.0 2024/8/28 11:53 Create - */ -public interface HostPortTesterItem { - - static String test(String host, int port) { - String hostPort = String.format("%s:%d", host, port); - try { - NetUtil.validateHostPortWithSocket(host, port); - return hostPort; - } catch (Exception e) { - throw new TapTestHostPortEx(hostPort, e, host, String.valueOf(port)); - } - } - - static boolean test(String itemName, Consumer consumer, String host, int port) { - try { - NetUtil.validateHostPortWithSocket(host, port); - consumer.accept(new TestItem(itemName, TestItem.RESULT_SUCCESSFULLY, String.format("%s:%d", host, port))); - return IStep.CHECK_ITEM_APPLY; - } catch (Exception e) { - consumer.accept(new TestItem(itemName, new TapTestHostPortEx(e, host, String.valueOf(port)), TestItem.RESULT_FAILED)); - return IStep.CHECK_ITEM_SKIPPED; - } - } -} - diff --git a/connectors/kafka-enhanced-connector/src/main/java/io/tapdata/kafka/AbsSchemaMode.java b/connectors/kafka-enhanced-connector/src/main/java/io/tapdata/kafka/AbsSchemaMode.java index 9d81dfedc..f3c2d6789 100644 --- a/connectors/kafka-enhanced-connector/src/main/java/io/tapdata/kafka/AbsSchemaMode.java +++ b/connectors/kafka-enhanced-connector/src/main/java/io/tapdata/kafka/AbsSchemaMode.java @@ -16,8 +16,11 @@ import org.apache.commons.lang3.StringUtils; import org.apache.kafka.clients.consumer.ConsumerRecord; import org.apache.kafka.clients.producer.ProducerRecord; +import org.apache.kafka.common.utils.Utils; +import java.math.BigDecimal; import java.util.*; +import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentLinkedQueue; import java.util.function.Consumer; import java.util.stream.Collectors; @@ -33,11 +36,15 @@ public abstract class AbsSchemaMode { protected final KafkaSchemaMode kafkaSchemaMode; protected final IKafkaService kafkaService; protected final Log tapLogger; + protected final Boolean applyDefault; + protected final Map> primaryKeyMap; protected AbsSchemaMode(KafkaSchemaMode kafkaSchemaMode, IKafkaService kafkaService) { this.kafkaSchemaMode = kafkaSchemaMode; this.kafkaService = kafkaService; this.tapLogger = kafkaService.getLog(); + this.applyDefault = kafkaService.getConfig().getNodeApplyDefault(); + this.primaryKeyMap = new ConcurrentHashMap<>(); } public KafkaSchemaMode getSchemaMode() { @@ -88,6 +95,8 @@ public static AbsSchemaMode create(KafkaSchemaMode schemaMode, IKafkaService kaf return new OriginalSchemaMode(kafkaService); case STANDARD: return new StandardSchemaMode(kafkaService); + case CUSTOM: + return new CustomSchemaMode(kafkaService); case CANAL: return new CanalSchemaMode(kafkaService); case DEBEZIUM: @@ -112,7 +121,7 @@ protected void processIfStringNotBlank(Object param, Consumer consumer) } protected byte[] createKafkaKey(Map data, TapTable tapTable) { - Collection keys = tapTable.primaryKeys(true); + Collection keys = primaryKeyMap.computeIfAbsent(tapTable.getId(), k -> tapTable.primaryKeys(true)); if (EmptyKit.isEmpty(keys)) { return null; } @@ -120,7 +129,7 @@ protected byte[] createKafkaKey(Map data, TapTable tapTable) { } protected String createKafkaKeyValueMap(Map data, TapTable tapTable) { - Collection keys = tapTable.primaryKeys(true); + Collection keys = primaryKeyMap.computeIfAbsent(tapTable.getId(), k -> tapTable.primaryKeys(true)); if (EmptyKit.isEmpty(keys)) { return null; } @@ -129,6 +138,16 @@ protected String createKafkaKeyValueMap(Map data, TapTable tapTa return TapSimplify.toJson(keyValue); } + protected Integer computePartition(byte[] key, int partitionNum) { + if (key == null) { + return null; + } + if (partitionNum <= 0) { + return null; + } + return Utils.toPositive(Utils.murmur2(key)) % partitionNum; + } + protected String topic(TapTable table, TapEvent tapEvent) { return KafkaUtils.pickTopic(kafkaService.getConfig(), tapEvent.getDatabase(), tapEvent.getSchema(), table); } @@ -160,4 +179,35 @@ protected String toTapType(String dataType) { return dataType; } } + + /** + * 根据值推断 TapData 类型 + */ + protected String inferTapType(Object value) { + if (value == null) { + return "STRING"; + } + + if (value instanceof Boolean) { + return "BOOLEAN"; + } else if (value instanceof Integer || value instanceof Short || value instanceof Byte) { + return "INTEGER"; + } else if (value instanceof Long) { + return "BIGINT"; + } else if (value instanceof Float) { + return "FLOAT"; + } else if (value instanceof Double) { + return "DOUBLE"; + } else if (value instanceof BigDecimal) { + return "DOUBLE"; + } else if (value instanceof String) { + return "STRING"; + } else if (value instanceof List) { + return "ARRAY"; + } else if (value instanceof Map) { + return "MAP"; + } else { + return "STRING"; + } + } } diff --git a/connectors/kafka-enhanced-connector/src/main/java/io/tapdata/kafka/IKafkaService.java b/connectors/kafka-enhanced-connector/src/main/java/io/tapdata/kafka/IKafkaService.java index af93ee14f..53893e1ff 100644 --- a/connectors/kafka-enhanced-connector/src/main/java/io/tapdata/kafka/IKafkaService.java +++ b/connectors/kafka-enhanced-connector/src/main/java/io/tapdata/kafka/IKafkaService.java @@ -55,6 +55,8 @@ public interface IKafkaService extends AutoCloseable { void writeRecord(List recordEvents, TapTable table, Consumer> consumer); + void writeRecord(KafkaProducer producer,List recordEvents, TapTable table, Consumer> consumer); + CreateTableOptions createTable(TapCreateTableEvent tapCreateTableEvent); void deleteTable(TapDropTableEvent tapDropTableEvent); diff --git a/connectors/kafka-enhanced-connector/src/main/java/io/tapdata/kafka/KafkaConfig.java b/connectors/kafka-enhanced-connector/src/main/java/io/tapdata/kafka/KafkaConfig.java index ecec5a62a..dd4a9283b 100644 --- a/connectors/kafka-enhanced-connector/src/main/java/io/tapdata/kafka/KafkaConfig.java +++ b/connectors/kafka-enhanced-connector/src/main/java/io/tapdata/kafka/KafkaConfig.java @@ -2,7 +2,6 @@ import io.tapdata.connector.config.BasicConfig; import io.tapdata.connector.config.ConnectionClusterURI; -import io.tapdata.connector.config.ConnectionDatasourceInstanceInfo; import io.tapdata.connector.config.ConnectionExtParams; import io.tapdata.kafka.config.IConnectionACL; import io.tapdata.kafka.config.IConnectionSecurity; @@ -10,6 +9,7 @@ import io.tapdata.kafka.constants.KafkaConcurrentReadMode; import io.tapdata.kafka.constants.KafkaSchemaMode; import io.tapdata.kafka.constants.KafkaSerialization; +import io.tapdata.kafka.utils.Krb5Util; import io.tapdata.pdk.apis.context.TapConnectionContext; import org.apache.commons.lang3.StringUtils; import org.apache.kafka.clients.CommonClientConfigs; @@ -31,12 +31,14 @@ public class KafkaConfig extends BasicConfig implements IConnectionSecurity, ConnectionClusterURI, - ConnectionDatasourceInstanceInfo, IConnectionACL, ConnectionExtParams { - public KafkaConfig(TapConnectionContext context) { + private final String connectorId; + + public KafkaConfig(TapConnectionContext context, String connectorId) { super(context); + this.connectorId = connectorId; } // ---------- 连接配置 ---------- @@ -59,11 +61,22 @@ public KafkaSchemaMode getConnectionSchemaMode() { return KafkaSchemaMode.fromString(schemaMode); } + public String getConnectionScript() { + return connectionConfigGet("analyzeScript", ""); + } + + public String getNodeScript() { + return nodeConfigGet("processScript", ""); + } + public KafkaSchemaMode getNodeSchemaMode() { String schemaMode = nodeConfigGet("schemaMode", null); return StringUtils.isEmpty(schemaMode) ? null : KafkaSchemaMode.fromString(schemaMode); } + public Boolean getNodeApplyDefault() { + return nodeConfigGet("applyDefault", false); + } public KafkaSerialization getConnectionKeySerialization() { String keySerializer = connectionConfigGet("keySerialization", KafkaSerialization.BINARY.getType()); return KafkaSerialization.fromString(keySerializer); @@ -102,6 +115,10 @@ public int getNodeBatchMaxDelay() { return nodeConfigGet("batchMaxDelay", 2000); } + public boolean getSplitUpdatePk() { + return nodeConfigGet("splitUpdatePk", true); + } + public KafkaConcurrentReadMode getNodeConcurrentReadMode() { // String concurrentReadMode = nodeConfigGet("concurrentReadMode", KafkaConcurrentReadMode.SINGLE.name()); // return KafkaConcurrentReadMode.valueOf(concurrentReadMode); @@ -199,6 +216,10 @@ private Properties buildProperties(String type) { " username='" + getSaslUsername() + "' password='" + getSaslPassword() + "';"); } + if (useKerberos()) { + String krb5Path = Krb5Util.saveByCatalog("connections-" + connectorId, getKrb5Keytab(), getKrb5Conf(), true); + Krb5Util.updateKafkaConf(getKrb5ServiceName(), getKrb5Principal(), krb5Path, getKrb5Conf(), props); + } if (useSsl()) { // ssl.truststore.location=/path/to/kafka.client.truststore.jks // ssl.truststore.password=truststore_password @@ -274,7 +295,8 @@ public Properties buildDiscoverSchemaConfig(boolean isEarliest) { } mode.setDeserializer(this, props); props.put(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, isEarliest ? "earliest" : "latest"); - props.put(ConsumerConfig.FETCH_MAX_WAIT_MS_CONFIG, 0); + // 不能设为 0:Confluent Cloud 等高延迟集群下 broker 会立刻空返回 + props.put(ConsumerConfig.FETCH_MAX_WAIT_MS_CONFIG, 500); props.put(ConsumerConfig.MAX_POLL_RECORDS_CONFIG, 10); props.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, false); eachConnectionExtParams(props::put, "All", type); @@ -312,7 +334,7 @@ public Properties buildProducerConfig() { // ---------- 静态方法 ---------- - public static KafkaConfig valueOf(TapConnectionContext context) { - return new KafkaConfig(context); + public static KafkaConfig valueOf(TapConnectionContext context, String connectorId) { + return new KafkaConfig(context, connectorId); } } diff --git a/connectors/kafka-enhanced-connector/src/main/java/io/tapdata/kafka/KafkaEnhancedConnector.java b/connectors/kafka-enhanced-connector/src/main/java/io/tapdata/kafka/KafkaEnhancedConnector.java index e33856ac0..ae4ee39db 100644 --- a/connectors/kafka-enhanced-connector/src/main/java/io/tapdata/kafka/KafkaEnhancedConnector.java +++ b/connectors/kafka-enhanced-connector/src/main/java/io/tapdata/kafka/KafkaEnhancedConnector.java @@ -3,23 +3,28 @@ import io.tapdata.base.ConnectorBase; import io.tapdata.connector.utils.ErrorHelper; import io.tapdata.entity.codec.TapCodecsRegistry; +import io.tapdata.entity.event.dml.TapRecordEvent; import io.tapdata.entity.schema.TapTable; import io.tapdata.entity.schema.value.*; import io.tapdata.kafka.service.KafkaService; import io.tapdata.kit.EmptyKit; import io.tapdata.pdk.apis.annotations.TapConnectorClass; import io.tapdata.pdk.apis.context.TapConnectionContext; +import io.tapdata.pdk.apis.context.TapConnectorContext; +import io.tapdata.pdk.apis.entity.Capability; import io.tapdata.pdk.apis.entity.ConnectionOptions; import io.tapdata.pdk.apis.entity.TestItem; +import io.tapdata.pdk.apis.entity.WriteListResult; import io.tapdata.pdk.apis.functions.ConnectorFunctions; +import org.apache.kafka.clients.producer.KafkaProducer; -import java.util.List; -import java.util.Optional; -import java.util.Set; -import java.util.UUID; +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.atomic.AtomicBoolean; import java.util.function.Consumer; +import static io.tapdata.pdk.apis.entity.ConnectionOptions.*; + /** * 标准 Kafka 连接器 * @@ -33,19 +38,25 @@ public class KafkaEnhancedConnector extends ConnectorBase { private IKafkaService kafkaService; protected KafkaConfig kafkaConfig; protected final AtomicBoolean stopping = new AtomicBoolean(false); + protected Map> producerMap = new ConcurrentHashMap<>(); + protected boolean isTransaction = false; @Override public void onStart(TapConnectionContext connectionContext) throws Throwable { connectionContext.getLog().info("Starting {}", PDK_ID); - kafkaConfig = KafkaConfig.valueOf(connectionContext); - kafkaService = new KafkaService(kafkaConfig, stopping); isConnectorStarted(connectionContext, connectorContext -> { String firstConnectorId = (String) connectorContext.getStateMap().get("firstConnectorId"); if (EmptyKit.isNull(firstConnectorId)) { firstConnectorId = UUID.randomUUID().toString().replace("-", ""); connectorContext.getStateMap().put("firstConnectorId", firstConnectorId); } + kafkaConfig = KafkaConfig.valueOf(connectionContext, firstConnectorId); }); + if (!(connectionContext instanceof TapConnectorContext)) { + kafkaConfig = KafkaConfig.valueOf(connectionContext, ""); + } + stopping.compareAndSet(true, false); + kafkaService = new KafkaService(kafkaConfig, stopping); } @Override @@ -53,6 +64,7 @@ public void onStop(TapConnectionContext connectionContext) throws Throwable { stopping.compareAndSet(false, true); connectionContext.getLog().info("Stopping {}", PDK_ID); ErrorHelper.closeWithNotNull(kafkaService); + kafkaService = null; } @Override @@ -83,7 +95,7 @@ public void registerCapabilities(ConnectorFunctions connectorFunctions, TapCodec connectorFunctions.supportTimestampToStreamOffset((context, startTime) -> kafkaService.timestampToStreamOffset(startTime)); // Target Capabilities - connectorFunctions.supportWriteRecord((context, list, tapTable, consumer) -> kafkaService.writeRecord(list, tapTable, consumer)); + connectorFunctions.supportWriteRecord(this::writeRecord); // DDL // - createTable @@ -92,6 +104,9 @@ public void registerCapabilities(ConnectorFunctions connectorFunctions, TapCodec connectorFunctions.supportDropTable((connectorContext, dropTableEvent) -> kafkaService.deleteTable(dropTableEvent)); // - js connectorFunctions.supportQueryByAdvanceFilter((connectorContext, filter, table, consumer) -> kafkaService.queryByAdvanceFilter(filter, table, consumer)); + connectorFunctions.supportTransactionBeginFunction(this::beginTransaction); + connectorFunctions.supportTransactionCommitFunction(this::commitTransaction); + connectorFunctions.supportTransactionRollbackFunction(this::rollbackTransaction); } @Override @@ -101,7 +116,28 @@ public void discoverSchema(TapConnectionContext connectionContext, List @Override public ConnectionOptions connectionTest(TapConnectionContext connectionContext, Consumer consumer) throws Throwable { - return new KafkaTester(connectionContext, consumer).start(); + ConnectionOptions connectionOptions = ConnectionOptions.create(); + try { + onStart(connectionContext); + connectionOptions.connectionString(kafkaConfig.getConnectionClusterURI()); + try (KafkaEnhancedTest kafkaTest = new KafkaEnhancedTest(kafkaConfig, consumer, connectionContext.getLog())) { + kafkaTest.testOneByOne(); + } + } catch (Throwable throwable) { +// TapLogger.error(TAG, throwable.getMessage()); +// kafkaExceptionCollector.collectTerminateByServer(throwable); +// kafkaExceptionCollector.collectUserPwdInvalid(kafkaConfig.getMqUsername(), throwable); + consumer.accept(testItem(TestItem.ITEM_CONNECTION, TestItem.RESULT_FAILED, "Failed, " + throwable.getMessage())); + } finally { + onStop(connectionContext); + } + List ddlCapabilities = Arrays.asList( + Capability.create(DDL_NEW_FIELD_EVENT).type(Capability.TYPE_DDL), + Capability.create(DDL_ALTER_FIELD_NAME_EVENT).type(Capability.TYPE_DDL), + Capability.create(DDL_ALTER_FIELD_ATTRIBUTES_EVENT).type(Capability.TYPE_DDL), + Capability.create(DDL_DROP_FIELD_EVENT).type(Capability.TYPE_DDL)); + ddlCapabilities.forEach(connectionOptions::capability); + return connectionOptions; } @Override @@ -110,4 +146,51 @@ public int tableCount(TapConnectionContext connectionContext) throws Throwable { Set topics = adminService.listTopics(); return topics.size(); } + + private void writeRecord(TapConnectorContext connectorContext, List tapRecordEvents, TapTable tapTable, Consumer> writeListResultConsumer) { + if (isTransaction) { + String threadName = Thread.currentThread().getName(); + KafkaProducer producer; + if (producerMap.containsKey(threadName)) { + producer = producerMap.get(threadName); + } else { + producer = kafkaService.getProducer(); + producerMap.put(threadName, producer); + } + kafkaService.writeRecord(producer, tapRecordEvents, tapTable, writeListResultConsumer); + } else { + kafkaService.writeRecord(tapRecordEvents, tapTable, writeListResultConsumer); + } + } + + protected void beginTransaction(TapConnectorContext connectorContext) { + producerMap.computeIfPresent(Thread.currentThread().getName(), (k, v) -> { + v.abortTransaction(); + v.close(); + v = kafkaService.getProducer(); + v.beginTransaction(); + return v; + }); + producerMap.computeIfAbsent(Thread.currentThread().getName(), key -> { + KafkaProducer v = kafkaService.getProducer(); + v.beginTransaction(); + return v; + }); + } + + protected void commitTransaction(TapConnectorContext connectorContext) { + producerMap.computeIfPresent(Thread.currentThread().getName(), (k, v) -> { + v.commitTransaction(); + v.close(); + return null; + }); + } + + protected void rollbackTransaction(TapConnectorContext connectorContext) { + producerMap.computeIfPresent(Thread.currentThread().getName(), (k, v) -> { + v.abortTransaction(); + v.close(); + return null; + }); + } } diff --git a/connectors/kafka-enhanced-connector/src/main/java/io/tapdata/kafka/KafkaEnhancedTest.java b/connectors/kafka-enhanced-connector/src/main/java/io/tapdata/kafka/KafkaEnhancedTest.java new file mode 100644 index 000000000..b8a1a3c66 --- /dev/null +++ b/connectors/kafka-enhanced-connector/src/main/java/io/tapdata/kafka/KafkaEnhancedTest.java @@ -0,0 +1,128 @@ +package io.tapdata.kafka; + +import io.tapdata.connector.error.KafkaErrorCodes; +import io.tapdata.constant.MqTestItem; +import io.tapdata.entity.logger.Log; +import io.tapdata.exception.TapCodeException; +import io.tapdata.kafka.service.KafkaAdminService; +import io.tapdata.kafka.utils.Krb5Util; +import io.tapdata.kit.EmptyKit; +import io.tapdata.kit.ErrorKit; +import io.tapdata.pdk.apis.entity.TestItem; +import io.tapdata.pdk.apis.exception.TapTestItemException; +import io.tapdata.util.NetUtil; + +import java.io.IOException; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.function.Consumer; +import java.util.function.Supplier; + +import static io.tapdata.base.ConnectorBase.testItem; + +public class KafkaEnhancedTest implements AutoCloseable { + + private KafkaConfig kafkaConfig; + private KafkaAdminService kafkaAdminService; + private Consumer consumer; + protected Map> testFunctionMap; + + public KafkaEnhancedTest(KafkaConfig kafkaConfig, Consumer consumer, Log tapLogger) { + this.kafkaConfig = kafkaConfig; + this.consumer = consumer; + this.kafkaAdminService = new KafkaAdminService(kafkaConfig, tapLogger); + testFunctionMap = new LinkedHashMap<>(); + testFunctionMap.put("testHostPort", this::testHostAndPort); + if (kafkaConfig.useKerberos()) { + testFunctionMap.put("testKerberos", this::testKerberos); + } + if (kafkaConfig.getConnectionSchemaRegister()) { + testFunctionMap.put("testSchemaRegistry", this::testSchemaRegistry); + } + testFunctionMap.put("testConnect", this::testConnect); +// testFunctionMap.put("testVersion", this::testVersion); + } + + public Boolean testHostAndPort() { + String[] hostAndPort = kafkaConfig.getConnectionClusterURI().split(","); + int failedCount = 0; + for (String hostAndPortItem : hostAndPort) { + String[] strs = hostAndPortItem.split(":"); + if (strs.length != 2) { + consumer.accept(testItem(MqTestItem.NAME_SERVER.getContent(), TestItem.RESULT_FAILED, "name server address is invalid!")); + return false; + } else { + try { + NetUtil.validateHostPortWithSocket(strs[0], Integer.parseInt(strs[1])); + } catch (IOException e) { + failedCount++; + } catch (NumberFormatException e) { + consumer.accept(testItem(MqTestItem.NAME_SERVER.getContent(), TestItem.RESULT_FAILED, "name server address is invalid!")); + return false; + } + } + } + if (failedCount == 0) { + consumer.accept(testItem(MqTestItem.NAME_SERVER.getContent(), TestItem.RESULT_SUCCESSFULLY)); + } else if (failedCount == hostAndPort.length) { + consumer.accept(testItem(MqTestItem.NAME_SERVER.getContent(), TestItem.RESULT_FAILED, "all addresses of name server is down!")); + return false; + } else { + consumer.accept(testItem(MqTestItem.NAME_SERVER.getContent(), TestItem.RESULT_SUCCESSFULLY_WITH_WARN, "some addresses of name server is down!")); + } + return true; + } + + public Boolean testKerberos() { + try { + Krb5Util.checkKDCDomainsBase64(kafkaConfig.getKrb5Conf()); + consumer.accept(new TestItem(MqTestItem.KAFKA_BASE64_CONNECTION.getContent(), TestItem.RESULT_SUCCESSFULLY, null)); + return true; + } catch (Exception e) { + consumer.accept(new TestItem(MqTestItem.KAFKA_BASE64_CONNECTION.getContent(), TestItem.RESULT_FAILED, e.getMessage())); + return false; + } + } + + public Boolean testSchemaRegistry() { + try { + if (kafkaAdminService.testRegistryConnect()) { + consumer.accept(new TestItem(MqTestItem.KAFKA_SCHEMA_REGISTER_CONNECTION.getContent(), TestItem.RESULT_SUCCESSFULLY, null)); + return true; + } + } catch (Exception e) { + consumer.accept(new TestItem(MqTestItem.KAFKA_SCHEMA_REGISTER_CONNECTION.getContent(), new TapTestItemException(new TapCodeException(KafkaErrorCodes.KAFKA_COMMON_ERROR, e)), TestItem.RESULT_FAILED)); + } + return false; + } + + public Boolean testConnect() { + try { + if (kafkaAdminService.isClusterConnectable()) { + consumer.accept(new TestItem(MqTestItem.KAFKA_MQ_CONNECTION.getContent(), TestItem.RESULT_SUCCESSFULLY, null)); + return true; + } else { + consumer.accept(new TestItem(MqTestItem.KAFKA_MQ_CONNECTION.getContent(), TestItem.RESULT_FAILED, "cluster is not connectable")); + return false; + } + } catch (Exception e) { + consumer.accept(new TestItem(MqTestItem.KAFKA_MQ_CONNECTION.getContent(), new TapTestItemException(new TapCodeException(KafkaErrorCodes.KAFKA_COMMON_ERROR, e)), TestItem.RESULT_FAILED)); + return false; + } + } + + public Boolean testOneByOne() { + for (Map.Entry> entry : testFunctionMap.entrySet()) { + Boolean res = entry.getValue().get(); + if (EmptyKit.isNotNull(res) && !res) { + return false; + } + } + return true; + } + + @Override + public void close() { + ErrorKit.ignoreAnyError(() -> kafkaAdminService.close()); + } +} diff --git a/connectors/kafka-enhanced-connector/src/main/java/io/tapdata/kafka/KafkaTester.java b/connectors/kafka-enhanced-connector/src/main/java/io/tapdata/kafka/KafkaTester.java deleted file mode 100644 index 355dd6ce5..000000000 --- a/connectors/kafka-enhanced-connector/src/main/java/io/tapdata/kafka/KafkaTester.java +++ /dev/null @@ -1,37 +0,0 @@ -package io.tapdata.kafka; - -import io.tapdata.connector.tester.Tester; -import io.tapdata.kafka.tester.KafkaCommonStep; -import io.tapdata.kafka.tester.KafkaSourceStep; -import io.tapdata.kafka.tester.KafkaTargetStep; -import io.tapdata.pdk.apis.context.TapConnectionContext; -import io.tapdata.pdk.apis.entity.TestItem; - -import java.util.function.Consumer; - -/** - * Kafka 连接测试实现 - * - * @author Harsen - * @version v1.0 2024/8/28 17:07 Create - */ -public class KafkaTester extends Tester { - public KafkaTester(TapConnectionContext connectionContext, Consumer consumer) { - super(KafkaConfig.valueOf(connectionContext), consumer); - } - - @Override - protected KafkaCommonStep openCommon() { - return new KafkaCommonStep(this); - } - - @Override - protected KafkaSourceStep openSource() { - return new KafkaSourceStep(this); - } - - @Override - protected KafkaTargetStep openTarget() { - return new KafkaTargetStep(this); - } -} diff --git a/connectors/kafka-enhanced-connector/src/main/java/io/tapdata/kafka/config/IConnectionSecurity.java b/connectors/kafka-enhanced-connector/src/main/java/io/tapdata/kafka/config/IConnectionSecurity.java index d4cb03847..b9757a497 100644 --- a/connectors/kafka-enhanced-connector/src/main/java/io/tapdata/kafka/config/IConnectionSecurity.java +++ b/connectors/kafka-enhanced-connector/src/main/java/io/tapdata/kafka/config/IConnectionSecurity.java @@ -14,6 +14,7 @@ public interface IConnectionSecurity extends IConfigWithContext { String KEY_SASL_USERNAME = "saslUsername"; String KEY_SASL_PASSWORD = "saslPassword"; String KEY_USE_SSL = "useSsl"; + String KEY_USE_KERBEROS = "krb5"; enum Protocol { PLAINTEXT, // 没有任何 authentication @@ -70,6 +71,26 @@ default boolean useSasl() { return connectionConfigGet(KEY_USE_SASL, false); } + default boolean useKerberos() { + return connectionConfigGet(KEY_USE_KERBEROS, false); + } + + default String getKrb5Keytab() { + return connectionConfigGet("krb5Keytab", ""); + } + + default String getKrb5Conf() { + return connectionConfigGet("krb5Conf", ""); + } + + default String getKrb5Principal() { + return connectionConfigGet("krb5Principal", ""); + } + + default String getKrb5ServiceName() { + return connectionConfigGet("krb5ServiceName", ""); + } + default String getSaslMechanism() { return connectionConfigGet(KEY_SASL_MECHANISM, "PLAIN"); } diff --git a/connectors/kafka-enhanced-connector/src/main/java/io/tapdata/kafka/constants/KafkaSchemaMode.java b/connectors/kafka-enhanced-connector/src/main/java/io/tapdata/kafka/constants/KafkaSchemaMode.java index 37f886fd3..4f920c47c 100644 --- a/connectors/kafka-enhanced-connector/src/main/java/io/tapdata/kafka/constants/KafkaSchemaMode.java +++ b/connectors/kafka-enhanced-connector/src/main/java/io/tapdata/kafka/constants/KafkaSchemaMode.java @@ -53,6 +53,19 @@ public void setDeserializer(KafkaConfig kafkaConfig, Properties props) { props.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, kafkaConfig.getConnectionValueSerialization().getDeserializer()); } }, + CUSTOM() { + @Override + public void setSerializer(KafkaConfig kafkaConfig, Properties props) { + props.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName()); + props.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName()); + } + + @Override + public void setDeserializer(KafkaConfig kafkaConfig, Properties props) { + props.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class.getName()); + props.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class.getName()); + } + }, CANAL() { @Override public void setSerializer(KafkaConfig kafkaConfig, Properties props) { diff --git a/connectors/kafka-enhanced-connector/src/main/java/io/tapdata/kafka/schema_mode/CanalSchemaMode.java b/connectors/kafka-enhanced-connector/src/main/java/io/tapdata/kafka/schema_mode/CanalSchemaMode.java index dc9aeea0b..04b1254a4 100644 --- a/connectors/kafka-enhanced-connector/src/main/java/io/tapdata/kafka/schema_mode/CanalSchemaMode.java +++ b/connectors/kafka-enhanced-connector/src/main/java/io/tapdata/kafka/schema_mode/CanalSchemaMode.java @@ -12,7 +12,6 @@ import io.tapdata.kafka.AbsSchemaMode; import io.tapdata.kafka.IKafkaService; import io.tapdata.kafka.constants.KafkaSchemaMode; -import io.tapdata.kafka.utils.KafkaUtils; import io.tapdata.pdk.apis.entity.FilterResults; import io.tapdata.pdk.apis.entity.TapAdvanceFilter; import org.apache.commons.lang3.StringUtils; @@ -95,12 +94,8 @@ public List> fromTapEvent(TapTable table, TapEven key = createKafkaKey(data, table); } - if (null == key) { - return Arrays.asList(new ProducerRecord<>(topic, value)); - } else { - return Arrays.asList(new ProducerRecord<>(topic, key, value)); - } - } + return List.of(new ProducerRecord<>(topic, computePartition(key, kafkaService.getConfig().getNodePartitionSize()), key, value)); + } @Override public void queryByAdvanceFilter(TapAdvanceFilter filter, TapTable table, Consumer consumer) { diff --git a/connectors/kafka-enhanced-connector/src/main/java/io/tapdata/kafka/schema_mode/CustomSchemaMode.java b/connectors/kafka-enhanced-connector/src/main/java/io/tapdata/kafka/schema_mode/CustomSchemaMode.java new file mode 100644 index 000000000..8cf136b5d --- /dev/null +++ b/connectors/kafka-enhanced-connector/src/main/java/io/tapdata/kafka/schema_mode/CustomSchemaMode.java @@ -0,0 +1,271 @@ +package io.tapdata.kafka.schema_mode; + +import io.tapdata.base.ConnectorBase; +import io.tapdata.constant.DMLType; +import io.tapdata.entity.error.CoreException; +import io.tapdata.entity.event.TapEvent; +import io.tapdata.entity.event.dml.TapDeleteRecordEvent; +import io.tapdata.entity.event.dml.TapInsertRecordEvent; +import io.tapdata.entity.event.dml.TapRecordEvent; +import io.tapdata.entity.event.dml.TapUpdateRecordEvent; +import io.tapdata.entity.schema.TapField; +import io.tapdata.entity.schema.TapTable; +import io.tapdata.entity.script.ScriptFactory; +import io.tapdata.entity.script.ScriptOptions; +import io.tapdata.entity.simplify.TapSimplify; +import io.tapdata.entity.utils.InstanceFactory; +import io.tapdata.entity.utils.JsonParser; +import io.tapdata.entity.utils.TapUtils; +import io.tapdata.exception.StopException; +import io.tapdata.kafka.AbsSchemaMode; +import io.tapdata.kafka.IKafkaService; +import io.tapdata.kafka.KafkaConfig; +import io.tapdata.kafka.constants.KafkaSchemaMode; +import io.tapdata.kit.EmptyKit; +import io.tapdata.pdk.apis.entity.FilterResults; +import io.tapdata.pdk.apis.entity.TapAdvanceFilter; +import org.apache.kafka.clients.consumer.ConsumerRecord; +import org.apache.kafka.clients.producer.ProducerRecord; +import org.apache.kafka.common.header.internals.RecordHeaders; + +import javax.script.Invocable; +import javax.script.ScriptEngine; +import javax.script.ScriptException; +import java.util.*; +import java.util.function.Consumer; + +import static io.tapdata.constant.DMLType.*; + +public class CustomSchemaMode extends AbsSchemaMode { + + private static final ScriptFactory scriptFactory = InstanceFactory.instance(ScriptFactory.class, "tapdata"); + private final ScriptEngine scriptEngine; + + public CustomSchemaMode(IKafkaService kafkaService) { + super(KafkaSchemaMode.CUSTOM, kafkaService); + try { + ClassLoader appClassLoader = ScriptOptions.class.getClassLoader(); + scriptEngine = scriptFactory.create(ScriptFactory.TYPE_JAVASCRIPT, + new ScriptOptions().engineName("graal.js").classLoader(appClassLoader)); + String buildInMethod = initBuildInMethod(); + KafkaConfig kafkaConfig = kafkaService.getConfig(); + String script = EmptyKit.isNotBlank(kafkaConfig.getNodeScript()) ? kafkaConfig.getNodeScript() : kafkaConfig.getConnectionScript(); + String scripts = script + System.lineSeparator() + buildInMethod; + scriptEngine.eval(scripts); + } catch (Exception e) { + throw new CoreException("Engine initialization failed!"); + } + } + + @Override + public void sampleOneSchema(String table, TapTable sampleTable) { + kafkaService.sampleValue(Collections.singletonList(table), null, record -> { + if (null != record) { + Object eventObj = executeScript(scriptEngine, "analyze", record.headers(), record.key(), record.value()); + if (eventObj instanceof Map) { + Map after = (Map) ((Map) eventObj).get("after"); + if (after != null) { + for (Map.Entry entry : after.entrySet()) { + String fieldName = entry.getKey(); + Object fieldValue = entry.getValue(); + TapField tapField = new TapField(fieldName, inferTapType(fieldValue)); + sampleTable.add(tapField); + } + return false; + } + } + } + return true; + }); + } + + @Override + public TapEvent toTapEvent(ConsumerRecord consumerRecord) { + if (consumerRecord == null || consumerRecord.value() == null) { + return null; + } + try { + Object value = executeScript(scriptEngine, "analyze", consumerRecord.headers(), consumerRecord.key(), consumerRecord.value()); + Map after = (Map) ((Map) value).get("after"); + Map before = (Map) ((Map) value).get("before"); + String op = String.valueOf(((Map) value).get("op")); + // 根据操作类型创建对应的 TapEvent + TapRecordEvent tapEvent; + switch (op) { + case "i": + tapEvent = TapInsertRecordEvent.create(); + ((TapInsertRecordEvent) tapEvent).setAfter(after); + break; + + case "u": + tapEvent = TapUpdateRecordEvent.create(); + ((TapUpdateRecordEvent) tapEvent).setAfter(after); + ((TapUpdateRecordEvent) tapEvent).setBefore(before); + break; + + case "d": + tapEvent = TapDeleteRecordEvent.create(); + ((TapDeleteRecordEvent) tapEvent).setBefore(before); + break; + + default: + tapEvent = TapInsertRecordEvent.create(); + ((TapInsertRecordEvent) tapEvent).setAfter(after); + break; + } + // 设置事件元数据 + tapEvent.setTableId(consumerRecord.topic()); + tapEvent.setReferenceTime(consumerRecord.timestamp()); + return tapEvent; + } catch (Exception e) { + throw new RuntimeException("Failed to convert JSON message to TapEvent from topic: " + consumerRecord.topic(), e); + } + } + + @Override + public List> fromTapEvent(TapTable table, TapEvent tapEvent) { + Map record = new HashMap<>(); + Map data; + Map allData = new HashMap<>(); + DMLType op = INSERT; + if (tapEvent instanceof TapInsertRecordEvent) { + data = ((TapInsertRecordEvent) tapEvent).getAfter(); + allData.put("before", new HashMap()); + allData.put("after", data); + } else if (tapEvent instanceof TapUpdateRecordEvent) { + data = ((TapUpdateRecordEvent) tapEvent).getAfter(); + Map before = ((TapUpdateRecordEvent) tapEvent).getBefore(); + allData.put("before", null == before ? new HashMap<>() : before); + allData.put("after", data); + op = UPDATE; + } else if (tapEvent instanceof TapDeleteRecordEvent) { + data = ((TapDeleteRecordEvent) tapEvent).getBefore(); + allData.put("before", data); + allData.put("after", new HashMap()); + op = DELETE; + } else { + data = new HashMap<>(); + } + String topic = topic(table, tapEvent); + record.put("tableName", topic); + String kafkaMessageKey = createKafkaKeyValueMap(data, table); + record.put("key", kafkaMessageKey); + record.put("data", allData); + Map header = new HashMap<>(); + header.put("op", op.name()); + record.put("header", header); + Collection conditionKeys = table.primaryKeys(true); + RecordHeaders recordHeaders = new RecordHeaders(); + String key = null; + Object eventObj = covertData(executeScript(scriptEngine, "process", record, op.name(), conditionKeys)); + String body = null; + if (null == eventObj) { + } else { + Map res = (Map) eventObj; + if (null == res.get("value")) { + throw new RuntimeException("value cannot be null"); + } else { + Object obj = res.get("value"); + if (obj instanceof Map) { + Map> map = (Map>) res.get("value"); + if (map.containsKey("before") && map.get("before").isEmpty()) { + map.remove("before"); + } + if (map.containsKey("after") && map.get("after").isEmpty()) { + map.remove("after"); + } + res.put("value", map); + body = TapSimplify.toJson(res.get("value"), JsonParser.ToJsonFeature.WriteMapNullValue); + } else { + body = obj.toString(); + } + } + if (res.containsKey("header")) { + Object obj = res.get("header"); + if (obj instanceof Map) { + Map head = (Map) res.get("header"); + for (String s : head.keySet()) { + recordHeaders.add(s, head.get(s).toString().getBytes()); + } + } else { + throw new RuntimeException("header must be a collection type"); + } + } else { + recordHeaders.add("op", op.name().getBytes()); + } + if (res.containsKey("key")) { + key = String.valueOf(res.get("key")); + } + } + Integer partition = key == null ? null : computePartition(key.getBytes(), kafkaService.getConfig().getNodePartitionSize()); + ProducerRecord producerRecord = new ProducerRecord<>(topic, partition, tapEvent.getTime(), key, body, + recordHeaders); + return List.of(producerRecord); + } + + @Override + public void queryByAdvanceFilter(TapAdvanceFilter filter, TapTable table, Consumer consumer) { + + } + + protected String initBuildInMethod() { + StringBuilder buildInMethod = new StringBuilder(); + buildInMethod.append("var DateUtil = Java.type(\"com.tapdata.constant.DateUtil\");\n"); + buildInMethod.append("var UUIDGenerator = Java.type(\"com.tapdata.constant.UUIDGenerator\");\n"); + buildInMethod.append("var idGen = Java.type(\"com.tapdata.constant.UUIDGenerator\");\n"); + buildInMethod.append("var HashMap = Java.type(\"java.util.HashMap\");\n"); + buildInMethod.append("var LinkedHashMap = Java.type(\"java.util.LinkedHashMap\");\n"); + buildInMethod.append("var ArrayList = Java.type(\"java.util.ArrayList\");\n"); + buildInMethod.append("var Date = Java.type(\"java.util.Date\");\n"); + buildInMethod.append("var uuid = UUIDGenerator.uuid;\n"); + buildInMethod.append("var JSONUtil = Java.type('com.tapdata.constant.JSONUtil');\n"); + buildInMethod.append("var HanLPUtil = Java.type(\"com.tapdata.constant.HanLPUtil\");\n"); + buildInMethod.append("var split_chinese = HanLPUtil.hanLPParticiple;\n"); + buildInMethod.append("var util = Java.type(\"com.tapdata.processor.util.Util\");\n"); + buildInMethod.append("var MD5Util = Java.type(\"com.tapdata.constant.MD5Util\");\n"); + buildInMethod.append("var MD5 = function(str){return MD5Util.crypt(str, true);};\n"); + buildInMethod.append("var Collections = Java.type(\"java.util.Collections\");\n"); + buildInMethod.append("var MapUtils = Java.type(\"com.tapdata.constant.MapUtil\");\n"); + buildInMethod.append("var sleep = function(ms){\n" + + "var Thread = Java.type(\"java.lang.Thread\");\n" + + "Thread.sleep(ms);\n" + + "}\n"); + return buildInMethod.toString(); + } + + public static Object executeScript(ScriptEngine scriptEngine, String function, Object... params) { + if (scriptEngine != null) { + Invocable invocable = (Invocable) scriptEngine; + try { + return invocable.invokeFunction(function, params); + } catch (StopException e) { +// TapLogger.info(TAG, "Get data and stop script."); + throw new RuntimeException(e); + } catch (ScriptException | NoSuchMethodException | RuntimeException e) { +// TapLogger.error(TAG, "Run script error, message: {}", e.getMessage(), e); + throw new RuntimeException(e); + } + } + return null; + } + + private Object covertData(Object apply) { + if (Objects.isNull(apply)) { + return null; + } else if (apply instanceof Map) { + return InstanceFactory.instance(TapUtils.class).cloneMap((Map) apply);//fromJson(toJson(apply)); + } else if (apply instanceof Collection) { + try { + return new ArrayList<>((List) apply);//ConnectorBase.fromJsonArray(toJson(apply)); + } catch (Exception e) { + String toString = apply.toString(); + if (toString.matches("\\(([0-9]+)\\)\\[.*]")) { + toString = toString.replaceFirst("\\(([0-9]+)\\)", ""); + } + return ConnectorBase.fromJsonArray(toString); + } + } else { + return apply; + } + } +} diff --git a/connectors/kafka-enhanced-connector/src/main/java/io/tapdata/kafka/schema_mode/DebeziumSchemaMode.java b/connectors/kafka-enhanced-connector/src/main/java/io/tapdata/kafka/schema_mode/DebeziumSchemaMode.java index c90c01aa0..beb69ed6b 100644 --- a/connectors/kafka-enhanced-connector/src/main/java/io/tapdata/kafka/schema_mode/DebeziumSchemaMode.java +++ b/connectors/kafka-enhanced-connector/src/main/java/io/tapdata/kafka/schema_mode/DebeziumSchemaMode.java @@ -85,12 +85,8 @@ public List> fromTapEvent(TapTable table, TapEven throw new TapCodeException(KafkaErrorCodes.DEBEZIUM_NOT_SUPPORT_EVENT).dynamicDescriptionParameters(tapEvent.getClass().getSimpleName()); } - if (null == key) { - return Arrays.asList(new ProducerRecord<>(topic, value)); - } else { - return Arrays.asList(new ProducerRecord<>(topic, key, value)); - } - } + return List.of(new ProducerRecord<>(topic, computePartition(key, kafkaService.getConfig().getNodePartitionSize()), key, value)); + } @Override public void queryByAdvanceFilter(TapAdvanceFilter filter, TapTable table, Consumer consumer) { diff --git a/connectors/kafka-enhanced-connector/src/main/java/io/tapdata/kafka/schema_mode/FlinkSchemaMode.java b/connectors/kafka-enhanced-connector/src/main/java/io/tapdata/kafka/schema_mode/FlinkSchemaMode.java index 2fc8b318d..0990de413 100644 --- a/connectors/kafka-enhanced-connector/src/main/java/io/tapdata/kafka/schema_mode/FlinkSchemaMode.java +++ b/connectors/kafka-enhanced-connector/src/main/java/io/tapdata/kafka/schema_mode/FlinkSchemaMode.java @@ -45,7 +45,8 @@ public List> fromTapEvent(TapTable table, TapEven value.put("data", data); value.put("op", "+I"); byte[] key = createKafkaKey(data, table); - producerRecords.add(new ProducerRecord<>(topic, key, value)); + Integer partition = computePartition(key, kafkaService.getConfig().getNodePartitionSize()); + producerRecords.add(new ProducerRecord<>(topic, partition, key, value)); } else if (tapEvent instanceof TapUpdateRecordEvent) { Map before = ((TapUpdateRecordEvent) tapEvent).getBefore(); Map after = ((TapUpdateRecordEvent) tapEvent).getAfter(); @@ -54,20 +55,23 @@ public List> fromTapEvent(TapTable table, TapEven beforeValue.put("data", before); beforeValue.put("op", "-U"); byte[] beforeKey = createKafkaKey(before, table); - producerRecords.add(new ProducerRecord<>(topic, beforeKey, beforeValue)); + Integer partition = computePartition(beforeKey, kafkaService.getConfig().getNodePartitionSize()); + producerRecords.add(new ProducerRecord<>(topic, partition, beforeKey, beforeValue)); } Map afterValue = new HashMap<>(); afterValue.put("data", after); afterValue.put("op", "+U"); byte[] afterKey = createKafkaKey(after, table); - producerRecords.add(new ProducerRecord<>(topic, afterKey, afterValue)); + Integer partition = computePartition(afterKey, kafkaService.getConfig().getNodePartitionSize()); + producerRecords.add(new ProducerRecord<>(topic, partition, afterKey, afterValue)); } else if (tapEvent instanceof TapDeleteRecordEvent) { Map before = ((TapDeleteRecordEvent) tapEvent).getBefore(); Map value = new HashMap<>(); value.put("data", before); value.put("op", "-D"); byte[] key = createKafkaKey(before, table); - producerRecords.add(new ProducerRecord<>(topic, key, value)); + Integer partition = computePartition(key, kafkaService.getConfig().getNodePartitionSize()); + producerRecords.add(new ProducerRecord<>(topic, partition, key, value)); } return producerRecords; } diff --git a/connectors/kafka-enhanced-connector/src/main/java/io/tapdata/kafka/schema_mode/OriginalSchemaMode.java b/connectors/kafka-enhanced-connector/src/main/java/io/tapdata/kafka/schema_mode/OriginalSchemaMode.java index 0194d7685..abd616eb5 100644 --- a/connectors/kafka-enhanced-connector/src/main/java/io/tapdata/kafka/schema_mode/OriginalSchemaMode.java +++ b/connectors/kafka-enhanced-connector/src/main/java/io/tapdata/kafka/schema_mode/OriginalSchemaMode.java @@ -6,11 +6,11 @@ import io.tapdata.entity.schema.TapField; import io.tapdata.entity.schema.TapTable; import io.tapdata.entity.schema.value.DateTime; +import io.tapdata.kafka.AbsSchemaMode; +import io.tapdata.kafka.IKafkaService; import io.tapdata.kafka.KafkaConfig; import io.tapdata.kafka.constants.KafkaSchemaMode; import io.tapdata.kafka.constants.KafkaSerialization; -import io.tapdata.kafka.AbsSchemaMode; -import io.tapdata.kafka.IKafkaService; import io.tapdata.kafka.utils.KafkaUtils; import io.tapdata.kafka.utils.RecordHeadersUtils; import io.tapdata.pdk.apis.entity.FilterResults; @@ -20,8 +20,6 @@ import org.apache.kafka.clients.producer.ProducerRecord; import org.apache.kafka.common.header.Headers; -import java.time.LocalDateTime; -import java.time.format.DateTimeFormatter; import java.util.*; import java.util.concurrent.atomic.AtomicInteger; import java.util.function.Consumer; diff --git a/connectors/kafka-enhanced-connector/src/main/java/io/tapdata/kafka/schema_mode/RegistryAvroMode.java b/connectors/kafka-enhanced-connector/src/main/java/io/tapdata/kafka/schema_mode/RegistryAvroMode.java index d64bb3bde..b17a117d7 100644 --- a/connectors/kafka-enhanced-connector/src/main/java/io/tapdata/kafka/schema_mode/RegistryAvroMode.java +++ b/connectors/kafka-enhanced-connector/src/main/java/io/tapdata/kafka/schema_mode/RegistryAvroMode.java @@ -12,18 +12,22 @@ import io.tapdata.kafka.AbsSchemaMode; import io.tapdata.kafka.IKafkaService; import io.tapdata.kafka.constants.KafkaSchemaMode; +import io.tapdata.kit.EmptyKit; import io.tapdata.kit.StringKit; import io.tapdata.pdk.apis.entity.FilterResults; import io.tapdata.pdk.apis.entity.TapAdvanceFilter; +import org.apache.avro.JsonProperties; import org.apache.avro.Schema; import org.apache.avro.SchemaBuilder; import org.apache.avro.generic.GenericData; import org.apache.avro.generic.GenericRecord; +import org.apache.avro.util.Utf8; import org.apache.commons.lang3.StringUtils; import org.apache.kafka.clients.consumer.ConsumerRecord; import org.apache.kafka.clients.producer.ProducerRecord; import org.apache.kafka.common.header.internals.RecordHeaders; +import java.io.Serializable; import java.util.*; import java.util.concurrent.ConcurrentHashMap; import java.util.function.Consumer; @@ -46,7 +50,11 @@ public void sampleOneSchema(String table, TapTable sampleTable) { GenericRecord genericRecord = (GenericRecord) record.value(); List primaryKeys = new ArrayList<>(); if (record.key() != null) { - primaryKeys.addAll(((Map) TapSimplify.fromJson(record.key())).keySet()); + try { + primaryKeys.addAll(((Map) TapSimplify.fromJson(record.key())).keySet()); + } catch (Exception e) { + tapLogger.warn("Failed to parse primary keys: {}", record.key(), e); + } } genericRecordToTapTable(sampleTable, genericRecord, primaryKeys); return false; @@ -73,7 +81,7 @@ private void genericRecordToTapTable(TapTable sampleTable, GenericRecord generic } else { field.setDataType(toTapType(key.schema().getType().name())); } - field.setDefaultValue(key.defaultVal()); + field.setDefaultValue(getDefaultValue(key.defaultVal())); field.setNullable(key.schema().isNullable()); if (primaryKeys.contains(key.name())) { field.setPrimaryKey(true); @@ -83,6 +91,19 @@ private void genericRecordToTapTable(TapTable sampleTable, GenericRecord generic }); } + private Object getDefaultValue(Object obj) { + if (obj == null) { + return null; + } + if (obj instanceof JsonProperties.Null) { + return null; + } + if (!(obj instanceof Serializable)) { + return String.valueOf(obj); + } + return obj; + } + @Override public TapEvent toTapEvent(ConsumerRecord consumerRecord) { if (consumerRecord == null || consumerRecord.value() == null) { @@ -145,7 +166,13 @@ private Map convertGericRecordToMap(GenericRecord record) { return result; } record.getSchema().getFields().forEach(key -> { - result.put(key.name(), record.get(key.name())); + String k = key.name(); + Object v = record.get(k); + if (v instanceof Utf8) { + result.put(k, v.toString()); + } else { + result.put(k, v); + } }); return result; } @@ -182,7 +209,11 @@ public List> fromTapEvent(TapTable tapTable, TapE } // 根据列的类型映射为 Avro 模式的类型 Schema.Field field = getOrCreateAvroField(tapTable, tapField); - fieldAssembler.name(columnName).type(field.schema()).withDefault(tapField.getDefaultValue()); + if (EmptyKit.isNotNull(tapField.getDefaultValue()) && applyDefault) { + fieldAssembler.name(columnName).type(field.schema()).withDefault(tapField.getDefaultValue()); + } else { + fieldAssembler.name(columnName).type(field.schema()).noDefault(); + } } Schema.Parser parser = new Schema.Parser(); Schema avroSchema = parser.parse(fieldAssembler.endRecord().toString()); @@ -211,7 +242,7 @@ public List> fromTapEvent(TapTable tapTable, TapE String keyValue = createKafkaKeyValueMap(data, tapTable); // 创建 ProducerRecord - ProducerRecord producerRecord = new ProducerRecord<>(topic(tapTable, tapEvent), null, + ProducerRecord producerRecord = new ProducerRecord<>(topic(tapTable, tapEvent), computePartition(createKafkaKey(data, tapTable), kafkaService.getConfig().getNodePartitionSize()), tapEvent.getTime(), keyValue, record, new RecordHeaders().add("op", op.name().getBytes())); return List.of(producerRecord); } @@ -343,7 +374,12 @@ private Schema.Field getOrCreateAvroField(TapTable tapTable, TapField tapField) avroType = baseType; } - Schema.Field field = new Schema.Field(columnName, avroType, null, tapField.getDefaultValue()); + Schema.Field field; + if (applyDefault) { + field = new Schema.Field(columnName, avroType, null, tapField.getDefaultValue()); + } else { + field = new Schema.Field(columnName, avroType, null, null); + } fieldCache.put(tapTable.getId() + "." + columnName, field); return field; } diff --git a/connectors/kafka-enhanced-connector/src/main/java/io/tapdata/kafka/schema_mode/RegistryJsonMode.java b/connectors/kafka-enhanced-connector/src/main/java/io/tapdata/kafka/schema_mode/RegistryJsonMode.java index e9cfc4127..153f8b7a6 100644 --- a/connectors/kafka-enhanced-connector/src/main/java/io/tapdata/kafka/schema_mode/RegistryJsonMode.java +++ b/connectors/kafka-enhanced-connector/src/main/java/io/tapdata/kafka/schema_mode/RegistryJsonMode.java @@ -52,8 +52,11 @@ public void sampleOneSchema(String table, TapTable sampleTable) { // 从数据推断 schema List primaryKeys = new ArrayList<>(); if (record.key() != null) { - Map keyMap = (Map) TapSimplify.fromJson(record.key()); - primaryKeys.addAll(keyMap.keySet()); + try { + primaryKeys.addAll(((Map) TapSimplify.fromJson(record.key())).keySet()); + } catch (Exception e) { + tapLogger.warn("Failed to parse primary keys: {}", record.key(), e); + } } // 构建 TapTable schema @@ -156,7 +159,7 @@ public List> fromTapEvent(TapTable tapTable, TapE // 创建 ProducerRecord ProducerRecord producerRecord = new ProducerRecord<>( topic(tapTable, tapEvent), - null, + computePartition(createKafkaKey(data, tapTable), kafkaService.getConfig().getNodePartitionSize()), tapEvent.getTime(), keyValue, valueNode, // 使用 JsonNode 而不是硬编码的对象 @@ -199,36 +202,4 @@ private Map convertToMap(Object value) { return new HashMap<>(); } } - - /** - * 根据值推断 TapData 类型 - */ - private String inferTapType(Object value) { - if (value == null) { - return "STRING"; - } - - if (value instanceof Boolean) { - return "BOOLEAN"; - } else if (value instanceof Integer || value instanceof Short || value instanceof Byte) { - return "INTEGER"; - } else if (value instanceof Long) { - return "BIGINT"; - } else if (value instanceof Float) { - return "FLOAT"; - } else if (value instanceof Double) { - return "DOUBLE"; - } else if (value instanceof BigDecimal) { - return "DOUBLE"; - } else if (value instanceof String) { - return "STRING"; - } else if (value instanceof List) { - return "ARRAY"; - } else if (value instanceof Map) { - return "MAP"; - } else { - return "STRING"; - } - } - } diff --git a/connectors/kafka-enhanced-connector/src/main/java/io/tapdata/kafka/schema_mode/RegistryProtobufMode.java b/connectors/kafka-enhanced-connector/src/main/java/io/tapdata/kafka/schema_mode/RegistryProtobufMode.java index c7b7f7d76..643e6f427 100644 --- a/connectors/kafka-enhanced-connector/src/main/java/io/tapdata/kafka/schema_mode/RegistryProtobufMode.java +++ b/connectors/kafka-enhanced-connector/src/main/java/io/tapdata/kafka/schema_mode/RegistryProtobufMode.java @@ -15,6 +15,7 @@ import io.tapdata.kafka.AbsSchemaMode; import io.tapdata.kafka.IKafkaService; import io.tapdata.kafka.constants.KafkaSchemaMode; +import io.tapdata.kit.EmptyKit; import io.tapdata.kit.StringKit; import io.tapdata.pdk.apis.entity.FilterResults; import io.tapdata.pdk.apis.entity.TapAdvanceFilter; @@ -184,7 +185,7 @@ public List> fromTapEvent(TapTable tapTable, TapE String keyValue = createKafkaKeyValueMap(data, tapTable); // 创建 ProducerRecord - ProducerRecord producerRecord = new ProducerRecord<>(topic(tapTable, tapEvent), null, + ProducerRecord producerRecord = new ProducerRecord<>(topic(tapTable, tapEvent), computePartition(createKafkaKey(data, tapTable), kafkaService.getConfig().getNodePartitionSize()), tapEvent.getTime(), keyValue, message, new RecordHeaders().add("op", op.name().getBytes())); return Collections.singletonList(producerRecord); @@ -233,7 +234,7 @@ private Descriptors.Descriptor buildProtobufDescriptor(TapTable tapTable) throws .setNumber(fieldNumber++) .setLabel(DescriptorProtos.FieldDescriptorProto.Label.LABEL_OPTIONAL) .setType(mapTapTypeToProtobufType(StringKit.removeParentheses(dataType))); - if (null != tapField.getDefaultValue()) { + if (EmptyKit.isNotNull(tapField.getDefaultValue()) && applyDefault) { fieldBuilder.setDefaultValue(String.valueOf(tapField.getDefaultValue())); } messageBuilder.addField(fieldBuilder.build()); diff --git a/connectors/kafka-enhanced-connector/src/main/java/io/tapdata/kafka/schema_mode/StandardSchemaMode.java b/connectors/kafka-enhanced-connector/src/main/java/io/tapdata/kafka/schema_mode/StandardSchemaMode.java index 614eee1cd..0776d0d45 100644 --- a/connectors/kafka-enhanced-connector/src/main/java/io/tapdata/kafka/schema_mode/StandardSchemaMode.java +++ b/connectors/kafka-enhanced-connector/src/main/java/io/tapdata/kafka/schema_mode/StandardSchemaMode.java @@ -1,17 +1,14 @@ package io.tapdata.kafka.schema_mode; -import io.tapdata.connector.utils.ConcurrentUtils; import io.tapdata.entity.event.TapEvent; import io.tapdata.entity.event.dml.TapDeleteRecordEvent; import io.tapdata.entity.event.dml.TapInsertRecordEvent; import io.tapdata.entity.event.dml.TapUpdateRecordEvent; import io.tapdata.entity.schema.TapTable; -import io.tapdata.kafka.KafkaEnhancedConnector; -import io.tapdata.kafka.constants.KafkaSchemaMode; import io.tapdata.kafka.AbsSchemaMode; import io.tapdata.kafka.IKafkaService; +import io.tapdata.kafka.constants.KafkaSchemaMode; import io.tapdata.kafka.utils.KafkaUtils; -import io.tapdata.kit.EmptyKit; import io.tapdata.pdk.apis.entity.FilterResults; import io.tapdata.pdk.apis.entity.TapAdvanceFilter; import io.tapdata.pdk.apis.exception.NotSupportedException; @@ -21,10 +18,8 @@ import org.apache.kafka.common.header.internals.RecordHeaders; import java.util.*; -import java.util.concurrent.ConcurrentLinkedQueue; import java.util.concurrent.atomic.AtomicInteger; import java.util.function.Consumer; -import java.util.stream.Collectors; /** * Kafka 标准结构模式服务接口 @@ -84,7 +79,7 @@ public List> fromTapEvent(TapTable table, TapEven } else { throw new NotSupportedException(String.format("TapEvent type '%s'", tapEvent.getClass().getName())); } - return Arrays.asList(new ProducerRecord<>(topic, null, ts, createKafkaKey(data, table), tapEvent, headers)); + return Arrays.asList(new ProducerRecord<>(topic, computePartition(createKafkaKey(data, table), kafkaService.getConfig().getNodePartitionSize()), ts, createKafkaKey(data, table), tapEvent, headers)); } @Override diff --git a/connectors/kafka-enhanced-connector/src/main/java/io/tapdata/kafka/service/KafkaAdminService.java b/connectors/kafka-enhanced-connector/src/main/java/io/tapdata/kafka/service/KafkaAdminService.java index 8cd15b331..9c048de23 100644 --- a/connectors/kafka-enhanced-connector/src/main/java/io/tapdata/kafka/service/KafkaAdminService.java +++ b/connectors/kafka-enhanced-connector/src/main/java/io/tapdata/kafka/service/KafkaAdminService.java @@ -1,11 +1,9 @@ package io.tapdata.kafka.service; import io.confluent.kafka.schemaregistry.client.CachedSchemaRegistryClient; -import io.tapdata.constant.MqTestItem; import io.tapdata.entity.logger.Log; import io.tapdata.kafka.IKafkaAdminService; import io.tapdata.kafka.KafkaConfig; -import io.tapdata.pdk.apis.entity.TestItem; import org.apache.kafka.clients.admin.*; import org.apache.kafka.common.KafkaFuture; import org.apache.kafka.common.TopicPartition; @@ -168,37 +166,30 @@ public Collection getTopicPartitions(Collection topics) return partitionInfos; } - public void testRegistryConnect(TestItem testItem) { - testItem.setItem(MqTestItem.KAFKA_SCHEMA_REGISTER_CONNECTION.getContent()); - try { - Map schemaRegistryConfigs = new HashMap<>(); - if (config.getConnectionBasicAuth()) { - String authCredentialsSource = config.getConnectionAuthCredentialsSource(); - schemaRegistryConfigs.put("basic.auth.credentials.source", authCredentialsSource); - if ("USER_INFO".equals(authCredentialsSource)) { - schemaRegistryConfigs.put("basic.auth.user.info", config.getConnectionAuthUserName() + ":" + config.getConnectionAuthPassword()); - } else if ("SASL_INHERIT".equals(authCredentialsSource)) { - schemaRegistryConfigs.put("sasl.jaas.config", String.format("org.apache.kafka.common.security.plain.PlainLoginModule required username=\"%s\" password=\"%s\";", config.getConnectionAuthUserName(), config.getConnectionAuthPassword())); - } + public boolean testRegistryConnect() throws Exception { + Map schemaRegistryConfigs = new HashMap<>(); + if (config.getConnectionBasicAuth()) { + String authCredentialsSource = config.getConnectionAuthCredentialsSource(); + schemaRegistryConfigs.put("basic.auth.credentials.source", authCredentialsSource); + if ("USER_INFO".equals(authCredentialsSource)) { + schemaRegistryConfigs.put("basic.auth.user.info", config.getConnectionAuthUserName() + ":" + config.getConnectionAuthPassword()); + } else if ("SASL_INHERIT".equals(authCredentialsSource)) { + schemaRegistryConfigs.put("sasl.jaas.config", String.format("org.apache.kafka.common.security.plain.PlainLoginModule required username=\"%s\" password=\"%s\";", config.getConnectionAuthUserName(), config.getConnectionAuthPassword())); } - config.getConnectionExtParams().forEach(param -> { - if (param.get("key").startsWith("schema.registry.")) { - schemaRegistryConfigs.put(param.get("key"), param.get("val")); - } - }); - // SSL/TLS Truststore configuration for HTTPS connection + } + config.getConnectionExtParams().forEach(param -> { + if (param.get("key").startsWith("schema.registry.")) { + schemaRegistryConfigs.put(param.get("key"), param.get("val")); + } + }); + // SSL/TLS Truststore configuration for HTTPS connection // schemaRegistryConfigs.put("schema.registry.ssl.truststore.location", "SCHEMA_REGISTRY_SSL_TRUSTSTORE_LOCATION"); // schemaRegistryConfigs.put("schema.registry.ssl.truststore.password", "SCHEMA_REGISTRY_SSL_TRUSTSTORE_PASSWORD"); // schemaRegistryConfigs.put("schema.registry.ssl.keystore.location", "SCHEMA_REGISTRY_SSL_KEYSTORE_LOCATION"); // schemaRegistryConfigs.put("schema.registry.ssl.keystore.password", "SCHEMA_REGISTRY_SSL_KEYSTORE_PASSWORD"); - CachedSchemaRegistryClient schemaRegistryClient = new CachedSchemaRegistryClient(config.getConnectionSchemaRegisterUrl(), 100, schemaRegistryConfigs); - schemaRegistryClient.getAllSubjects(); - testItem.setResult(TestItem.RESULT_SUCCESSFULLY); - testItem.setInformation("Schema register connection successfully"); - } catch (Exception e) { - testItem.setResult(TestItem.RESULT_FAILED); - testItem.setInformation("Please check the service address. " + e.getMessage()); - } + CachedSchemaRegistryClient schemaRegistryClient = new CachedSchemaRegistryClient(config.getConnectionSchemaRegisterUrl(), 100, schemaRegistryConfigs); + schemaRegistryClient.getAllSubjects(); + return true; } @Override diff --git a/connectors/kafka-enhanced-connector/src/main/java/io/tapdata/kafka/service/KafkaService.java b/connectors/kafka-enhanced-connector/src/main/java/io/tapdata/kafka/service/KafkaService.java index 9453361a0..826bea675 100644 --- a/connectors/kafka-enhanced-connector/src/main/java/io/tapdata/kafka/service/KafkaService.java +++ b/connectors/kafka-enhanced-connector/src/main/java/io/tapdata/kafka/service/KafkaService.java @@ -2,8 +2,8 @@ import io.tapdata.connector.error.KafkaErrorCodes; import io.tapdata.connector.utils.AsyncBatchPusher; -import io.tapdata.connector.utils.ErrorHelper; import io.tapdata.connector.utils.ConcurrentUtils; +import io.tapdata.connector.utils.ErrorHelper; import io.tapdata.entity.event.TapBaseEvent; import io.tapdata.entity.event.TapEvent; import io.tapdata.entity.event.ddl.table.TapCreateTableEvent; @@ -36,11 +36,14 @@ import org.apache.kafka.clients.consumer.ConsumerRecords; import org.apache.kafka.clients.consumer.KafkaConsumer; import org.apache.kafka.clients.producer.KafkaProducer; -import org.apache.kafka.clients.producer.ProducerRecord; import org.apache.kafka.common.TopicPartition; import org.apache.kafka.common.TopicPartitionInfo; import org.apache.kafka.common.errors.SerializationException; +import java.net.HttpURLConnection; +import java.net.URL; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; import java.time.Duration; import java.util.*; import java.util.concurrent.*; @@ -345,21 +348,25 @@ public void streamRead(List tables, Object offset, int batchSize, BiCons public void sampleValue(List tables, Object offset, Predicate> callback) { boolean isEarliest = true; long batchMaxDelay = config.getNodeBatchMaxDelay(); - Duration timeout = Duration.ofMillis(batchMaxDelay); + Duration timeout = Duration.ofMillis(Math.max(batchMaxDelay, 3000)); try (KafkaConsumer kafkaConsumer = new KafkaConsumer<>(config.buildDiscoverSchemaConfig(isEarliest))) { - // 设置断点信息 Collection topicPartitions = getAdminService().getTopicPartitions(tables); + if (null == topicPartitions || topicPartitions.isEmpty()) return; kafkaConsumer.assign(topicPartitions); kafkaConsumer.seekToBeginning(topicPartitions); - ConsumerRecords consumerRecords = kafkaConsumer.poll(timeout); - if (null == consumerRecords || consumerRecords.isEmpty()) return; - - for (ConsumerRecord consumerRecord : consumerRecords) { - if (!callback.test(consumerRecord)) { - return; + long deadline = System.currentTimeMillis() + timeout.toMillis(); + Duration pollOnce = Duration.ofMillis(1000); + while (System.currentTimeMillis() < deadline) { + ConsumerRecords consumerRecords = kafkaConsumer.poll(pollOnce); + if (null == consumerRecords || consumerRecords.isEmpty()) continue; + for (ConsumerRecord consumerRecord : consumerRecords) { + if (!callback.test(consumerRecord)) { + return; + } } + return; } } catch (InterruptedException | org.apache.kafka.common.errors.InterruptException e) { Thread.currentThread().interrupt(); @@ -372,27 +379,46 @@ public void sampleValue(List tables, Object offset, Predicate wrapProduceRecord(TapTable table, TapRecordEvent recordEvent, Collection primaryKey) { + List producerRecords = new ArrayList<>(); + if (config.getSplitUpdatePk() && recordEvent instanceof TapUpdateRecordEvent updateRecordEvent) { + Map after = updateRecordEvent.getAfter(); + Map before = updateRecordEvent.getBefore(); + if (!primaryKey.isEmpty() && primaryKey.stream().anyMatch(v -> !Objects.equals(after.get(v), before.get(v)))) { + TapDeleteRecordEvent deleteRecordEvent = new TapDeleteRecordEvent(); + updateRecordEvent.clone(deleteRecordEvent); + schemaModeService.fromTapEvent(table, deleteRecordEvent).forEach(record -> { + producerRecords.add(new ProducerRecordWrapper(deleteRecordEvent, record)); + }); + TapInsertRecordEvent insertRecordEvent = new TapInsertRecordEvent(); + updateRecordEvent.clone(insertRecordEvent); + schemaModeService.fromTapEvent(table, insertRecordEvent).forEach(record -> { + producerRecords.add(new ProducerRecordWrapper(insertRecordEvent, record)); + }); + return producerRecords; + } + } + schemaModeService.fromTapEvent(table, recordEvent).forEach(record -> { + producerRecords.add(new ProducerRecordWrapper(recordEvent, record)); + }); + return producerRecords; + } + @Override - public void writeRecord(List recordEvents, TapTable table, Consumer> consumer) { + public void writeRecord(KafkaProducer producer, List recordEvents, TapTable table, Consumer> consumer) { AtomicLong insert = new AtomicLong(0); AtomicLong update = new AtomicLong(0); AtomicLong delete = new AtomicLong(0); WriteListResult listResult = new WriteListResult<>(); - + Collection primaryKey = table.primaryKeys(true); List producerRecords = new ArrayList<>(); for (TapRecordEvent recordEvent : recordEvents) { - List> fromTapEvents = schemaModeService.fromTapEvent(table, recordEvent); - if (null != fromTapEvents && !fromTapEvents.isEmpty()) { - for (ProducerRecord fromTapEvent : fromTapEvents) { - producerRecords.add(new ProducerRecordWrapper(recordEvent, fromTapEvent)); - } - } + producerRecords.addAll(wrapProduceRecord(table, recordEvent, primaryKey)); } - CountDownLatch latch = new CountDownLatch(producerRecords.size()); AtomicReference sendEx = new AtomicReference<>(); for (ProducerRecordWrapper producerRecord : producerRecords) { - getProducer().send(producerRecord.getProducerRecord(), (metadata, exception) -> { + producer.send(producerRecord.getProducerRecord(), (metadata, exception) -> { try { TapRecordEvent recordEvent = producerRecord.getRecordEvent(); if (exception != null) { @@ -430,6 +456,11 @@ public void writeRecord(List recordEvents, TapTable table, Consu } } + @Override + public void writeRecord(List recordEvents, TapTable table, Consumer> consumer) { + writeRecord(getProducer(), recordEvents, table, consumer); + } + @Override public CreateTableOptions createTable(TapCreateTableEvent tapCreateTableEvent) { CreateTableOptions createTableOptions = new CreateTableOptions(); @@ -471,6 +502,9 @@ public void deleteTable(TapDropTableEvent tapDropTableEvent) { try { logger.info("Deleting topic '{}'...", topic); getAdminService().dropTopics(Collections.singleton(topic)); + if (config.getConnectionSchemaRegister()) { + deleteTableSchemaRegistryInfo(topic); + } } catch (InterruptedException e) { Thread.currentThread().interrupt(); } catch (Exception e) { @@ -541,4 +575,74 @@ private BiConsumer, Object> getFieldTypeConverterConsumer(BiConsu }; } + private void deleteTableSchemaRegistryInfo(String topic) { + List registryUrls = new ArrayList<>(); + for (String registryUrl : config.getConnectionSchemaRegisterUrl().split(",")) { + if (registryUrl != null) { + registryUrl = registryUrl.trim(); + if (!registryUrl.isEmpty()) { + registryUrls.add(registryUrl); + } + } + } + if (registryUrls.isEmpty()) { + logger.warn("Schema Registry is enabled but no registry url configured, skip deleting subjects for topic '{}'", topic); + return; + } + + for (String subject : Arrays.asList(topic + "-key", topic + "-value")) { + boolean handled = false; + for (String registryUrl : registryUrls) { + try { + int code = deleteSchemaRegistrySubject(registryUrl, subject, false); + if (code == HttpURLConnection.HTTP_NOT_FOUND) { + handled = true; + break; + } + if (code >= 200 && code < 300) { + int permanentCode = deleteSchemaRegistrySubject(registryUrl, subject, true); + if (permanentCode != HttpURLConnection.HTTP_NOT_FOUND && (permanentCode < 200 || permanentCode >= 300)) { + logger.warn("Delete schema registry subject '{}' permanently failed, url: {}, code: {}", subject, registryUrl, permanentCode); + } + handled = true; + logger.info("Deleted schema registry subject '{}' for topic '{}'", subject, topic); + break; + } + logger.warn("Delete schema registry subject '{}' failed, url: {}, code: {}", subject, registryUrl, code); + } catch (Exception e) { + logger.warn("Delete schema registry subject '{}' failed, url: {}, error: {}", subject, registryUrl, e.getMessage()); + } + } + if (!handled) { + logger.warn("Failed to delete schema registry subject '{}' for topic '{}' from all configured registry endpoints", subject, topic); + } + } + } + + private int deleteSchemaRegistrySubject(String registryUrl, String subject, boolean permanent) throws Exception { + String baseUrl = registryUrl.endsWith("/") ? registryUrl.substring(0, registryUrl.length() - 1) : registryUrl; + String url = baseUrl + "/subjects/" + URLEncoder.encode(subject, StandardCharsets.UTF_8.name()); + if (permanent) { + url += "?permanent=true"; + } + HttpURLConnection connection = null; + try { + connection = (HttpURLConnection) new URL(url).openConnection(); + connection.setRequestMethod("DELETE"); + connection.setConnectTimeout(5000); + connection.setReadTimeout(10000); + connection.setDoInput(true); + if (config.getConnectionBasicAuth()) { + String auth = config.getConnectionAuthUserName() + ":" + config.getConnectionAuthPassword(); + String encoded = Base64.getEncoder().encodeToString(auth.getBytes(StandardCharsets.UTF_8)); + connection.setRequestProperty("Authorization", "Basic " + encoded); + } + return connection.getResponseCode(); + } finally { + if (connection != null) { + connection.disconnect(); + } + } + } + } diff --git a/connectors/kafka-enhanced-connector/src/main/java/io/tapdata/kafka/tester/KafkaCommonStep.java b/connectors/kafka-enhanced-connector/src/main/java/io/tapdata/kafka/tester/KafkaCommonStep.java deleted file mode 100644 index c5c8a4e56..000000000 --- a/connectors/kafka-enhanced-connector/src/main/java/io/tapdata/kafka/tester/KafkaCommonStep.java +++ /dev/null @@ -1,97 +0,0 @@ -package io.tapdata.kafka.tester; - -import io.tapdata.connector.tester.AbsStep; -import io.tapdata.connector.tester.ICommonStep; -import io.tapdata.connector.tester.IStep; -import io.tapdata.kafka.KafkaConfig; -import io.tapdata.kafka.KafkaTester; -import io.tapdata.kafka.service.KafkaAdminService; -import io.tapdata.kit.StringKit; -import io.tapdata.pdk.apis.entity.TestItem; -import io.tapdata.pdk.apis.exception.testItem.TapTestAuthEx; - -import java.util.ArrayList; - -/** - * Kafka-基础测试 - * - * @author Harsen - * @version v1.0 2024/8/29 11:45 Create - */ -public class KafkaCommonStep extends AbsStep implements ICommonStep { - private KafkaAdminService adminService; - - public KafkaCommonStep(KafkaTester tester) { - super(tester); - } - - protected synchronized KafkaAdminService getAdminService() { - if (null == adminService) { - KafkaConfig config = tester.getConfig(); - this.adminService = new KafkaAdminService(config, config.tapConnectionContext().getLog()); - } - return adminService; - } - - @Override - public boolean testVersion(TestItem testItem) { - return IStep.CHECK_ITEM_SKIPPED; // Kafka 获取不到版本,不显示此测试项 - } - - @Override - public boolean testConnection(TestItem testItem) { - tester.getConfig().testClusterURI(testItem, itemConsumer(), options()); - if (TestItem.RESULT_FAILED != testItem.getResult()) { - try { - if (getAdminService().isClusterConnectable()) { - testItem.setResult(TestItem.RESULT_SUCCESSFULLY); - testItem.setInformation(null); - } else { - testItem.setResult(TestItem.RESULT_FAILED); - testItem.setInformation("cluster is not connectable"); - } - } catch (Exception e) { - testItem.setResult(TestItem.RESULT_FAILED); - testItem.setTapTestItemException(new TapTestAuthEx(String.format("cluster is not connectable: %s", e.getMessage()), e)); - if (e instanceof InterruptedException) { - Thread.currentThread().interrupt(); - } - } - } - return IStep.CHECK_ITEM_APPLY; - } - - public boolean testRegistryConnection(TestItem testItem) { - if (!tester.getConfig().getConnectionSchemaRegister()) { - return IStep.CHECK_ITEM_SKIPPED; - } - getAdminService().testRegistryConnect(testItem); - return CHECK_ITEM_APPLY; - } - - @Override - public boolean testInstanceUniqueId(TestItem testItem) { - options().setInstanceUniqueId(StringKit.md5(String.join("|" - , config().getConnectionClusterURI() - , config().getConnectionSchemaMode().name() - , config().getConnectionKeySerialization().name() - , config().getConnectionValueSerialization().name() - ))); - options().setNamespaces(new ArrayList<>()); - testItem.setResult(TestItem.RESULT_SUCCESSFULLY); - testItem.setInformation(options().getInstanceUniqueId()); - return IStep.CHECK_ITEM_APPLY; - } - - @Override - public boolean testLogin(TestItem testItem) { - return IStep.CHECK_ITEM_SKIPPED; - } - - @Override - public void close() throws Exception { - if (null != adminService) { - adminService.close(); - } - } -} diff --git a/connectors/kafka-enhanced-connector/src/main/java/io/tapdata/kafka/tester/KafkaSourceStep.java b/connectors/kafka-enhanced-connector/src/main/java/io/tapdata/kafka/tester/KafkaSourceStep.java deleted file mode 100644 index 163f698b3..000000000 --- a/connectors/kafka-enhanced-connector/src/main/java/io/tapdata/kafka/tester/KafkaSourceStep.java +++ /dev/null @@ -1,36 +0,0 @@ -package io.tapdata.kafka.tester; - -import io.tapdata.connector.tester.AbsStep; -import io.tapdata.connector.tester.ISourceStep; -import io.tapdata.connector.tester.IStep; -import io.tapdata.kafka.KafkaConfig; -import io.tapdata.kafka.KafkaTester; -import io.tapdata.pdk.apis.entity.TestItem; - -/** - * Kafka-源测试 - * - * @author Harsen - * @version v1.0 2024/8/29 11:48 Create - */ -public class KafkaSourceStep extends AbsStep implements ISourceStep { - - public KafkaSourceStep(KafkaTester tester) { - super(tester); - } - - @Override - public boolean testReadLog(TestItem item) { - return IStep.CHECK_ITEM_SKIPPED; - } - - @Override - public boolean testRead(TestItem item) { - return IStep.CHECK_ITEM_SKIPPED; - } - - @Override - public void close() throws Exception { - - } -} diff --git a/connectors/kafka-enhanced-connector/src/main/java/io/tapdata/kafka/tester/KafkaTargetStep.java b/connectors/kafka-enhanced-connector/src/main/java/io/tapdata/kafka/tester/KafkaTargetStep.java deleted file mode 100644 index ddac142d2..000000000 --- a/connectors/kafka-enhanced-connector/src/main/java/io/tapdata/kafka/tester/KafkaTargetStep.java +++ /dev/null @@ -1,31 +0,0 @@ -package io.tapdata.kafka.tester; - -import io.tapdata.connector.tester.AbsStep; -import io.tapdata.connector.tester.IStep; -import io.tapdata.connector.tester.ITargetStep; -import io.tapdata.kafka.KafkaConfig; -import io.tapdata.kafka.KafkaTester; -import io.tapdata.pdk.apis.entity.TestItem; - -/** - * Kafka-目标测试 - * - * @author Harsen - * @version v1.0 2024/8/29 11:50 Create - */ -public class KafkaTargetStep extends AbsStep implements ITargetStep { - - public KafkaTargetStep(KafkaTester tester) { - super(tester); - } - - @Override - public boolean testWrite(TestItem item) { - return IStep.CHECK_ITEM_SKIPPED; - } - - @Override - public void close() throws Exception { - - } -} diff --git a/connectors/kafka-enhanced-connector/src/main/java/io/tapdata/kafka/utils/Krb5Util.java b/connectors/kafka-enhanced-connector/src/main/java/io/tapdata/kafka/utils/Krb5Util.java new file mode 100644 index 000000000..8ff686c0e --- /dev/null +++ b/connectors/kafka-enhanced-connector/src/main/java/io/tapdata/kafka/utils/Krb5Util.java @@ -0,0 +1,289 @@ +package io.tapdata.kafka.utils; + +import io.tapdata.entity.logger.TapLogger; +import org.apache.commons.lang3.StringUtils; + +import java.io.*; +import java.lang.reflect.Method; +import java.net.InetAddress; +import java.net.UnknownHostException; +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.HashMap; +import java.util.Map; +import java.util.Properties; +import java.util.regex.Pattern; + +/** + * Kerberos 工具类 + * + * @author Harsen + * @version v1.0 2021/11/17 下午5:04 Create + */ +public class Krb5Util { + + public static final String TAG = Krb5Util.class.getSimpleName(); + + private static String decodeConf(String base64Conf) { + byte[] bytes = Base64.getUrlDecoder().decode(base64Conf); + return new String(bytes, StandardCharsets.UTF_8); + } + + private static String storeDir() { + String dir = System.getenv("TAPDATA_WORK_DIR"); + if (null == dir) { + dir = "."; + } + return paths(dir, "krb5"); + } + + private static String paths(String... paths) { + return String.join(File.separator, paths); + } + + /** + * 保存文件 + * + * @param data 数据 + * @param savePath 保存路径 + * @param deleteExists 是否存在删除 + * @throws IOException 异常 + */ + private static void save(byte[] data, String savePath, boolean deleteExists) throws IOException { + File file = new File(savePath); + if (file.exists()) { + if (!deleteExists) { + throw new RuntimeException("Save config is exists: " + savePath); + } + file.delete(); + } else { + File dir = file.getParentFile(); + if (!dir.exists()) { + dir.mkdirs(); + } + } + try (FileOutputStream fos = new FileOutputStream(file)) { + fos.write(data); + } + } + + private static Map> getRealms(String krb5Conf) { + try { + String[] kv; + boolean inRealms = false, begin = true; + Map kvMap = null; + Map> map = new HashMap<>(); + for (String line : krb5Conf.split("\n")) { + if (line.startsWith("#")) continue; + line = line.replaceAll("#.*", "").trim(); + if (line.isEmpty()) continue; + + if (!inRealms) { + if (Pattern.matches("^[\\[]*\\[realms].*", line)) { + inRealms = true; + } + continue; + } else if (line.startsWith("[")) { + break; + } + + if (begin) { + kvMap = new HashMap<>(); + map.put(line.replaceAll("([^\\s]+).*", "$1"), kvMap); + begin = false; + continue; + } else if (line.contains("}")) { + begin = true; + continue; + } + kv = line.split("="); + if (kv.length != 2) continue; + kvMap.put(kv[0].trim(), kv[1].trim()); + } + return map; + } catch (Exception e) { + throw new RuntimeException("Parse [realms] failed: " + e.getMessage(), e); + } + } + + /** + * 获取配置 + * + * @param krb5Path 授权路径 + * @param krb5Principal 主体 + * @return 配置 + */ + private static String saslJaasConfig(String krb5Path, String krb5Principal) { + return "com.sun.security.auth.module.Krb5LoginModule required\n" + + " useKeyTab=true\n" + + " storeKey=true\n" + + " useTicketCache=true\n" + + " keyTab=\"" + keytabPath(krb5Path) + "\"\n" + + " principal=\"" + krb5Principal + "\";"; + } + + /** + * 获取密钥路径 + * + * @param dir 配置目录 + * @return 密钥路径 + */ + public static String keytabPath(String dir) { + return paths(dir, "krb5.keytab"); + } + + /** + * 获取配置路径 + * + * @param dir 配置目录 + * @return 配置路径 + */ + public static String confPath(String dir) { + return paths(dir, "krb5.conf"); + } + + /** + * 检查配置域 + * + * @param conf 配置 + * @throws UnknownHostException 异常 + */ + public static void checkKDCDomains(String conf) throws UnknownHostException { + Map> realms = getRealms(conf); + for (Map realm : realms.values()) { + for (Map.Entry en : realm.entrySet()) { + if (StringUtils.containsAny(en.getKey(), "kdc", "master_kdc", "admin_server", "default_domain") + && null != en.getValue()) { + String s = en.getValue().split(":")[0].trim(); + if (!s.isEmpty()) { + InetAddress.getAllByName(s); + } + } + } + } + } + + /** + * 检查配置域 + * + * @param base64Conf base64配置 + * @throws UnknownHostException 异常 + */ + public static void checkKDCDomainsBase64(String base64Conf) throws UnknownHostException { + String conf = decodeConf(base64Conf); + checkKDCDomains(conf); + } + + /** + * 根据类别保存 + * + * @param catalog 类别 + * @param keytab 密钥 + * @param conf 配置 + * @param deleteExists 存在删除 + * @return 配置路径 + */ + public static String saveByCatalog(String catalog, String keytab, String conf, boolean deleteExists) { + byte[] bytes; + String savePath = null; + String dir = paths(storeDir(), catalog); + try { + savePath = keytabPath(dir); + bytes = Base64.getDecoder().decode(keytab); + save(bytes, savePath, deleteExists); + } catch (Exception e) { + throw new RuntimeException("Save kerberos keytab failed: " + savePath, e); + } + try { + savePath = confPath(dir); + bytes = Base64.getDecoder().decode(conf); + save(bytes, savePath, deleteExists); + } catch (Exception e) { + throw new RuntimeException("Save kerberos conf failed: " + savePath, e); + } + return dir; + } + + /** + * 刷新 Kerberos 配置(使用反射访问 sun.security.krb5.Config) + * 兼容 JDK 9+ 模块化系统 + */ + private static void refreshKrb5Config() { + try { + // 使用反射访问 sun.security.krb5.Config.refresh() + // 这是为了兼容 JDK 9+ 的模块化系统 + Class configClass = Class.forName("sun.security.krb5.Config"); + Method refreshMethod = configClass.getMethod("refresh"); + refreshMethod.invoke(null); + TapLogger.debug(TAG, "Kerberos config refreshed successfully"); + } catch (ClassNotFoundException e) { + // JDK 中没有 sun.security.krb5.Config 类,可能是非标准 JDK + TapLogger.warn(TAG, "sun.security.krb5.Config class not found, skip refresh"); + } catch (NoSuchMethodException e) { + TapLogger.warn(TAG, "sun.security.krb5.Config.refresh() method not found, skip refresh"); + } catch (Exception e) { + // 反射调用失败,记录警告但不抛出异常 + TapLogger.warn(TAG, "Failed to refresh Kerberos config via reflection: " + e.getMessage()); + } + } + + /** + * 更新 Kafka 配置 + * + * @param serviceName 服务名 + * @param principal 主体 + * @param krb5Path 授权配置目录 + * @param krb5Conf 授权配置 + * @param conf kafka配置 + */ + public static void updateKafkaConf(String serviceName, String principal, String krb5Path, String krb5Conf, Properties conf) { + String krb5ConfPath = confPath(krb5Path); + String saslJaasConfig = saslJaasConfig(krb5Path, principal); + + conf.put("security.protocol", "SASL_PLAINTEXT"); + conf.put("sasl.mechanism", "GSSAPI"); + conf.put("sasl.kerberos.service.name", serviceName); + conf.put("sasl.jaas.config", saslJaasConfig); + System.setProperty("java.security.krb5.conf", krb5ConfPath); + + String realm = null; + if (null != principal) { + String[] arr = principal.split("@"); + if (arr.length == 2) realm = arr[1].trim(); + } + if (null == realm || realm.isEmpty()) { + TapLogger.warn(TAG, "Parse krb5 realm failed: " + principal); + return; + } + + krb5Conf = decodeConf(krb5Conf); + Map> realms = getRealms(krb5Conf); + Map currentRealms = realms.get(realm); + if (null != currentRealms && currentRealms.containsKey("kdc")) { + System.setProperty("java.security.krb5.realm", realm); + System.setProperty("java.security.krb5.kdc", currentRealms.get("kdc")); + + // 使用反射刷新配置,避免直接依赖 sun.security.krb5.Config + refreshKrb5Config(); + } else { + TapLogger.warn(TAG, "Not found kdc in realm '{}' >> {}", realm, krb5Conf); + } + } + + + public static void main(String[] args) throws Exception { + String conf; + try (FileInputStream fis = new FileInputStream("/Users/lhs/Downloads/krb5-test.conf")) { + try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) { + int len; + byte[] buf = new byte[1024]; + while (-1 != (len = fis.read(buf, 0, 1024))) { + baos.write(buf, 0, len); + } + conf = baos.toString("UTF-8"); + } + } + System.out.println(conf); + checkKDCDomains(conf); + } +} diff --git a/connectors/kafka-enhanced-connector/src/main/resources/spec_kafka_enhanced.json b/connectors/kafka-enhanced-connector/src/main/resources/spec_kafka_enhanced.json index cf7cfc123..2f5843dff 100644 --- a/connectors/kafka-enhanced-connector/src/main/resources/spec_kafka_enhanced.json +++ b/connectors/kafka-enhanced-connector/src/main/resources/spec_kafka_enhanced.json @@ -1,6 +1,7 @@ { "properties": { "name": "Kafka-Enhanced", + "realName": "Apache Kafka(Confluent Compatible)", "icon": "icons/kafka_enhanced.png", "doc": "${doc}", "id": "kafka_enhanced", @@ -10,6 +11,11 @@ ] }, "configOptions": { + "supportDDL": { + "events": [ + "new_field_event", "alter_field_name_event", "alter_field_attributes_event", "drop_field_event" + ] + }, "pdkExpansion": [], "capabilities": [ { @@ -32,29 +38,176 @@ "x-decorator-props": { "tooltip": "${clusterURITip}" }, + "x-index": 1 + }, + "schemaRegister": { + "type": "boolean", + "title": "${schemaRegister}", + "default": false, + "x-decorator": "FormItem", + "x-decorator-props": { + "tooltip": "${schemaRegisterTip}" + }, + "x-component": "Switch", + "apiServerKey": "schemaRegister", + "x-reactions": [ + { + "target": "*(schemaRegisterUrl,registrySchemaType,basicAuth)", + "fulfill": { + "state": { + "visible": "{{$self.value===true}}" + } + } + }, + { + "target": "*(schemaMode)", + "fulfill": { + "state": { + "visible": "{{$self.value===false}}" + } + } + } + ], "x-index": 2 }, - "schemaMode": { + "schemaRegisterUrl": { + "required": true, "type": "string", - "title": "${schemaMode}", - "default": "TAP_STANDARD", + "title": "${schemaRegisterUrl}", + "x-decorator": "FormItem", + "x-decorator-props": { + "tooltip": "${schemaRegisterUrlTip}" + }, + "x-component": "Input", + "apiServerKey": "schemaRegisterUrl", + "x-index": 3 + }, + "registrySchemaType": { + "type": "string", + "title": "${registrySchemaType}", + "default": "JSON", + "x-decorator": "FormItem", + "x-component": "Select", + "apiServerKey": "registrySchemaType", + "x-index": 4, + "enum": [ + { + "label": "JSON", + "value": "JSON" + }, + { + "label": "AVRO", + "value": "AVRO" + }, + { + "label": "PROTOBUF", + "value": "PROTOBUF" + } + ] + }, + "basicAuth": { + "type": "boolean", + "title": "${basicAuth}", + "default": false, + "x-decorator": "FormItem", + "x-component": "Switch", + "apiServerKey": "basicAuth", + "x-reactions": [ + { + "target": "*(authCredentialsSource,authUserName,authPassword)", + "fulfill": { + "state": { + "visible": "{{$self.value===true}}" + } + } + } + ], + "x-index": 5 + }, + "authCredentialsSource": { + "type": "string", + "title": "${authCredentialsSource}", + "default": "USER_INFO", "x-decorator": "FormItem", "x-component": "Radio.Group", "x-component-props": { "optionType": "button" }, - "x-index": 10, + "x-index": 6, "enum": [ { - "label": "${schemaModeTapStandard}", - "value": "TAP_STANDARD" + "label": "USER_INFO", + "value": "USER_INFO" }, { - "label": "${schemaModeOriginal}", - "value": "ORIGINAL" + "label": "SASL_INHERIT", + "value": "SASL_INHERIT" } ] }, + "authUserName": { + "required": true, + "type": "string", + "title": "${authUserName}", + "x-decorator": "FormItem", + "x-component": "Input", + "apiServerKey": "database_username", + "x-index": 7 + }, + "authPassword": { + "required": true, + "type": "string", + "title": "${authPassword}", + "x-decorator": "FormItem", + "x-component": "Password", + "apiServerKey": "database_password", + "x-index": 8 + }, + "schemaMode": { + "type": "string", + "title": "${schemaMode}", + "x-decorator": "FormItem", + "x-component": "Select", + "default": "TAP_STANDARD", + "enum": [ + {"label": "${schemaModeTapStandard}", "value": "TAP_STANDARD"}, + {"label": "${schemaModeOriginal}", "value": "ORIGINAL"}, + {"label": "${schemaModeCanal}", "value": "CANAL"}, + {"label": "${schemaModeDebezium}", "value": "DEBEZIUM"}, + {"label": "${schemaModeFlinkCDC}", "value": "FLINK_CDC"}, + {"label": "${schemaModeCustom}", "value": "CUSTOM"} + ], + "x-index": 9 + }, + "analyzeScript": { + "type": "string", + "required": false, + "default": "function analyze(header, key, value){\n\n\t\/\/ Enter your code at here\n\t return value;\n}", + "x-decorator": "FormItem", + "x-decorator-props": { + "tooltip": "${analyzeScriptTip}" + }, + "x-component": "JsEditor", + "x-component-props": { + "options": { "showPrintMargin": false, "useWrapMode": true }, + "includeBeforeAndAfter": true, + "before": "function analyze(header, key, value){", + "beforeRegexp": "^[^]*function\\s+analyze\\s*\\(header, key, value\\)\\{", + "afterRegexp": "}[^}]*$", + "after": "}" + }, + "x-index": 15, + "x-reactions": { + "fulfill": { + "state": { + "visible": "{{$deps[0]==='CUSTOM'}}" + } + }, + "dependencies": [ + "schemaMode" + ] + } + }, "serialization": { "type": "void", "x-component": "FormGrid", @@ -248,123 +401,107 @@ } } }, + "krb5": { + "type": "boolean", + "title": "${krb5}", + "default": false, + "x-decorator": "FormItem", + "x-component": "Switch", + "apiServerKey": "krb5", + "x-reactions": [ + { + "target": "*(krb5Keytab,krb5Conf,krb5Principal,krb5ServiceName)", + "fulfill": { + "state": { + "visible": "{{$self.value===true}}" + } + } + }, + { + "target": "*(mqUsername,mqPassword,kafkaSaslMechanism)", + "fulfill": { + "state": { + "visible": "{{$self.value===false}}" + } + } + } + ], + "x-index": 24 + }, + "krb5Keytab": { + "type": "string", + "title": "${krb5Keytab}", + "x-decorator": "FormItem", + "x-component": "TextFileReader", + "x-component-props": { + "base64": true + }, + "x-decorator-props": { + "tooltip": "${krb5KeytabTip}" + }, + "apiServerKey": "krb5Keytab", + "fileNameField": "krb5KeytabFile", + "required": true, + "x-index": 25 + }, + "krb5Conf": { + "type": "string", + "title": "${krb5Conf}", + "x-decorator": "FormItem", + "x-component": "TextFileReader", + "x-component-props": { + "base64": true + }, + "x-decorator-props": { + "tooltip": "${krb5ConfTip}" + }, + "apiServerKey": "krb5Conf", + "fileNameField": "krb5ConfFile", + "required": true, + "x-index": 26 + }, + "krb5Principal": { + "type": "string", + "title": "${krb5Principal}", + "x-decorator": "FormItem", + "x-component": "Input", + "apiServerKey": "krb5Principal", + "x-decorator-props": { + "tooltip": "${krb5PrincipalTip}" + }, + "required": true, + "x-index": 27 + }, + "krb5ServiceName": { + "type": "string", + "title": "${krb5ServiceName}", + "x-decorator": "FormItem", + "x-component": "Input", + "apiServerKey": "krb5ServiceName", + "x-decorator-props": { + "tooltip": "${krb5ServiceNameTip}" + }, + "required": true, + "x-index": 28 + }, "OPTIONAL_FIELDS": { "type": "void", "properties": { - "schemaRegister": { - "type": "boolean", - "title": "${schemaRegister}", - "default": false, - "x-decorator": "FormItem", - "x-decorator-props": { - "tooltip": "${schemaRegisterTip}" - }, - "x-component": "Switch", - "apiServerKey": "schemaRegister", - "x-reactions": [ - { - "target": "*(schemaRegisterUrl,registrySchemaType,basicAuth)", - "fulfill": { - "state": { - "visible": "{{$self.value===true}}" - } - } - } - ], - "x-index": 1 - }, - "schemaRegisterUrl": { + "batchMaxDelay": { "required": true, "type": "string", - "title": "${schemaRegisterUrl}", + "title": "${batchMaxDelay}", + "default": 2000, + "x-index": 6, "x-decorator": "FormItem", - "x-decorator-props": { - "tooltip": "${schemaRegisterUrlTip}" - }, - "x-component": "Input", - "apiServerKey": "schemaRegisterUrl", - "x-index": 2 - }, - "registrySchemaType": { - "type": "string", - "title": "${registrySchemaType}", - "default": "JSON", - "x-decorator": "FormItem", - "x-component": "Select", - "apiServerKey": "registrySchemaType", - "x-index": 3, - "enum": [ - { - "label": "JSON", - "value": "JSON" - }, - { - "label": "AVRO", - "value": "AVRO" - }, - { - "label": "PROTOBUF", - "value": "PROTOBUF" - } - ] - }, - "basicAuth": { - "type": "boolean", - "title": "${basicAuth}", - "default": false, - "x-decorator": "FormItem", - "x-component": "Switch", - "apiServerKey": "basicAuth", - "x-reactions": [ - { - "target": "*(authCredentialsSource,authUserName,authPassword)", - "fulfill": { - "state": { - "visible": "{{$self.value===true}}" - } - } - } - ], - "x-index": 4 - }, - "authCredentialsSource": { - "type": "string", - "title": "${authCredentialsSource}", - "default": "USER_INFO", - "x-decorator": "FormItem", - "x-component": "Radio.Group", + "x-component": "InputNumber", "x-component-props": { - "optionType": "button" + "min": 1000, + "max": 30000 }, - "x-index": 5, - "enum": [ - { - "label": "USER_INFO", - "value": "USER_INFO" - }, - { - "label": "SASL_INHERIT", - "value": "SASL_INHERIT" - } - ] - }, - "authUserName": { - "required": true, - "type": "string", - "title": "${authUserName}", - "x-decorator": "FormItem", - "x-component": "Input", - "apiServerKey": "database_username", - "x-index": 6 - }, - "authPassword": { - "required": true, - "type": "string", - "title": "${authPassword}", - "x-decorator": "FormItem", - "x-component": "Password", - "apiServerKey": "database_password", - "x-index": 7 + "x-decorator-props": { + "tooltip": "${batchMaxDelayTooltip}" + } }, "kafkaSaslMechanism": { "type": "string", @@ -539,7 +676,6 @@ "properties": { "val": { "type": "string", - "required": true, "x-decorator": "FormItem", "x-component": "Input" } @@ -672,6 +808,25 @@ "x-decorator-props": { "tooltip": "${partitionSizeTip}" } + }, + "splitUpdatePk": { + "type": "boolean", + "title": "${splitUpdatePk}", + "default": true, + "x-index": 30, + "x-decorator": "FormItem", + "x-component": "Switch", + "x-reactions": { + "fulfill": { + "state": { + "visible": "{{$deps[0]!=='source' && $deps[1].length}}" + } + }, + "dependencies": [ + "__TAPDATA.connection_type", + "$inputs" + ] + } } } }, @@ -685,7 +840,18 @@ "x-decorator-props": { "tooltip": "${combinePushTopicTip}" }, - "x-index": 2 + "x-reactions": { + "fulfill": { + "state": { + "visible": "{{$deps[0]!=='source' && $deps[1].length}}" + } + }, + "dependencies": [ + "__TAPDATA.connection_type", + "$inputs" + ] + }, + "x-index": 50 }, "schemaMode": { "type": "string", @@ -698,7 +864,70 @@ {"label": "${schemaModeOriginal}", "value": "ORIGINAL"}, {"label": "${schemaModeCanal}", "value": "CANAL"}, {"label": "${schemaModeDebezium}", "value": "DEBEZIUM"}, - {"label": "${schemaModeFlinkCDC}", "value": "FLINK_CDC"} + {"label": "${schemaModeFlinkCDC}", "value": "FLINK_CDC"}, + {"label": "${schemaModeCustom}", "value": "CUSTOM"} + ], + "x-index": 60, + "x-reactions": { + "fulfill": { + "state": { + "visible": "{{$deps[0]!=='source' && $deps[1].length}}" + } + }, + "dependencies": [ + "__TAPDATA.connection_type", + "$inputs" + ] + } + }, + "processScript": { + "type": "string", + "required": false, + "default": "function process(record, op, conditionKeys){\n\n\t\/\/ Enter your code at here\n\t return record;\n}", + "x-decorator": "FormItem", + "x-decorator-props": { + "tooltip": "${processScriptTip}" + }, + "x-component": "JsEditor", + "x-component-props": { + "options": { "showPrintMargin": false, "useWrapMode": true }, + "includeBeforeAndAfter": true, + "before": "function process(record, op, conditionKeys){", + "beforeRegexp": "^[^]*function\\s+process\\s*\\(record, op, conditionKeys\\)\\{", + "afterRegexp": "}[^}]*$", + "after": "}" + }, + "x-index": 70, + "x-reactions": { + "fulfill": { + "state": { + "visible": "{{$deps[0]==='CUSTOM'}}" + } + }, + "dependencies": [ + ".schemaMode" + ] + } + }, + "applyDefault": { + "type": "boolean", + "title": "${applyDefault}", + "default": false, + "x-index": 80, + "x-decorator": "FormItem", + "x-component": "Switch", + "x-decorator-props": { + "tooltip": "${applyDefaultTooltip}" + }, + "x-reactions": [ + { + "dependencies": ["$inputs"], + "fulfill": { + "state": { + "display": "{{$deps[0].length > 0 ? \"visible\":\"hidden\"}}" + } + } + } ] } } @@ -716,6 +945,7 @@ "schemaMode": "Structural mode", "schemaModeTapStandard": "Standard structure", "schemaModeOriginal": "Original structure", + "schemaModeCustom": "Custom structure", "schemaModeCanal": "Canal", "schemaModeDebezium": "Debezium", "schemaModeFlinkCDC": "Flink CDC", @@ -738,11 +968,21 @@ "replicasSizeTip": "Used when creating a topic, default: 1", "partitionSize": "Number of partitions", "partitionSizeTip": "Used when creating a topic, default: 3 (if the number of server partitions is less than this configuration, it will be increased)", + "splitUpdatePk": "Split update unique key", "useSasl": "Enable SASL", "saslMechanism": "Mechanism", "saslUserPassword": "User And Password", "saslUsername": "Username", "saslPassword": "Password", + "krb5": "kerberos authentication", + "krb5Keytab": "Key representation file", + "krb5KeytabTip": "Keytab file, which can achieve SSH login free", + "krb5Conf": "Configuration file", + "krb5ConfTip": "Kerberos configuration file", + "krb5Principal": "Body Configuration", + "krb5PrincipalTip": "for example: kafka/hostname@REALM", + "krb5ServiceName": "Service name", + "krb5ServiceNameTip": "Service name, usually kafka", "useSsl": "Enable SSL", "schemaRegister": "Schema Registry", "schemaRegisterTip": "To register a center using third-party mode, an additional address is required. If basic authentication is enabled, authentication information is also required", @@ -753,6 +993,8 @@ "authUserName": "Auth UserName", "authPassword": "Auth Password", "basicAuth": "Basic Auth", + "batchMaxDelay": "Consumption timeout (ms)", + "batchMaxDelayTooltip": "When loading the model, it is necessary to test consumption data. If there is significant network latency, it is recommended to extend the consumption timeout period; otherwise, the model may not be loaded successfully.", "kafkaSaslMechanism": "SASL Mechanism", "acksType": "ACK confirmation mechanism", "acksTypeNotSure": "No confirmation", @@ -772,7 +1014,9 @@ "extParamsTypeConsumer": "Consumer configuration", "extParamsTypeProducer": "Production configuration", "combinePushTopic": "Combine Push Topic", - "combinePushTopicTip": "Control the topic of kafka data push, supports the following wildcard characters, dynamic styling\n - {db_name}: Source database name\n - {schema_name}: Source schema name\n - {table_name}: Table name" + "combinePushTopicTip": "Control the topic of kafka data push, supports the following wildcard characters, dynamic styling\n - {db_name}: Source database name\n - {schema_name}: Source schema name\n - {table_name}: Table name", + "applyDefault": "Apply default value", + "applyDefaultTooltip": "When the switch is turned on, the default value is applied to the target. If there are unadapted functions or expressions, it may cause an error" }, "zh_CN": { "doc": "docs/kafka_enhanced_zh_CN.md", @@ -784,6 +1028,7 @@ "schemaMode": "结构模式", "schemaModeTapStandard": "标准结构", "schemaModeOriginal": "原生结构", + "schemaModeCustom": "自定义结构", "schemaModeCanal": "Canal", "schemaModeDebezium": "Debezium", "schemaModeFlinkCDC": "Flink CDC", @@ -806,11 +1051,21 @@ "replicasSizeTip": "创建主题时使用,默认:1", "partitionSize": "分区数", "partitionSizeTip": "创建主题时使用,默认:3(如果服务端分区数少于此配置,则会进行增加)", + "splitUpdatePk": "修改唯一键拆分", "useSasl": "启用 SASL", "saslMechanism": "安全机制", "saslUserPassword": "用户密码", "saslUsername": "用户", "saslPassword": "密码", + "krb5": "kerberos 认证", + "krb5Keytab": "密钥表示文件", + "krb5KeytabTip": "keytab文件,可以实现ssh免登录", + "krb5Conf": "配置文件", + "krb5ConfTip": "krb5的conf配置文件", + "krb5Principal": "主体配置", + "krb5PrincipalTip": "例如:kafka/hostname@REALM", + "krb5ServiceName": "服务名", + "krb5ServiceNameTip": "服务名,一般均为kafka", "useSsl": "启用 SSL", "schemaRegister": "模式注册", "schemaRegisterTip": "使用第三方模式注册中心,需要额外提供地址,若打开基本认证还需要提供认证信息等", @@ -821,6 +1076,8 @@ "authUserName": "认证用户名", "authPassword": "认证密码", "basicAuth": "基本认证", + "batchMaxDelay": "消费超时(ms)", + "batchMaxDelayTooltip": "加载模型时需要试消费数据,如果网络延迟较大,建议延长消费超时时间,否则将加载不到模型", "kafkaSaslMechanism": "加密方式", "acksType": "ACK 确认机制", "acksTypeNotSure": "不确认", @@ -840,7 +1097,9 @@ "extParamsTypeConsumer": "消费配置", "extParamsTypeProducer": "生产配置", "combinePushTopic": "推送Topic", - "combinePushTopicTip": "控制kafka数据推送的topic,支持以下通配符,动态拼接\n - {db_name}: 源端库名\n - {schema_name}: 源端模式名称\n - {table_name}: 表名" + "combinePushTopicTip": "控制kafka数据推送的topic,支持以下通配符,动态拼接\n - {db_name}: 源端库名\n - {schema_name}: 源端模式名称\n - {table_name}: 表名", + "applyDefault": "应用默认值", + "applyDefaultTooltip": "开关打开时会将默认值应用到目标,如果有未适配的函数或表达式,可能会导致报错" }, "zh_TW": { "doc": "docs/kafka_enhanced_zh_TW.md", @@ -852,6 +1111,7 @@ "schemaMode": "結構模式", "schemaModeTapStandard": "標準結構", "schemaModeOriginal": "原生結構", + "schemaModeCustom": "自定義結構", "schemaModeCanal": "Canal", "schemaModeDebezium": "Debezium", "schemaModeFlinkCDC": "Flink CDC", @@ -874,11 +1134,21 @@ "replicasSizeTip": "建立主題時使用,預設:1", "partitionSize": "分區數", "partitionSizeTip": "建立主題時使用,預設:3(如果服務端分區數少於此配置,則會進行增加)", + "splitUpdatePk": "修改唯一鍵拆分", "useSasl": "啟用 SASL", "saslMechanism": "安全機制", "saslUserPassword": "用戶密碼", "saslUsername": "用戶", "saslPassword": "密碼", + "krb5": "kerberos 認證", + "krb5Keytab": "密鑰表示文件", + "krb5KeytabTip": "keytab文件,可以實現ssh免登錄", + "krb5Conf": "配置文件", + "krb5ConfTip": "krb5的conf配置文件", + "krb5Principal": "主体配置", + "krb5PrincipalTip": "例如:kafka/hostname@REALM", + "krb5ServiceName": "服務名", + "krb5ServiceNameTip": "服務名,一般均為kafka", "useSsl": "啟用 SSL", "schemaRegister": "模式註冊", "schemaRegisterTip": "使用第三方模式註冊中心,需要額外提供地址,若打開基本認證還需要提供認證信息等", @@ -889,6 +1159,8 @@ "authUserName": "認證用戶名", "authPassword": "認證密碼", "basicAuth": "基本認證", + "batchMaxDelay": "消費超時(ms)", + "batchMaxDelayTooltip": "載入模型時需要試消費資料,如果網絡延遲較大,建議延長消費超時時間,否則將載入不到模型", "kafkaSaslMechanism": "加密方式", "acksType": "ACK 確認機制", "acksTypeNotSure": "不確認", @@ -908,7 +1180,9 @@ "extParamsTypeConsumer": "消費配置", "extParamsTypeProducer": "生產配置", "combinePushTopic": "推送Topic", - "combinePushTopicTip": "控制kafka數據推送的topic,支持以下通配符,動態拼接\n - {db_name}: 源端庫名\n - {schema_name}: 源端模式名稱\n - {table_name}: 表名" + "combinePushTopicTip": "控制kafka數據推送的topic,支持以下通配符,動態拼接\n - {db_name}: 源端庫名\n - {schema_name}: 源端模式名稱\n - {table_name}: 表名", + "applyDefault": "應用默認值", + "applyDefaultTooltip": "開關打開時會將默認值應用到目標,如果有未適配的函數或表達式,可能會導致報錯" } }, "dataTypes": { diff --git a/connectors/mariadb-connector/pom.xml b/connectors/mariadb-connector/pom.xml index 0f25688ec..76ef6af84 100644 --- a/connectors/mariadb-connector/pom.xml +++ b/connectors/mariadb-connector/pom.xml @@ -16,7 +16,7 @@ 4.0.3 1.5.4.Final 4.4 - 2.0.0-SNAPSHOT + 2.0.5-SNAPSHOT @@ -219,6 +219,25 @@ + + org.codehaus.mojo + build-helper-maven-plugin + 3.2.0 + + + add-source + generate-sources + + add-source + + + + src/main/overwrite/ + + + + + diff --git a/connectors/mariadb-connector/src/main/java/io/tapdata/connector/mariadb/MariadbConnector.java b/connectors/mariadb-connector/src/main/java/io/tapdata/connector/mariadb/MariadbConnector.java index f70aadaf2..04d79d05c 100644 --- a/connectors/mariadb-connector/src/main/java/io/tapdata/connector/mariadb/MariadbConnector.java +++ b/connectors/mariadb-connector/src/main/java/io/tapdata/connector/mariadb/MariadbConnector.java @@ -13,6 +13,12 @@ @TapConnectorClass("spec_mariadb.json") public class MariadbConnector extends MysqlConnector { + @Override + public void onStart(TapConnectionContext tapConnectionContext) throws Throwable { + super.onStart(tapConnectionContext); + mysqlJdbcContext = new MariadbJdbcContextV2(commonDbConfig); + } + @Override public ConnectionOptions connectionTest(TapConnectionContext connectionContext, Consumer consumer) { mysqlConfig = new MysqlConfig().load(connectionContext.getConnectionConfig()); diff --git a/connectors/mariadb-connector/src/main/java/io/tapdata/connector/mariadb/MariadbJdbcContextV2.java b/connectors/mariadb-connector/src/main/java/io/tapdata/connector/mariadb/MariadbJdbcContextV2.java new file mode 100644 index 000000000..b68af575b --- /dev/null +++ b/connectors/mariadb-connector/src/main/java/io/tapdata/connector/mariadb/MariadbJdbcContextV2.java @@ -0,0 +1,32 @@ +package io.tapdata.connector.mariadb; + +import io.tapdata.common.CommonDbConfig; +import io.tapdata.connector.mysql.MysqlJdbcContextV2; +import io.tapdata.connector.mysql.entity.MysqlBinlogPosition; + +import java.util.concurrent.atomic.AtomicReference; + +public class MariadbJdbcContextV2 extends MysqlJdbcContextV2 { + + + public MariadbJdbcContextV2(CommonDbConfig config) { + super(config); + } + + public MysqlBinlogPosition readBinlogPosition() throws Throwable { + AtomicReference mysqlBinlogPositionAtomicReference = new AtomicReference<>(); + normalQuery("SHOW MASTER STATUS", rs -> { + if (rs.next()) { + String binlogFilename = rs.getString(1); + long binlogPosition = rs.getLong(2); + mysqlBinlogPositionAtomicReference.set(new MysqlBinlogPosition(binlogFilename, binlogPosition)); + if (rs.getMetaData().getColumnCount() > 4) { + // This column exists only in MySQL 5.6.5 or later ... + String gtidSet = rs.getString(5); // GTID set, may be null, blank, or contain a GTID set + mysqlBinlogPositionAtomicReference.get().setGtidSet(gtidSet); + } + } + }); + return mysqlBinlogPositionAtomicReference.get(); + } +} diff --git a/connectors/mariadb-connector/src/main/overwrite/io/debezium/connector/mysql/MySqlConnection.java b/connectors/mariadb-connector/src/main/overwrite/io/debezium/connector/mysql/MySqlConnection.java new file mode 100644 index 000000000..5d025940a --- /dev/null +++ b/connectors/mariadb-connector/src/main/overwrite/io/debezium/connector/mysql/MySqlConnection.java @@ -0,0 +1,551 @@ +/* + * Copyright Debezium Authors. + * + * Licensed under the Apache Software License version 2.0, available at http://www.apache.org/licenses/LICENSE-2.0 + */ + +package io.debezium.connector.mysql; + +import io.debezium.DebeziumException; +import io.debezium.config.CommonConnectorConfig; +import io.debezium.config.CommonConnectorConfig.EventProcessingFailureHandlingMode; +import io.debezium.config.Configuration; +import io.debezium.config.Configuration.Builder; +import io.debezium.config.Field; +import io.debezium.connector.mysql.MySqlConnectorConfig.SecureConnectionMode; +import io.debezium.connector.mysql.legacy.MySqlJdbcContext.DatabaseLocales; +import io.debezium.jdbc.JdbcConnection; +import io.debezium.relational.TableId; +import io.debezium.relational.history.DatabaseHistory; +import io.debezium.util.Strings; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.sql.Connection; +import java.sql.DatabaseMetaData; +import java.sql.SQLException; +import java.time.Duration; +import java.util.*; + +/** + * {@link JdbcConnection} extension to be used with MySQL Server + * + * @author Jiri Pechanec, Randall Hauch + * + */ +public class MySqlConnection extends JdbcConnection { + + private static Logger LOGGER = LoggerFactory.getLogger(MySqlConnection.class); + + private static final String SQL_SHOW_SYSTEM_VARIABLES = "SHOW VARIABLES"; + private static final String SQL_SHOW_SYSTEM_VARIABLES_CHARACTER_SET = "SHOW VARIABLES WHERE Variable_name IN ('character_set_server','collation_server')"; + private static final String SQL_SHOW_SESSION_VARIABLE_SSL_VERSION = "SHOW SESSION STATUS LIKE 'Ssl_version'"; + + protected static final String URL_PATTERN = "jdbc:mysql://${hostname}:${port}/?useInformationSchema=true&nullCatalogMeansCurrent=false&useSSL=${useSSL}&useUnicode=true&characterEncoding=UTF-8&characterSetResults=UTF-8&zeroDateTimeBehavior=CONVERT_TO_NULL&connectTimeout=${connectTimeout}"; + + private final Map originalSystemProperties = new HashMap<>(); + private final MySqlConnectionConfiguration connectionConfig; + + /** + * Creates a new connection using the supplied configuration. + * + */ + public MySqlConnection(MySqlConnectionConfiguration connectionConfig) { + super(connectionConfig.config(), connectionConfig.factory()); + this.connectionConfig = connectionConfig; + } + + @Override + public synchronized Connection connection(boolean executeOnConnect) throws SQLException { + if (!isConnected() && connectionConfig.sslModeEnabled()) { + originalSystemProperties.clear(); + // Set the System properties for SSL for the MySQL driver ... + setSystemProperty("javax.net.ssl.keyStore", MySqlConnectorConfig.SSL_KEYSTORE, true); + setSystemProperty("javax.net.ssl.keyStorePassword", MySqlConnectorConfig.SSL_KEYSTORE_PASSWORD, false); + setSystemProperty("javax.net.ssl.trustStore", MySqlConnectorConfig.SSL_TRUSTSTORE, true); + setSystemProperty("javax.net.ssl.trustStorePassword", MySqlConnectorConfig.SSL_TRUSTSTORE_PASSWORD, false); + } + return super.connection(executeOnConnect); + } + + @Override + public void close() throws SQLException { + try { + super.close(); + } + finally { + // Reset the system properties to their original value ... + originalSystemProperties.forEach((name, value) -> { + if (value != null) { + System.setProperty(name, value); + } + else { + System.clearProperty(name); + } + }); + } + } + + /** + * Read the MySQL charset-related system variables. + * + * @return the system variables that are related to server character sets; never null + */ + protected Map readMySqlCharsetSystemVariables() { + // Read the system variables from the MySQL instance and get the current database name ... + LOGGER.debug("Reading MySQL charset-related system variables before parsing DDL history."); + return querySystemVariables(SQL_SHOW_SYSTEM_VARIABLES_CHARACTER_SET); + } + + /** + * Read the MySQL system variables. + * + * @return the system variables that are related to server character sets; never null + */ + protected Map readMySqlSystemVariables() { + // Read the system variables from the MySQL instance and get the current database name ... + LOGGER.debug("Reading MySQL system variables"); + return querySystemVariables(SQL_SHOW_SYSTEM_VARIABLES); + } + + private Map querySystemVariables(String statement) { + final Map variables = new HashMap<>(); + try { + query(statement, rs -> { + while (rs.next()) { + String varName = rs.getString(1); + String value = rs.getString(2); + if (varName != null && value != null) { + variables.put(varName, value); + LOGGER.debug("\t{} = {}", + Strings.pad(varName, 45, ' '), + Strings.pad(value, 45, ' ')); + } + } + }); + } + catch (SQLException e) { + throw new DebeziumException("Error reading MySQL variables: " + e.getMessage(), e); + } + + return variables; + } + + protected String setStatementFor(Map variables) { + StringBuilder sb = new StringBuilder("SET "); + boolean first = true; + List varNames = new ArrayList<>(variables.keySet()); + Collections.sort(varNames); + for (String varName : varNames) { + if (first) { + first = false; + } + else { + sb.append(", "); + } + sb.append(varName).append("="); + String value = variables.get(varName); + if (value == null) { + value = ""; + } + if (value.contains(",") || value.contains(";")) { + value = "'" + value + "'"; + } + sb.append(value); + } + return sb.append(";").toString(); + } + + protected void setSystemProperty(String property, Field field, boolean showValueInError) { + String value = connectionConfig.config().getString(field); + if (value != null) { + value = value.trim(); + String existing = System.setProperty(property, value); + originalSystemProperties.put(property, existing); // the existing value may be null +// String existingValue = System.getProperty(property); +// if (existingValue == null) { +// // There was no existing property ... +// String existing = System.setProperty(property, value); +// originalSystemProperties.put(property, existing); // the existing value may be null +// } +// else { +// existingValue = existingValue.trim(); +// if (!existingValue.equalsIgnoreCase(value)) { +// // There was an existing property, and the value is different ... +// String msg = "System or JVM property '" + property + "' is already defined, but the configuration property '" +// + field.name() +// + "' defines a different value"; +// if (showValueInError) { +// msg = "System or JVM property '" + property + "' is already defined as " + existingValue +// + ", but the configuration property '" + field.name() + "' defines a different value '" + value + "'"; +// } +// throw new DebeziumException(msg); +// } +// // Otherwise, there was an existing property, and the value is exactly the same (so do nothing!) +// } + } + } + + /** + * Read the Ssl Version session variable. + * + * @return the session variables that are related to sessions ssl version + */ + protected String getSessionVariableForSslVersion() { + final String SSL_VERSION = "Ssl_version"; + LOGGER.debug("Reading MySQL Session variable for Ssl Version"); + Map sessionVariables = querySystemVariables(SQL_SHOW_SESSION_VARIABLE_SSL_VERSION); + if (!sessionVariables.isEmpty() && sessionVariables.containsKey(SSL_VERSION)) { + return sessionVariables.get(SSL_VERSION); + } + return null; + } + + /** + * Determine whether the MySQL server has GTIDs enabled. + * + * @return {@code false} if the server's {@code gtid_mode} is set and is {@code OFF}, or {@code true} otherwise + */ + public boolean isGtidModeEnabled() { + try { + return queryAndMap("SHOW GLOBAL VARIABLES LIKE 'GTID_MODE'", rs -> { + if (rs.next()) { + return !"OFF".equalsIgnoreCase(rs.getString(2)); + } + return false; + }); + } + catch (SQLException e) { + throw new DebeziumException("Unexpected error while connecting to MySQL and looking at GTID mode: ", e); + } + } + + /** + * Determine the executed GTID set for MySQL. + * + * @return the string representation of MySQL's GTID sets; never null but an empty string if the server does not use GTIDs + */ + public String knownGtidSet() { + try { + String binLogStatusSql = "SHOW MASTER STATUS"; + return queryAndMap(binLogStatusSql, rs -> { + if (rs.next() && rs.getMetaData().getColumnCount() > 4) { + return rs.getString(5); // GTID set, may be null, blank, or contain a GTID set + } + return ""; + }); + } + catch (SQLException e) { + throw new DebeziumException("Unexpected error while connecting to MySQL and looking at GTID mode: ", e); + } + } + + /** + * Determine the difference between two sets. + * + * @return a subtraction of two GTID sets; never null + */ + public GtidSet subtractGtidSet(GtidSet set1, GtidSet set2) { + try { + return prepareQueryAndMap("SELECT GTID_SUBTRACT(?, ?)", + ps -> { + ps.setString(1, set1.toString()); + ps.setString(2, set2.toString()); + }, + rs -> { + if (rs.next()) { + return new GtidSet(rs.getString(1)); + } + return new GtidSet(""); + }); + } + catch (SQLException e) { + throw new DebeziumException("Unexpected error while connecting to MySQL and looking at GTID mode: ", e); + } + } + + /** + * Get the purged GTID values from MySQL (gtid_purged value) + * + * @return A GTID set; may be empty if not using GTIDs or none have been purged yet + */ + public GtidSet purgedGtidSet() { + try { + return queryAndMap("SELECT @@global.gtid_purged", rs -> { + if (rs.next() && rs.getMetaData().getColumnCount() > 0) { + return new GtidSet(rs.getString(1)); // GTID set, may be null, blank, or contain a GTID set + } + return new GtidSet(""); + }); + } + catch (SQLException e) { + throw new DebeziumException("Unexpected error while connecting to MySQL and looking at gtid_purged variable: ", e); + } + } + + /** + * Determine if the current user has the named privilege. Note that if the user has the "ALL" privilege this method + * returns {@code true}. + * + * @param grantName the name of the MySQL privilege; may not be null + * @return {@code true} if the user has the named privilege, or {@code false} otherwise + */ + public boolean userHasPrivileges(String grantName) { + try { + return queryAndMap("SHOW GRANTS FOR CURRENT_USER", rs -> { + while (rs.next()) { + String grants = rs.getString(1); + LOGGER.debug(grants); + if (grants == null) { + return false; + } + grants = grants.toUpperCase(); + if (grants.contains("ALL") || grants.contains(grantName.toUpperCase())) { + return true; + } + } + return false; + }); + } + catch (SQLException e) { + throw new DebeziumException("Unexpected error while connecting to MySQL and looking at privileges for current user: ", e); + } + } + + /** + * Determine the earliest binlog filename that is still available in the server. + * + * @return the name of the earliest binlog filename, or null if there are none. + */ + public String earliestBinlogFilename() { + // Accumulate the available binlog filenames ... + List logNames = new ArrayList<>(); + try { + LOGGER.info("Checking all known binlogs from MySQL"); + query("SHOW BINARY LOGS", rs -> { + while (rs.next()) { + logNames.add(rs.getString(1)); + } + }); + } + catch (SQLException e) { + throw new DebeziumException("Unexpected error while connecting to MySQL and looking for binary logs: ", e); + } + + if (logNames.isEmpty()) { + return null; + } + return logNames.get(0); + } + + /** + * Determine whether the MySQL server has the binlog_row_image set to 'FULL'. + * + * @return {@code true} if the server's {@code binlog_row_image} is set to {@code FULL}, or {@code false} otherwise + */ + protected boolean isBinlogRowImageFull() { + try { + final String rowImage = queryAndMap("SHOW GLOBAL VARIABLES LIKE 'binlog_row_image'", rs -> { + if (rs.next()) { + return rs.getString(2); + } + // This setting was introduced in MySQL 5.6+ with default of 'FULL'. + // For older versions, assume 'FULL'. + return "FULL"; + }); + LOGGER.debug("binlog_row_image={}", rowImage); + return "FULL".equalsIgnoreCase(rowImage); + } + catch (SQLException e) { + throw new DebeziumException("Unexpected error while connecting to MySQL and looking at BINLOG_ROW_IMAGE mode: ", e); + } + } + + /** + * Determine whether the MySQL server has the row-level binlog enabled. + * + * @return {@code true} if the server's {@code binlog_format} is set to {@code ROW}, or {@code false} otherwise + */ + protected boolean isBinlogFormatRow() { + try { + final String mode = queryAndMap("SHOW GLOBAL VARIABLES LIKE 'binlog_format'", rs -> rs.next() ? rs.getString(2) : ""); + LOGGER.debug("binlog_format={}", mode); + return "ROW".equalsIgnoreCase(mode); + } + catch (SQLException e) { + throw new DebeziumException("Unexpected error while connecting to MySQL and looking at BINLOG_FORMAT mode: ", e); + } + } + + /** + * Query the database server to get the list of the binlog files availble. + * + * @return list of the binlog files + */ + public List availableBinlogFiles() { + List logNames = new ArrayList<>(); + try { + LOGGER.info("Get all known binlogs from MySQL"); + query("SHOW BINARY LOGS", rs -> { + while (rs.next()) { + logNames.add(rs.getString(1)); + } + }); + return logNames; + } + catch (SQLException e) { + throw new DebeziumException("Unexpected error while connecting to MySQL and looking for binary logs: ", e); + } + } + + public OptionalLong getEstimatedTableSize(TableId tableId) { + try { + // Choose how we create statements based on the # of rows. + // This is approximate and less accurate then COUNT(*), + // but far more efficient for large InnoDB tables. + execute("USE `" + tableId.catalog() + "`;"); + return queryAndMap("SHOW TABLE STATUS LIKE '" + tableId.table() + "';", rs -> { + if (rs.next()) { + return OptionalLong.of((rs.getLong(5))); + } + return OptionalLong.empty(); + }); + } + catch (SQLException e) { + LOGGER.debug("Error while getting number of rows in table {}: {}", tableId, e.getMessage(), e); + } + return OptionalLong.empty(); + } + + public boolean isTableIdCaseSensitive() { + return 0 != lowerCaseTableNames(); + } + + public int lowerCaseTableNames() { + return Integer.parseInt(readMySqlSystemVariables().get(MySqlSystemVariables.LOWER_CASE_TABLE_NAMES)); + } + + /** + * Read the MySQL default character sets for exisiting databases. + * + * @return the map of database names with their default character sets; never null + */ + protected Map readDatabaseCollations() { + LOGGER.debug("Reading default database charsets"); + try { + return queryAndMap("SELECT schema_name, default_character_set_name, default_collation_name FROM information_schema.schemata", rs -> { + final Map charsets = new HashMap<>(); + while (rs.next()) { + String dbName = rs.getString(1); + String charset = rs.getString(2); + String collation = rs.getString(3); + if (dbName != null && (charset != null || collation != null)) { + charsets.put(dbName, new DatabaseLocales(charset, collation)); + LOGGER.debug("\t{} = {}, {}", + Strings.pad(dbName, 45, ' '), + Strings.pad(charset, 45, ' '), + Strings.pad(collation, 45, ' ')); + } + } + return charsets; + }); + } + catch (SQLException e) { + throw new DebeziumException("Error reading default database charsets: " + e.getMessage(), e); + } + } + + public String connectionString() { + return connectionString(URL_PATTERN); + } + + public static class MySqlConnectionConfiguration { + + protected static final String JDBC_PROPERTY_LEGACY_DATETIME = "useLegacyDatetimeCode"; + + private final Configuration jdbcConfig; + private final ConnectionFactory factory; + private final Configuration config; + + public MySqlConnectionConfiguration(Configuration config) { + // Set up the JDBC connection without actually connecting, with extra MySQL-specific properties + // to give us better JDBC database metadata behavior, including using UTF-8 for the client-side character encoding + // per https://dev.mysql.com/doc/connector-j/5.1/en/connector-j-reference-charsets.html + this.config = config; + final boolean useSSL = sslModeEnabled(); + final Configuration dbConfig = config + .filter(x -> !(x.startsWith(DatabaseHistory.CONFIGURATION_FIELD_PREFIX_STRING) || x.equals(MySqlConnectorConfig.DATABASE_HISTORY.name()))) + .edit() + .withDefault(MySqlConnectorConfig.PORT, MySqlConnectorConfig.PORT.defaultValue()) + .build() + .subset("database.", true); + + final Builder jdbcConfigBuilder = dbConfig + .edit() + .with("connectTimeout", Long.toString(getConnectionTimeout().toMillis())) + .with("useSSL", Boolean.toString(useSSL)); + + final String legacyDateTime = dbConfig.getString(JDBC_PROPERTY_LEGACY_DATETIME); + if (legacyDateTime == null) { + jdbcConfigBuilder.with(JDBC_PROPERTY_LEGACY_DATETIME, "false"); + } + else if ("true".equals(legacyDateTime)) { + LOGGER.warn("'{}' is set to 'true'. This setting is not recommended and can result in timezone issues.", JDBC_PROPERTY_LEGACY_DATETIME); + } + + this.jdbcConfig = jdbcConfigBuilder.build(); + String driverClassName = this.jdbcConfig.getString(MySqlConnectorConfig.JDBC_DRIVER); + factory = JdbcConnection.patternBasedFactory(MySqlConnection.URL_PATTERN, driverClassName, getClass().getClassLoader()); + } + + public Configuration config() { + return jdbcConfig; + } + + public ConnectionFactory factory() { + return factory; + } + + public String username() { + return config.getString(MySqlConnectorConfig.USER); + } + + public String password() { + return config.getString(MySqlConnectorConfig.PASSWORD); + } + + public String hostname() { + return config.getString(MySqlConnectorConfig.HOSTNAME); + } + + public int port() { + return config.getInteger(MySqlConnectorConfig.PORT); + } + + public SecureConnectionMode sslMode() { + String mode = config.getString(MySqlConnectorConfig.SSL_MODE); + return SecureConnectionMode.parse(mode); + } + + public boolean sslModeEnabled() { + return sslMode() != SecureConnectionMode.DISABLED; + } + + public Duration getConnectionTimeout() { + return Duration.ofMillis(config.getLong(MySqlConnectorConfig.CONNECTION_TIMEOUT_MS)); + } + + public EventProcessingFailureHandlingMode eventProcessingFailureHandlingMode() { + String mode = config.getString(CommonConnectorConfig.EVENT_PROCESSING_FAILURE_HANDLING_MODE); + if (mode == null) { + mode = config.getString(MySqlConnectorConfig.EVENT_DESERIALIZATION_FAILURE_HANDLING_MODE); + } + return EventProcessingFailureHandlingMode.parse(mode); + } + + public EventProcessingFailureHandlingMode inconsistentSchemaHandlingMode() { + String mode = config.getString(MySqlConnectorConfig.INCONSISTENT_SCHEMA_HANDLING_MODE); + return EventProcessingFailureHandlingMode.parse(mode); + } + } +} diff --git a/connectors/mariadb-connector/src/main/resources/spec_mariadb.json b/connectors/mariadb-connector/src/main/resources/spec_mariadb.json index b7aae1fbc..f31f5ec36 100644 --- a/connectors/mariadb-connector/src/main/resources/spec_mariadb.json +++ b/connectors/mariadb-connector/src/main/resources/spec_mariadb.json @@ -1,6 +1,7 @@ { "properties": { "name": "Mariadb", + "realName": "MariaDB", "icon": "icons/mariadb.png", "id": "mariadb", "doc": "${doc}", diff --git a/connectors/mock-source-connector/pom.xml b/connectors/mock-source-connector/pom.xml index be15592aa..1a7f4c181 100644 --- a/connectors/mock-source-connector/pom.xml +++ b/connectors/mock-source-connector/pom.xml @@ -15,7 +15,7 @@ 8 - 2.0.0-SNAPSHOT + 2.0.5-SNAPSHOT diff --git a/connectors/mock-source-connector/src/main/resources/spec_mock_source.json b/connectors/mock-source-connector/src/main/resources/spec_mock_source.json index 71850cb0f..888d47c35 100644 --- a/connectors/mock-source-connector/src/main/resources/spec_mock_source.json +++ b/connectors/mock-source-connector/src/main/resources/spec_mock_source.json @@ -1,6 +1,7 @@ { "properties": { "name": "Mock Source", + "realName": "Mock Source", "icon": "icons/mock-source.png", "doc" : "${doc}", "id": "mock-source", diff --git a/connectors/mock-target-connector/pom.xml b/connectors/mock-target-connector/pom.xml index 9079a6b82..be15b3c97 100644 --- a/connectors/mock-target-connector/pom.xml +++ b/connectors/mock-target-connector/pom.xml @@ -15,7 +15,7 @@ 8 - 2.0.0-SNAPSHOT + 2.0.5-SNAPSHOT diff --git a/connectors/mock-target-connector/src/main/resources/spec_mock_target.json b/connectors/mock-target-connector/src/main/resources/spec_mock_target.json index d0636b682..760f03de6 100644 --- a/connectors/mock-target-connector/src/main/resources/spec_mock_target.json +++ b/connectors/mock-target-connector/src/main/resources/spec_mock_target.json @@ -1,6 +1,7 @@ { "properties": { "name": "Mock Target", + "realName": "Mock Target", "icon": "icons/mock-target.png", "doc" : "${doc}", "id": "mock-target", diff --git a/connectors/mongodb-atlas-connector/pom.xml b/connectors/mongodb-atlas-connector/pom.xml index 705e46e3f..bb4b8fee1 100644 --- a/connectors/mongodb-atlas-connector/pom.xml +++ b/connectors/mongodb-atlas-connector/pom.xml @@ -13,7 +13,7 @@ 8 - 2.0.0-SNAPSHOT + 2.0.5-SNAPSHOT diff --git a/connectors/mongodb-atlas-connector/src/main/resources/atlas-spec.json b/connectors/mongodb-atlas-connector/src/main/resources/atlas-spec.json index 209c4b5c0..99122bf07 100644 --- a/connectors/mongodb-atlas-connector/src/main/resources/atlas-spec.json +++ b/connectors/mongodb-atlas-connector/src/main/resources/atlas-spec.json @@ -1,6 +1,7 @@ { "properties": { "name": "MongoDB Atlas", + "realName": "MongoDB Atlas", "icon": "icons/atlas.png", "doc" : "${doc}", "tags" : ["schema-free","Database"], diff --git a/connectors/mongodb-connector/pom.xml b/connectors/mongodb-connector/pom.xml index 6e7df67db..5803b7bcb 100644 --- a/connectors/mongodb-connector/pom.xml +++ b/connectors/mongodb-connector/pom.xml @@ -13,7 +13,7 @@ 8 - 2.0.5-SNAPSHOT + 2.0.7-SNAPSHOT @@ -70,6 +70,12 @@ bcprov-jdk15on 1.60 + + org.bouncycastle + bcpkix-jdk15on + 1.60 + compile + io.tapdata diff --git a/connectors/mongodb-connector/src/main/java/io/tapdata/mongodb/MongodbConnector.java b/connectors/mongodb-connector/src/main/java/io/tapdata/mongodb/MongodbConnector.java index cf477d2ad..54097e96f 100644 --- a/connectors/mongodb-connector/src/main/java/io/tapdata/mongodb/MongodbConnector.java +++ b/connectors/mongodb-connector/src/main/java/io/tapdata/mongodb/MongodbConnector.java @@ -135,6 +135,8 @@ import java.math.BigDecimal; import java.text.ParseException; import java.text.SimpleDateFormat; +import java.time.Instant; +import java.time.ZoneOffset; import java.util.ArrayList; import java.util.Collection; import java.util.Collections; @@ -186,6 +188,7 @@ public class MongodbConnector extends ConnectorBase { protected MongodbConfig mongoConfig; protected MongoClient mongoClient; protected MongoDatabase mongoDatabase; + protected int mongoVersion; private MongoBatchOffset batchOffset = null; protected MongodbExceptionCollector exceptionCollector; private MongodbStreamReader mongodbStreamReader; @@ -252,6 +255,7 @@ protected MongoCollection getMongoRawCollection(String table) { public void discoverSchema(TapConnectionContext connectionContext, List tables, int tableSize, Consumer> consumer) throws Throwable { final String database = mongoConfig.getDatabase(); final String version = MongodbUtil.getVersionString(mongoClient, database); + Map collectionTypeMap = getCollectionTypeMap(tables); MongoIterable collectionNames = mongoDatabase.listCollectionNames(); TableFieldTypesGenerator tableFieldTypesGenerator = InstanceFactory.instance(TableFieldTypesGenerator.class); this.stringTypeValueMap = new HashMap<>(); @@ -285,8 +289,14 @@ public void discoverSchema(TapConnectionContext connectionContext, List //List all the tables under the database. List list = list(); nameList.forEach(name -> { + String collectionType = collectionTypeMap.get(name); + boolean isView = isViewCollection(collectionType); TapTable table = new TapTable(name); - table.defaultPrimaryKeys("_id"); + if(!isView){ + table.defaultPrimaryKeys("_id"); + }else{ + table.setType(collectionType); + } MongoCollection collection = documentMap.get(name); try { MongodbUtil.sampleDataRow(collection, sampleSizeBatchSize, (dataRow) -> { @@ -305,46 +315,48 @@ public void discoverSchema(TapConnectionContext connectionContext, List MongodbUtil.maskUriPassword(mongoConfig.getUri()), name, e.getMessage(), e); } - collection.listIndexes().forEach((index) -> { - Object keyObj = index.get("key"); - if (!(keyObj instanceof Document)) { - return; - } - Document keys = (Document) keyObj; - - TapIndex tapIndex = new TapIndex(); - // TODO: TapIndex struct not enough to represent index, so we encode index info in name - tapIndex.setName("__t__" + ((Document) index).toJson()); - - AtomicBoolean haveOid = new AtomicBoolean(); - AtomicInteger keyCounter = new AtomicInteger(); - keys.forEach((k, v) -> { - TapIndexField tapIndexField = new TapIndexField().name(k); - if (v instanceof Integer) { - tapIndexField.fieldAsc(v.equals(1)); + if(!isView){ + collection.listIndexes().forEach((index) -> { + Object keyObj = index.get("key"); + if (!(keyObj instanceof Document)) { + return; + } + Document keys = (Document) keyObj; + + TapIndex tapIndex = new TapIndex(); + // TODO: TapIndex struct not enough to represent index, so we encode index info in name + tapIndex.setName("__t__" + ((Document) index).toJson()); + + AtomicBoolean haveOid = new AtomicBoolean(); + AtomicInteger keyCounter = new AtomicInteger(); + keys.forEach((k, v) -> { + TapIndexField tapIndexField = new TapIndexField().name(k); + if (v instanceof Integer) { + tapIndexField.fieldAsc(v.equals(1)); + } else { + tapIndexField.fieldAsc(true); + } + tapIndex.indexField(tapIndexField); + if (k.equals("_id")) { + haveOid.set(true); + } + keyCounter.incrementAndGet(); + }); + if (Boolean.TRUE.equals(index.get(UNIQUE_KEY))) { + tapIndex.unique(true); } else { - tapIndexField.fieldAsc(true); + tapIndex.unique(false); } - tapIndex.indexField(tapIndexField); - if (k.equals("_id")) { - haveOid.set(true); + if (haveOid.get() && keyCounter.get() == 1) { + tapIndex.unique(true); } - keyCounter.incrementAndGet(); + TapLogger.info(TAG, "MongodbConnector discoverSchema table: {} index {}", name, ((Document) index).toJson()); + table.add(tapIndex); }); - if (Boolean.TRUE.equals(index.get(UNIQUE_KEY))) { - tapIndex.unique(true); - } else { - tapIndex.unique(false); - } - if (haveOid.get() && keyCounter.get() == 1) { - tapIndex.unique(true); - } - TapLogger.info(TAG, "MongodbConnector discoverSchema table: {} index {}", name, ((Document) index).toJson()); - table.add(tapIndex); - }); - Map sharkedKeys = MongodbUtil.getCollectionSharkedKeys(mongoClient, database, name); - MongoShardUtil.saveCollectionStats(table, MongodbUtil.getCollectionStatus(mongoClient, database, name), sharkedKeys); - MongodbUtil.getTimeSeriesCollectionStatus(mongoClient, database, name,table); + Map sharkedKeys = MongodbUtil.getCollectionSharkedKeys(mongoClient, database, name); + MongoShardUtil.saveCollectionStats(table, MongodbUtil.getCollectionStatus(mongoClient, database, name), sharkedKeys); + MongodbUtil.getTimeSeriesCollectionStatus(mongoClient, database, name,table); + } if (!Objects.isNull(table.getNameFieldMap()) && !table.getNameFieldMap().isEmpty()) { list.add(table); } @@ -368,8 +380,14 @@ public void discoverSchema(TapConnectionContext connectionContext, List //List all the tables under the database. List list = list(); nameList.forEach(name -> { + String collectionType = collectionTypeMap.get(name); + boolean isView = isViewCollection(collectionType); TapTable table = new TapTable(name); - table.defaultPrimaryKeys(singletonList(COLLECTION_ID_FIELD)); + if(!isView){ + table.defaultPrimaryKeys(singletonList(COLLECTION_ID_FIELD)); + }else{ + table.setType(collectionType); + } // save collection info which include capped info try (MongoCursor cursor = documentMap.get(name).find().iterator()) { while (cursor.hasNext()) { @@ -382,9 +400,11 @@ public void discoverSchema(TapConnectionContext connectionContext, List break; } } - Map sharkedKeys = MongodbUtil.getCollectionSharkedKeys(mongoClient, database, name); - MongoShardUtil.saveCollectionStats(table, MongodbUtil.getCollectionStatus(mongoClient, database, name), sharkedKeys); - MongodbUtil.getTimeSeriesCollectionStatus(mongoClient, database, name,table); + if(!isView){ + Map sharkedKeys = MongodbUtil.getCollectionSharkedKeys(mongoClient, database, name); + MongoShardUtil.saveCollectionStats(table, MongodbUtil.getCollectionStatus(mongoClient, database, name), sharkedKeys); + MongodbUtil.getTimeSeriesCollectionStatus(mongoClient, database, name,table); + } if (!Objects.isNull(table.getNameFieldMap()) && !table.getNameFieldMap().isEmpty()) { list.add(table); } @@ -607,7 +627,14 @@ public void registerCapabilities(ConnectorFunctions connectorFunctions, TapCodec Symbol symbol = (Symbol) value; return new TapStringValue(symbol.getSymbol()); }); - + codecRegistry.registerToTapValue(BsonTimestamp.class, (value, tapType) -> { + BsonTimestamp bsonTimestamp = (BsonTimestamp) value; + return new TapDateTimeValue(new DateTime(Instant.ofEpochMilli(bsonTimestamp.getTime()).atZone(ZoneOffset.UTC))); + }); + codecRegistry.registerToTapValue(BsonRegularExpression.class, (value, tapType) -> { + BsonRegularExpression bsonRegularExpression = (BsonRegularExpression) value; + return new TapStringValue("/" + bsonRegularExpression.getPattern() + "/" + bsonRegularExpression.getOptions()); + }); //TapTimeValue, TapDateTimeValue and TapDateValue's value is DateTime, need convert into Date object. codecRegistry.registerFromTapValue(TapTimeValue.class, "DATE_TIME", tapTimeValue -> tapTimeValue.getValue().toDate()); codecRegistry.registerFromTapValue(TapDateTimeValue.class, "DATE_TIME", tapDateTimeValue -> tapDateTimeValue.getValue().toDate()); @@ -754,9 +781,41 @@ private CreateTableOptions createTableV2(TapConnectorContext tapConnectorContext //created capped collection createCappedCollection(table, isShardCollection, log); + // open pre image + openPreImage(table, nodeConfig); + return createTableOptions; } + private void openPreImage(TapTable table, DataMap nodeConfig) { + if (null == nodeConfig) { + return; + } + Object preImage4SinkObj = nodeConfig.get("preImage4Sink"); + if (!Boolean.TRUE.equals(preImage4SinkObj)) { + return; + } + // changeStreamPreAndPostImages is only supported in MongoDB 6.0+ + if (mongoVersion < 6) { + return; + } + String tableName = table.getId(); + BsonDocument bsonDocument = new BsonDocument(); + bsonDocument.put("collMod", new BsonString(tableName)); + bsonDocument.put("changeStreamPreAndPostImages", new BsonDocument("enabled", new BsonBoolean(true))); + try { + mongoDatabase.runCommand(bsonDocument); + } catch (MongoException e) { + if (e.getCode() == 26) { + // Collection doesn't exist (NamespaceNotFound), create it and try again + mongoDatabase.createCollection(tableName); + mongoDatabase.runCommand(bsonDocument); + } else { + throw e; + } + } + } + protected void createIndex(TapTable table, List indexList, Log log) { if (null == indexList || indexList.isEmpty()) return; List indexModels = new ArrayList<>(); @@ -1368,8 +1427,9 @@ public void onStart(TapConnectionContext connectionContext) throws Throwable { throw new RuntimeException("load mongo config failed from connection config"); } if (mongoClient == null) { - mongoClient = MongodbUtil.createMongoClient(mongoConfig); + mongoClient = MongodbUtil.createMongoClient(mongoConfig,false); mongoDatabase = mongoClient.getDatabase(mongoConfig.getDatabase()); + mongoVersion = MongodbUtil.getVersion(mongoClient, mongoConfig.getDatabase()); } mongodbExecuteCommandFunction.setLog(connectionContext.getLog()); } @@ -1798,8 +1858,7 @@ protected void streamReadOpLog(TapConnectorContext connectorContext, List= 4) { + if (mongoVersion >= 4) { mongodbStreamReader = new MongodbV4StreamReader().setPreImage(mongoConfig.getPreImage()); } else { mongodbStreamReader = new MongodbV3StreamReader(); @@ -1817,13 +1876,9 @@ protected void getTableNames(TapConnectionContext tapConnectionContext, int batc String database = mongoConfig.getDatabase(); List temp = new ArrayList<>(); for (Document collection : mongoClient.getDatabase(database).listCollections()) { - // 去除视图表 - if (collection.get("type", "").equals("view")) { - continue; - } String tableName = collection.getString("name"); // 如果 tableName 以 "system." 开头, 则跳过(这是一些系统表) - if (tableName.startsWith("system.")) { + if (StringUtils.isBlank(tableName) || tableName.startsWith("system.")) { continue; } temp.add(tableName); @@ -1893,10 +1948,9 @@ protected TableInfo getTableInfo(TapConnectionContext tapConnectorContext, Strin MongoDatabase mongoDatabase = mongoClient.getDatabase(database); Document collStats = mongoDatabase.runCommand(new Document("collStats", tableName)); tableInfo = TableInfo.create(); - BigDecimal numOfRows = new BigDecimal(String.valueOf(collStats.get("count"))); - tableInfo.setNumOfRows(numOfRows.longValue()); - BigDecimal storageSize = new BigDecimal(String.valueOf(collStats.get("size"))); - tableInfo.setStorageSize(storageSize.longValue()); + tableInfo.setNumOfRows(getLongFromDocument(collStats, "count")); + tableInfo.setStorageSize(getLongFromDocument(collStats, "size")); + tableInfo.setAvgObjSize(getLongFromDocument(collStats, "avgObjSize")); }catch (Exception e){ exceptionCollector.collectTerminateByServer(e); exceptionCollector.collectReadPrivileges(e); @@ -1905,6 +1959,21 @@ protected TableInfo getTableInfo(TapConnectionContext tapConnectorContext, Strin return tableInfo; } + private long getLongFromDocument(Document document, String key) { + Object value = document.get(key); + if (value == null) { + return 0L; + } + if (value instanceof Number) { + return ((Number) value).longValue(); + } + try { + return new BigDecimal(String.valueOf(value)).longValue(); + } catch (NumberFormatException e) { + return 0L; + } + } + protected void errorHandle(Throwable throwable, TapConnectorContext connectorContext) { if (null == throwable) { return; @@ -1970,4 +2039,30 @@ protected void rollbackTransaction(TapConnectorContext connectorContext) { return null; }); } + + private Map getCollectionTypeMap(List tables) { + Map collectionTypeMap = new HashMap<>(); + ListCollectionsIterable listCollections; + if (CollectionUtils.isNotEmpty(tables)) { + listCollections = mongoDatabase.listCollections().filter(new Document("name", new Document("$in", tables))); + } else { + listCollections = mongoDatabase.listCollections(); + } + for (Document collection : listCollections) { + String name = collection.getString("name"); + if (StringUtils.isBlank(name) || name.startsWith("system.")) { + continue; + } + String type = collection.getString("type"); + if (StringUtils.isBlank(type)) { + type = "collection"; + } + collectionTypeMap.put(name, type); + } + return collectionTypeMap; + } + + private boolean isViewCollection(String collectionType) { + return "view".equalsIgnoreCase(collectionType); + } } diff --git a/connectors/mongodb-connector/src/main/java/io/tapdata/mongodb/MongodbExceptionCollector.java b/connectors/mongodb-connector/src/main/java/io/tapdata/mongodb/MongodbExceptionCollector.java index ee508f932..14f0c21bb 100644 --- a/connectors/mongodb-connector/src/main/java/io/tapdata/mongodb/MongodbExceptionCollector.java +++ b/connectors/mongodb-connector/src/main/java/io/tapdata/mongodb/MongodbExceptionCollector.java @@ -15,6 +15,7 @@ import io.tapdata.mongodb.writer.error.TapMongoBulkWriteException; import org.bson.BsonMaximumSizeExceededException; +import java.io.IOException; import java.util.ArrayList; import java.util.List; import java.util.Optional; @@ -44,6 +45,9 @@ public void revealException(Throwable cause) { throw new TapPdkRetryableEx(getPdkId(), ErrorKit.getLastCause(cause)) .withServerErrorCode(String.valueOf(((MongoException) cause).getCode())); } + if (cause instanceof IOException) { + throw new TapPdkRetryableEx(getPdkId(), ErrorKit.getLastCause(cause)); + } } @Override diff --git a/connectors/mongodb-connector/src/main/java/io/tapdata/mongodb/MongodbExecuteCommandFunction.java b/connectors/mongodb-connector/src/main/java/io/tapdata/mongodb/MongodbExecuteCommandFunction.java index c181401c9..fce379deb 100644 --- a/connectors/mongodb-connector/src/main/java/io/tapdata/mongodb/MongodbExecuteCommandFunction.java +++ b/connectors/mongodb-connector/src/main/java/io/tapdata/mongodb/MongodbExecuteCommandFunction.java @@ -169,11 +169,8 @@ private AggregateIterable getAggregateIterable(Map exe ExecuteObject executeObject = new ExecuteObject(executeObj); String database = executeObject.getDatabase(); String collection = executeObject.getCollection(); - if (collection == null || collection.isEmpty()) { - throw new RuntimeException(String.format("Process execute %s failed, collection name cannot be blank", executeObject)); - } List pipelines = executeObject.getPipeline(); - if (pipelines.isEmpty()) { + if (pipelines == null || pipelines.isEmpty()) { throw new RuntimeException(String.format("Process execute %s failed, pipeline cannot be blank", executeObject)); } boolean allowDiskUse = true; @@ -189,7 +186,11 @@ private AggregateIterable getAggregateIterable(Map exe // ignored } - return mongoClient.getDatabase(database).getCollection(collection).aggregate(pipelines).allowDiskUse(allowDiskUse); + MongoDatabase mongoDatabase = mongoClient.getDatabase(database); + if (collection == null || collection.isEmpty()) { + return mongoDatabase.aggregate(pipelines).allowDiskUse(allowDiskUse); + } + return mongoDatabase.getCollection(collection).aggregate(pipelines).allowDiskUse(allowDiskUse); } public Object aggregate(Map executeObj, MongoClient mongoClient) { diff --git a/connectors/mongodb-connector/src/main/java/io/tapdata/mongodb/MongodbTest.java b/connectors/mongodb-connector/src/main/java/io/tapdata/mongodb/MongodbTest.java index fc5eba3ad..8057a940c 100644 --- a/connectors/mongodb-connector/src/main/java/io/tapdata/mongodb/MongodbTest.java +++ b/connectors/mongodb-connector/src/main/java/io/tapdata/mongodb/MongodbTest.java @@ -206,7 +206,7 @@ public Boolean testWritePrivilege() { MongoDatabase mongoDatabase = mongoClient.getDatabase(mongodbConfig.getDatabase()); Document connectionStatus = mongoDatabase.runCommand(new Document("connectionStatus", 1).append("showPrivileges", 1)); if (!validateReadOrWriteDatabase(connectionStatus, mongodbConfig.getDatabase(), READ_WRITE_PRIVILEGE_ACTIONS)) { - consumer.accept(new TestItem(TestItem.ITEM_WRITE, new TapTestItemException(new TapCodeException(MongodbErrorCode.WRITE_PRIVILEGES_MISSING, "Missing write privileges")), TestItem.RESULT_FAILED)); + consumer.accept(new TestItem(TestItem.ITEM_WRITE, new TapTestItemException(new TapCodeException(MongodbErrorCode.WRITE_PRIVILEGES_MISSING, "Missing write privileges")), TestItem.RESULT_SUCCESSFULLY_WITH_WARN)); return false; } Document isMaster = mongoDatabase.runCommand(new Document("isMaster", 1)); diff --git a/connectors/mongodb-connector/src/main/java/io/tapdata/mongodb/MongodbUtil.java b/connectors/mongodb-connector/src/main/java/io/tapdata/mongodb/MongodbUtil.java index 6c890cc7f..68e70518a 100644 --- a/connectors/mongodb-connector/src/main/java/io/tapdata/mongodb/MongodbUtil.java +++ b/connectors/mongodb-connector/src/main/java/io/tapdata/mongodb/MongodbUtil.java @@ -34,9 +34,9 @@ import java.security.cert.CertificateException; import java.security.cert.X509Certificate; import java.util.*; -import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicReference; import java.util.function.Consumer; +import java.util.regex.Pattern; import java.util.stream.Collectors; import java.util.stream.IntStream; @@ -286,6 +286,31 @@ public static String replicaSetUsedIn(String addresses) { return addresses.substring(0, index); } + private static final String[][] DEFAULT_HA_TIMEOUT_OPTIONS = { + {"serverSelectionTimeoutMS", "15000"}, + {"socketTimeoutMS", "60000"}, + {"maxIdleTimeMS", "30000"} + }; + + public static String appendDefaultHaTimeoutOptions(String mongodbUri,Boolean isStreamRead) { + if (EmptyKit.isBlank(mongodbUri)) { + return mongodbUri; + } + StringBuilder result = new StringBuilder(mongodbUri); + for (String[] kv : DEFAULT_HA_TIMEOUT_OPTIONS) { + String key = kv[0]; + String value = kv[1]; + if (Boolean.TRUE.equals(isStreamRead) && "socketTimeoutMS".equalsIgnoreCase(key)) { + value = "0"; + } + Pattern pattern = Pattern.compile("[?&]" + Pattern.quote(key) + "=", Pattern.CASE_INSENSITIVE); + if (!pattern.matcher(result).find()) { + result.append(result.indexOf("?") >= 0 ? '&' : '?').append(key).append('=').append(value); + } + } + return result.toString(); + } + public static String getMongoDBURIOptions(String databaseUri) { String options = null; try { @@ -361,8 +386,8 @@ public static String mongodbKeySpecialCharHandler(String key, String replacement return key; } - private static boolean isMongoShards(String mongoUri) { - try (MongoClient mongoClient = MongoClients.create(mongoUri)) { + private static boolean isMongoShards(String mongoUri, MongodbConfig mongodbConfig) { + try (MongoClient mongoClient = MongoClients.create(getMongoClientSettingsBuilder(mongoUri, mongodbConfig).build())) { MongoCollection collection = mongoClient.getDatabase("config").getCollection("shards"); final MongoCursor cursor = collection.find().iterator(); if (cursor.hasNext()) { @@ -375,8 +400,8 @@ private static boolean isMongoShards(String mongoUri) { } // 写一个方法, 接收 mongoUri, 如果参数里没有包含 replicaSet, 返回 mongoUri, 但是里面只有主节点的地址 - private static String getPrimaryUri(String mongoUri) { - if (mongoUri.contains("replicaSet") || mongoUri.contains("mongodb+srv:") || isMongoShards(mongoUri)) { + private static String getPrimaryUri(String mongoUri, MongodbConfig mongodbConfig) { + if (mongoUri.contains("replicaSet") || mongoUri.contains("mongodb+srv:") || isMongoShards(mongoUri,mongodbConfig)) { return mongoUri; } @@ -386,10 +411,12 @@ private static String getPrimaryUri(String mongoUri) { try { List hosts = connectionString.getHosts(); if (EmptyKit.isNotEmpty(hosts)) { + String finalMongoUri = mongoUri; hosts.forEach(host -> { MongoClient client = null; try { - client = MongoClients.create("mongodb://" + host); + String tempUri = buildTempUri(finalMongoUri, host, connectionString); + client = MongoClients.create(getMongoClientSettingsBuilder(tempUri, mongodbConfig).build()); MongoDatabase database = client.getDatabase("admin"); Document result = database.runCommand(new Document("isMaster", 1)); if (result.getBoolean("ismaster")) { @@ -427,70 +454,112 @@ private static String getPrimaryUri(String mongoUri) { return mongoUri; } + private static String buildTempUri(String originalUri, String host, ConnectionString connectionString) { + StringBuilder tempUri = new StringBuilder("mongodb://"); - public static MongoClient createMongoClient(MongodbConfig mongodbConfig) { - CodecRegistry defaultCodecRegistry = MongoClientSettings.getDefaultCodecRegistry(); - CodecRegistry codecRegistry = CodecRegistries.fromRegistries(CodecRegistries.fromCodecs( - new TapdataBigDecimalCodec(), - new TapdataBigIntegerCodec() - ), defaultCodecRegistry); - final MongoClientSettings.Builder builder = MongoClientSettings.builder() - .codecRegistry(codecRegistry) - .writeConcern(WriteConcern.valueOf(mongodbConfig.getWriteConcern())); - String mongodbUri = mongodbConfig.getUri(); - if (null == mongodbUri || "".equals(mongodbUri)) { - throw new RuntimeException("Create MongoDB client failed, error: uri is blank"); + if (connectionString.getCredential() != null) { + MongoCredential credential = connectionString.getCredential(); + String username = credential.getUserName(); + char[] password = credential.getPassword(); + if (username != null && !username.isEmpty()) { + try { + tempUri.append(URLEncoder.encode(username, "UTF-8")); + if (password != null && password.length > 0) { + tempUri.append(":").append(URLEncoder.encode(new String(password), "UTF-8")); + } + tempUri.append("@"); + } catch (UnsupportedEncodingException e) { + // 如果编码失败,不添加认证信息 + } } + } - // if mongodbUri not contains replicaSet, then only connect to primary node - mongodbUri = getPrimaryUri(mongodbUri); - ConnectionPoolSettings.Builder connectionPoolSettingsBuilder = ConnectionPoolSettings.builder(); - ConnectionPoolSettings connectionPoolSettings = connectionPoolSettingsBuilder.build(); - builder.applyToConnectionPoolSettings(settingBuilder -> { - settingBuilder.applySettings(connectionPoolSettings); - }); + tempUri.append(host); + String database = connectionString.getDatabase(); + if (database != null && !database.isEmpty()) { + tempUri.append("/").append(database); + } - builder.applyConnectionString(new ConnectionString(mongodbUri)); + int queryIndex = originalUri.indexOf("?"); + if (queryIndex > 0) { + String queryParams = originalUri.substring(queryIndex); + if (database == null || database.isEmpty()) { + tempUri.append("/"); + } + tempUri.append(queryParams); + } - if (mongodbConfig.isSsl()) { - if (EmptyKit.isNotEmpty(mongodbUri) && - (mongodbUri.indexOf("tlsAllowInvalidCertificates=true") > 0 || - mongodbUri.indexOf("sslAllowInvalidCertificates=true") > 0)) { - builder.applyToSslSettings(sslSettingBuilder -> { - SSLContext sslContext = null; - try { - sslContext = SSLContext.getInstance("SSL"); - } catch (NoSuchAlgorithmException e) { - throw new RuntimeException(String.format("Create ssl context failed %s", e.getMessage()), e); - } - try { - sslContext.init(null, new TrustManager[]{new X509TrustManager() { - @Override - public void checkClientTrusted(X509Certificate[] x509Certificates, String s) throws CertificateException { - } + return tempUri.toString(); + } - @Override - public void checkServerTrusted(X509Certificate[] x509Certificates, String s) throws CertificateException { - } - @Override - public X509Certificate[] getAcceptedIssuers() { - return null; - } - }}, new SecureRandom()); - } catch (KeyManagementException e) { - throw new RuntimeException(String.format("Initialize ssl context failed %s", e.getMessage()), e); - } - sslSettingBuilder.enabled(true).context(sslContext).invalidHostNameAllowed(true); - }); - - } else { - sslMongoClientOption(mongodbConfig.isSslValidate(), mongodbConfig.getSslCA(), - mongodbConfig.getSslKey(), mongodbConfig.getSslPass(), mongodbConfig.getCheckServerIdentity(), builder); - } + public static MongoClient createMongoClient(MongodbConfig mongodbConfig,Boolean isStreamRead) { + String mongodbUri = mongodbConfig.getUri(); + if (null == mongodbUri || "".equals(mongodbUri)) { + throw new RuntimeException("Create MongoDB client failed, error: uri is blank"); } + // if mongodbUri not contains replicaSet, then only connect to primary node + mongodbUri = getPrimaryUri(mongodbUri, mongodbConfig); + return MongoClients.create(getMongoClientSettingsBuilder(mongodbUri, mongodbConfig,isStreamRead).build()); + } + + public static MongoClientSettings.Builder getMongoClientSettingsBuilder(String mongodbUri, MongodbConfig mongodbConfig) { + return getMongoClientSettingsBuilder(mongodbUri,mongodbConfig,false); + } + + public static MongoClientSettings.Builder getMongoClientSettingsBuilder(String mongodbUri, MongodbConfig mongodbConfig,Boolean isStreamRead) { + mongodbUri = appendDefaultHaTimeoutOptions(mongodbUri,isStreamRead); + CodecRegistry defaultCodecRegistry = MongoClientSettings.getDefaultCodecRegistry(); + CodecRegistry codecRegistry = CodecRegistries.fromRegistries(CodecRegistries.fromCodecs( + new TapdataBigDecimalCodec(), + new TapdataBigIntegerCodec() + ), defaultCodecRegistry); + final MongoClientSettings.Builder builder = MongoClientSettings.builder() + .codecRegistry(codecRegistry) + .writeConcern(WriteConcern.valueOf(mongodbConfig.getWriteConcern())); + ConnectionPoolSettings.Builder connectionPoolSettingsBuilder = ConnectionPoolSettings.builder(); + ConnectionPoolSettings connectionPoolSettings = connectionPoolSettingsBuilder.build(); + builder.applyToConnectionPoolSettings(settingBuilder -> { + settingBuilder.applySettings(connectionPoolSettings); + }); - return MongoClients.create(builder.build()); + builder.applyConnectionString(new ConnectionString(mongodbUri)); + if (EmptyKit.isNotEmpty(mongodbUri) && + (mongodbUri.indexOf("tlsAllowInvalidCertificates=true") > 0 || + mongodbUri.indexOf("sslAllowInvalidCertificates=true") > 0)) { + builder.applyToSslSettings(sslSettingBuilder -> { + SSLContext sslContext = null; + try { + sslContext = SSLContext.getInstance("SSL"); + } catch (NoSuchAlgorithmException e) { + throw new RuntimeException(String.format("Create ssl context failed %s", e.getMessage()), e); + } + try { + sslContext.init(null, new TrustManager[]{new X509TrustManager() { + @Override + public void checkClientTrusted(X509Certificate[] x509Certificates, String s) throws CertificateException { + } + + @Override + public void checkServerTrusted(X509Certificate[] x509Certificates, String s) throws CertificateException { + } + + @Override + public X509Certificate[] getAcceptedIssuers() { + return null; + } + }}, new SecureRandom()); + } catch (KeyManagementException e) { + throw new RuntimeException(String.format("Initialize ssl context failed %s", e.getMessage()), e); + } + sslSettingBuilder.enabled(true).context(sslContext).invalidHostNameAllowed(true); + }); + + } else if(mongodbConfig.isSsl()) { + sslMongoClientOption(mongodbConfig.isSslValidate(), mongodbConfig.getSslCA(), + mongodbConfig.getSslKey(), mongodbConfig.getSslPass(), mongodbConfig.getCheckServerIdentity(), builder); + } + return builder; } public static void sslMongoClientOption(boolean sslValidate, String sslCA, String sslClientPem, String sslPass, diff --git a/connectors/mongodb-connector/src/main/java/io/tapdata/mongodb/reader/MongodbV4StreamReader.java b/connectors/mongodb-connector/src/main/java/io/tapdata/mongodb/reader/MongodbV4StreamReader.java index af40fd7da..c2304ba48 100644 --- a/connectors/mongodb-connector/src/main/java/io/tapdata/mongodb/reader/MongodbV4StreamReader.java +++ b/connectors/mongodb-connector/src/main/java/io/tapdata/mongodb/reader/MongodbV4StreamReader.java @@ -98,7 +98,7 @@ public MongodbV4StreamReader initAcceptor(int batchSize, TapStreamReadConsumer tableList, O // List pipeline1 = asList(Aggregates.match(Filters.or(collList))); FullDocument fullDocumentOption = FullDocument.DEFAULT; FullDocumentBeforeChange fullDocumentBeforeChangeOption = FullDocumentBeforeChange.WHEN_AVAILABLE; - if (mongodbConfig.isEnableFillingModifiedData() && !isPreImage ) { + if (mongodbConfig.isEnableFillingModifiedData() || isPreImage) { fullDocumentOption = FullDocument.UPDATE_LOOKUP; } while (running.get()) { @@ -306,12 +306,12 @@ private OffsetEvent emit(ChangeStreamDocument event) { if (null != updateDescription.getUpdatedFields()) { Document decodeUpdateDocument = new DocumentCodec().decode(new BsonDocumentReader(updateDescription.getUpdatedFields()), DecoderContext.builder().build()); - decodeUpdateDocument.forEach((k, v) -> { - if (k.contains(".")) { - return; - } - after.put(k, v); - }); + after.putAll(decodeUpdateDocument); + before.forEach((k, v) -> { + if (!after.containsKey(k) && after.keySet().stream().noneMatch(ak -> ak.startsWith(k + "."))) { + after.put(k, v); + } + }); } TapUpdateRecordEvent recordEvent = updateDMLEvent(before, after, collectionName); diff --git a/connectors/mongodb-connector/src/main/java/io/tapdata/mongodb/reader/v3/MongodbV3StreamReader.java b/connectors/mongodb-connector/src/main/java/io/tapdata/mongodb/reader/v3/MongodbV3StreamReader.java index b9b20bbdc..e3daaf250 100644 --- a/connectors/mongodb-connector/src/main/java/io/tapdata/mongodb/reader/v3/MongodbV3StreamReader.java +++ b/connectors/mongodb-connector/src/main/java/io/tapdata/mongodb/reader/v3/MongodbV3StreamReader.java @@ -88,7 +88,7 @@ public class MongodbV3StreamReader implements MongodbStreamReader { @Override public void onStart(MongodbConfig mongodbConfig) { this.mongodbConfig = mongodbConfig; - mongoClient = MongodbUtil.createMongoClient(mongodbConfig); + mongoClient = MongodbUtil.createMongoClient(mongodbConfig,true); nodesURI = MongodbUtil.nodesURI(mongoClient, mongodbConfig.getUri()); running.compareAndSet(false, true); @@ -145,8 +145,7 @@ public void read(TapConnectorContext connectorContext, List tableList, O @Override protected void report(BsonTimestamp bsonTimestamp) throws InterruptedException { while (running.get()) { - HeartbeatEvent heartbeatEvent = new HeartbeatEvent().referenceTime(bsonTimestamp.getTime() * 1000L); - heartbeatEvent.setTime(heartbeatEvent.getReferenceTime()); + HeartbeatEvent heartbeatEvent = new HeartbeatEvent().init().referenceTime(bsonTimestamp.getTime() * 1000L); if (tapEventQueue.offer( new TapEventOffset( heartbeatEvent, @@ -238,7 +237,7 @@ private void readFromOplog(TapConnectorContext connectorContext, String replicaS final Bson fromMigrateFilter = Filters.exists("fromMigrate", false); - try (MongoClient mongoclient = MongoClients.create(mongodbURI)) { + try (MongoClient mongoclient = MongoClients.create(MongodbUtil.appendDefaultHaTimeoutOptions(mongodbURI,true))) { final MongoCollection oplogCollection = mongoclient.getDatabase(LOCAL_DATABASE).getCollection(OPLOG_COLLECTION); try (final MongoCursor mongoCursor = oplogCollection.find(fromMigrateFilter).sort(new Document("$natural", 1)).limit(1).cursorType(CursorType.TailableAwait) .noCursorTimeout(true).iterator()) { diff --git a/connectors/mongodb-connector/src/main/java/io/tapdata/mongodb/util/MapDiffUtil.java b/connectors/mongodb-connector/src/main/java/io/tapdata/mongodb/util/MapDiffUtil.java index c1a092494..2847f48a0 100644 --- a/connectors/mongodb-connector/src/main/java/io/tapdata/mongodb/util/MapDiffUtil.java +++ b/connectors/mongodb-connector/src/main/java/io/tapdata/mongodb/util/MapDiffUtil.java @@ -97,6 +97,7 @@ private static void compareMapRecursively(Map beforeMap, // Check if the key exists in after if (!afterMap.containsKey(key)) { // Key does not exist in after, record difference (without path types) + if(key.equals("_id"))continue; KeyDiffInfo diffInfo = new KeyDiffInfo(key, fullPath, null); result.add(diffInfo); } else { diff --git a/connectors/mongodb-connector/src/main/java/io/tapdata/mongodb/util/SSLUtil.java b/connectors/mongodb-connector/src/main/java/io/tapdata/mongodb/util/SSLUtil.java index 9d5367866..7068f1805 100644 --- a/connectors/mongodb-connector/src/main/java/io/tapdata/mongodb/util/SSLUtil.java +++ b/connectors/mongodb-connector/src/main/java/io/tapdata/mongodb/util/SSLUtil.java @@ -2,6 +2,12 @@ import io.tapdata.kit.EmptyKit; import org.apache.commons.collections4.CollectionUtils; +import org.bouncycastle.asn1.pkcs.PrivateKeyInfo; +import org.bouncycastle.openssl.PEMParser; +import org.bouncycastle.openssl.jcajce.JcaPEMKeyConverter; +import org.bouncycastle.openssl.jcajce.JceOpenSSLPKCS8DecryptorProviderBuilder; +import org.bouncycastle.operator.InputDecryptorProvider; +import org.bouncycastle.pkcs.PKCS8EncryptedPrivateKeyInfo; import javax.net.ssl.*; import javax.xml.bind.DatatypeConverter; @@ -90,7 +96,7 @@ public static KeyStore createKeyStore(String privateKey, List certificat final KeyStore keystore = KeyStore.getInstance("JKS"); keystore.load(null); // Import private key - final PrivateKey key = createPrivateKey(privateKey); + final PrivateKey key = createPrivateKey(privateKey, password); keystore.setKeyEntry("", key, password.toCharArray(), x509Certificates); return keystore; } @@ -198,10 +204,49 @@ public static String retrivePrivateKey(String privatePEMString) { return b.toString(); } + @Deprecated private static PrivateKey createPrivateKey(String privateKey) throws Exception { + return createPrivateKey(privateKey, null); + } + + protected static PrivateKey createPrivateKey(String privateKey, String password) throws Exception { + final byte[] keyBytes = DatatypeConverter.parseBase64Binary(privateKey); + + if (password != null && !password.isEmpty()) { + java.security.Security.addProvider( + new org.bouncycastle.jce.provider.BouncyCastleProvider() + ); + String pemContent = "-----BEGIN ENCRYPTED PRIVATE KEY-----\n" + + privateKey + "\n" + + "-----END ENCRYPTED PRIVATE KEY-----"; + + try (PEMParser pemParser = new PEMParser(new StringReader(pemContent))) { + Object object = pemParser.readObject(); + + if (object instanceof PKCS8EncryptedPrivateKeyInfo) { + PKCS8EncryptedPrivateKeyInfo encryptedInfo = (PKCS8EncryptedPrivateKeyInfo) object; + + InputDecryptorProvider decryptorProvider = + new JceOpenSSLPKCS8DecryptorProviderBuilder() + .setProvider("BC") + .build(password.toCharArray()); + + PrivateKeyInfo privateKeyInfo = encryptedInfo.decryptPrivateKeyInfo(decryptorProvider); + + return new JcaPEMKeyConverter() + .setProvider("BC") + .getPrivateKey(privateKeyInfo); + } else if (object instanceof PrivateKeyInfo) { + return new JcaPEMKeyConverter() + .setProvider("BC") + .getPrivateKey((PrivateKeyInfo) object); + } + } catch (Exception e) { + throw new Exception("Failed to decrypt private key: " + e.getMessage(), e); + } + } - final byte[] bytes = DatatypeConverter.parseBase64Binary(privateKey); - return generatePrivateKeyFromDER(bytes); + return generatePrivateKeyFromDER(keyBytes); } private static X509Certificate[] createCertificates(List certificates) throws Exception { diff --git a/connectors/mongodb-connector/src/main/java/io/tapdata/mongodb/writer/MongodbMergeOperate.java b/connectors/mongodb-connector/src/main/java/io/tapdata/mongodb/writer/MongodbMergeOperate.java index e9873ae59..1766ba248 100644 --- a/connectors/mongodb-connector/src/main/java/io/tapdata/mongodb/writer/MongodbMergeOperate.java +++ b/connectors/mongodb-connector/src/main/java/io/tapdata/mongodb/writer/MongodbMergeOperate.java @@ -124,7 +124,8 @@ public static void recursiveMerge( mergeFilter, topLevel, 1, - updateKeyDiffInfos + updateKeyDiffInfos, + extractMainTableKeys(mergeBundle, topLevel) ); } @@ -142,6 +143,39 @@ public static void recursiveMerge( int topLevel, int loopTime, List updateKeyDiffInfos + ) { + recursiveMerge(mergeBundle, properties, mergeResults, mergeLookupResults, mergeResult, unsetResult, + updateJoinKeys, sharedJoinKeys, parentProperties, mergeFilter, topLevel, loopTime, + updateKeyDiffInfos, extractMainTableKeys(mergeBundle, topLevel)); + } + + private static Set extractMainTableKeys(MergeBundle mergeBundle, int topLevel) { + if (topLevel != 1 || mergeBundle == null) { + return Collections.emptySet(); + } + Map after = mergeBundle.getAfter(); + Map source = MapUtils.isNotEmpty(after) ? after : mergeBundle.getBefore(); + if (MapUtils.isEmpty(source)) { + return Collections.emptySet(); + } + return new HashSet<>(source.keySet()); + } + + private static void recursiveMerge( + MergeBundle mergeBundle, + MergeTableProperties properties, + List mergeResults, + List mergeLookupResults, + MergeResult mergeResult, + MergeResult unsetResult, + Map updateJoinKeys, + Set sharedJoinKeys, + MergeTableProperties parentProperties, + MergeFilter mergeFilter, + int topLevel, + int loopTime, + List updateKeyDiffInfos, + Set mainTableKeys ) { boolean unsetResultNull = null == unsetResult; MergeResult updateKeyDiff = null; @@ -153,7 +187,7 @@ public static void recursiveMerge( } break; case updateWrite: - unsetResult = updateWriteUnsetMerge(mergeBundle, properties, updateJoinKeys, unsetResult, sharedJoinKeys, mergeFilter, topLevel); + unsetResult = updateWriteUnsetMerge(mergeBundle, properties, updateJoinKeys, unsetResult, sharedJoinKeys, mergeFilter, topLevel, loopTime, mainTableKeys); if (unsetResultNull) { addUnsetMerge(mergeResults, unsetResult); } @@ -198,7 +232,8 @@ public static void recursiveMerge( mergeFilter, topLevel, privateLoopTime, - null + null, + mainTableKeys ); recursiveOnce = true; } @@ -266,7 +301,8 @@ private static MergeResult addMergeResults(List mergeResults, Merge private static MergeResult updateWriteUnsetMerge( MergeBundle mergeBundle, MergeTableProperties currentProperty, Map updateJoinKeys, - MergeResult mergeResult, Set sharedJoinKeys, MergeFilter mergeFilter, int topLevel) { + MergeResult mergeResult, Set sharedJoinKeys, MergeFilter mergeFilter, int topLevel, + int loopTime, Set mainTableKeys) { if (null == currentProperty) { return mergeResult; } @@ -289,10 +325,11 @@ private static MergeResult updateWriteUnsetMerge( MergeInfo.UpdateJoinKey updateJoinKey = updateJoinKeys.get(id); Map updateJoinKeyAfter = updateJoinKey.getAfter(); Map updateJoinKeyBefore = updateJoinKey.getBefore(); + Map updateJoinKeyParentBefore = updateJoinKey.getParentBefore(); List> joinKeys = currentProperty.getJoinKeys(); Document filter; mergeResult = new MergeResult(); - filter = unsetFilter(updateJoinKeyBefore, updateJoinKeyAfter, joinKeys, topLevel); + filter = unsetFilterForUpdateJoinKey(updateJoinKeyBefore, updateJoinKeyAfter, joinKeys, topLevel, loopTime,updateJoinKeyParentBefore); if (null != updateJoinKey.getParentBefore()) { filter.putAll(updateJoinKey.getParentBefore()); } @@ -314,7 +351,15 @@ private static MergeResult updateWriteUnsetMerge( if (null == mergeResult.getOperation()) { mergeResult.setOperation(MergeResult.Operation.UPDATE); } - Document unsetDoc = buildUnsetDocument(sharedJoinKeys, after, targetPath, isArray, firstMergeResult); + Set effectiveSharedJoinKeys = sharedJoinKeys; + if (EmptyKit.isEmpty(targetPath) && CollectionUtils.isNotEmpty(mainTableKeys)) { + effectiveSharedJoinKeys = new HashSet<>(); + if (sharedJoinKeys != null) { + effectiveSharedJoinKeys.addAll(sharedJoinKeys); + } + effectiveSharedJoinKeys.addAll(mainTableKeys); + } + Document unsetDoc = buildUnsetDocument(effectiveSharedJoinKeys, after, targetPath, isArray, firstMergeResult); Document update = mergeResult.getUpdate(); if (update.containsKey(UNSET_KEY)) { update.get(UNSET_KEY, Document.class).putAll(unsetDoc); @@ -481,8 +526,6 @@ public static void updateMerge(MergeBundle mergeBundle, MergeTableProperties cur MergeBundle.EventOperation operation = mergeBundle.getOperation(); Map before = mergeBundle.getBefore(); Map after = mergeBundle.getAfter(); - String targetPath = MergeUtils.dynamicKey(currentProperty.getTargetPath(), after); - removeIdIfNeed(after, currentProperty); Map filterMap = buildFilterMap(operation, after, before); Document filter = filter( filterMap, @@ -510,8 +553,10 @@ public static void updateMerge(MergeBundle mergeBundle, MergeTableProperties cur appendAllParentMergeFilters(mergeResult, mergeFilter); Map value = MapUtils.isNotEmpty(after) ? after : before; + removeIdIfNeed(value, currentProperty); Map removeFields = mergeBundle.getRemovefields(); + String targetPath = MergeUtils.dynamicKey(currentProperty.getTargetPath(), value); String updatePatch = targetPath; if (array) { if (targetPath.contains(".")) { @@ -771,8 +816,10 @@ protected static Document buildUnsetDocument(Set sharedJoinKeys, Map isShareJoinKey(sharedJoinKeys, key)); } @@ -849,20 +896,31 @@ private static Document filter(Map data, List before, Map after, List> joinKeys, int topLevel) { + return unsetFilter(before, after, joinKeys, topLevel == 1); + } + + protected static Document unsetFilterForUpdateJoinKey(Map before, Map after, + List> joinKeys, int topLevel, int loopTime,Map updateJoinKeyParentBefore){ + return unsetFilter(before, after, joinKeys, shouldUseAfterForUpdateJoinKeyUnsetFilter(topLevel, loopTime,updateJoinKeyParentBefore)); + } + + private static Document unsetFilter(Map before, Map after, List> joinKeys, boolean useAfter) { Document document = new Document(); for (Map joinKey : joinKeys) { String key = joinKey.get("target"); - Object value; - if (topLevel == 1) { - value = MapUtil.getValueByKey(after, key); - } else { - value = MapUtil.getValueByKey(before, key); + Map filter = useAfter ? after : before; + if(MapUtil.containsKey(filter, key)){ + Object value = MapUtil.getValueByKey(filter, key); + document.put(key, value); } - document.put(key, value); } return document; } + private static boolean shouldUseAfterForUpdateJoinKeyUnsetFilter(int topLevel, int loopTime,Map updateJoinKeyParentBefore) { + return topLevel == 1 && (loopTime > 1 || MapUtils.isNotEmpty(updateJoinKeyParentBefore)); + } + private static List arrayFilter(Map data, List> joinKeys, String arrayPath) { List arrayFilter = new ArrayList<>(); Document filter = new Document(); diff --git a/connectors/mongodb-connector/src/main/java/io/tapdata/mongodb/writer/MongodbWriter.java b/connectors/mongodb-connector/src/main/java/io/tapdata/mongodb/writer/MongodbWriter.java index c6635cb01..94e726a71 100644 --- a/connectors/mongodb-connector/src/main/java/io/tapdata/mongodb/writer/MongodbWriter.java +++ b/connectors/mongodb-connector/src/main/java/io/tapdata/mongodb/writer/MongodbWriter.java @@ -14,6 +14,7 @@ import io.tapdata.entity.event.dml.TapRecordEvent; import io.tapdata.entity.event.dml.TapUpdateRecordEvent; import io.tapdata.entity.logger.Log; +import io.tapdata.entity.logger.TapLogger; import io.tapdata.entity.schema.TapTable; import io.tapdata.entity.utils.cache.KVMap; import io.tapdata.kit.DbKit; @@ -23,6 +24,7 @@ import io.tapdata.mongodb.reader.MongodbV4StreamReader; import io.tapdata.mongodb.util.MongodbLookupUtil; import io.tapdata.mongodb.writer.error.BulkWriteErrorCodeHandlerEnum; +import io.tapdata.mongodb.writer.error.IgnoreWriteModel; import io.tapdata.mongodb.writer.error.TapMongoBulkWriteException; import io.tapdata.pdk.apis.entity.ConnectionOptions; import io.tapdata.pdk.apis.entity.WriteListResult; @@ -32,6 +34,14 @@ import org.apache.commons.collections4.MapUtils; import org.bson.Document; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.StandardOpenOption; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; import java.util.*; import java.util.concurrent.atomic.AtomicLong; import java.util.concurrent.atomic.AtomicReference; @@ -54,12 +64,18 @@ public class MongodbWriter { private final Log tapLogger; private boolean is_cloud; - private final Map> shardKeyMap; + private final Map> shardKeyMap; private String insertPolicy; private String updatePolicy; private final Map sessionMap; - public MongodbWriter(KVMap globalStateMap, MongodbConfig mongodbConfig, MongoClient mongoClient, Log tapLogger, Map> shardKeyMap, Map sessionMap) { + // Error file cleanup configuration + private volatile long lastCleanupTime = 0; + private static final long CLEANUP_INTERVAL_MS = 60 * 60 * 1000; // 1 hour + private static final int ERROR_FILE_RETENTION_DAYS = 7; // Keep error files for 7 days + private static final int ERROR_FILE_MAX_COUNT = 100; // Keep at most 100 error files + + public MongodbWriter(KVMap globalStateMap, MongodbConfig mongodbConfig, MongoClient mongoClient, Log tapLogger, Map> shardKeyMap, Map sessionMap) { this.globalStateMap = globalStateMap; this.mongoClient = mongoClient; this.mongoDatabase = mongoClient.getDatabase(mongodbConfig.getDatabase()); @@ -112,14 +128,14 @@ public void writeRecord(List tapRecordEvents, TapTable table, Co } } - private void doubleActiveWrite(List tapRecordEvents, TapTable table, Consumer> writeListResultConsumer, ClientSession session) throws Throwable { - Document doubleActiveDoc = new Document("_id", "aaaaaaaa"); - UpdateOptions options = new UpdateOptions().upsert(true); - mongoDatabase.getCollection("_tap_double_active").updateOne(session, doubleActiveDoc, new Document("$set", new Document("ts", System.currentTimeMillis())), options); - write(table, tapRecordEvents, writeListResultConsumer, session); - } + private void doubleActiveWrite(List tapRecordEvents, TapTable table, Consumer> writeListResultConsumer, ClientSession session) throws Throwable { + Document doubleActiveDoc = new Document("_id", "aaaaaaaa"); + UpdateOptions options = new UpdateOptions().upsert(true); + mongoDatabase.getCollection("_tap_double_active").updateOne(session, doubleActiveDoc, new Document("$set", new Document("ts", System.currentTimeMillis())), options); + write(table, tapRecordEvents, writeListResultConsumer, session); + } - private void write(TapTable table, List tapRecordEvents, Consumer> writeListResultConsumer, ClientSession session) throws Throwable { + private void write(TapTable table, List tapRecordEvents, Consumer> writeListResultConsumer, ClientSession session) throws Throwable { AtomicLong inserted = new AtomicLong(0); //insert count AtomicLong updated = new AtomicLong(0); //update count AtomicLong deleted = new AtomicLong(0); //delete count @@ -145,6 +161,8 @@ private void write(TapTable table, List tapRecordEvents, Consume BulkWriteOptions bulkWriteOptions; AtomicReference mongoBulkWriteException = new AtomicReference<>(); + boolean errorContextDumped = false; + while (!bulkWriteModel.isEmpty()) { bulkWriteOptions = buildBulkWriteOptions(bulkWriteModel); try { @@ -156,6 +174,18 @@ private void write(TapTable table, List tapRecordEvents, Consume } bulkWriteModel.clearAll(); } catch (MongoBulkWriteException e) { + // Dump error context only once per batch to avoid duplicate files across retries. + if (!errorContextDumped) { + try { + String dumpPath = dumpBulkWriteErrorContext(e, bulkWriteModel, bulkWriteOptions, collection); + if (dumpPath != null) { + TapLogger.info("MongoDB bulk write context saved to file: ", dumpPath); + } + } catch (Throwable ignored) { + // Never affect main flow + } + errorContextDumped = true; + } Consumer errorConsumer = mongoBulkWriteException::set; if (!handleBulkWriteError(e, bulkWriteModel, bulkWriteOptions, collection, errorConsumer)) { if (null != mongoBulkWriteException.get()) { @@ -221,22 +251,24 @@ public void writeUpdateRecordWithLog(TapRecordEvent tapRecordEvent, TapTable tab tapLogger.info("update record ignored: {}", tapRecordEvent); } writeListResultConsumer.accept(writeListResult - .insertedCount(0) - .modifiedCount(1) - .removedCount(0)); + .insertedCount(0) + .modifiedCount(1) + .removedCount(0)); } private boolean handleBulkWriteError( - MongoBulkWriteException originMongoBulkWriteException, - BulkWriteModel bulkWriteModel, - BulkWriteOptions bulkWriteOptions, - MongoCollection collection, - Consumer errorConsumer + MongoBulkWriteException originMongoBulkWriteException, + BulkWriteModel bulkWriteModel, + BulkWriteOptions bulkWriteOptions, + MongoCollection collection, + Consumer errorConsumer ) { List writeErrors = originMongoBulkWriteException.getWriteErrors(); List cantHandleErrors = new ArrayList<>(); List> retryWriteModels = new ArrayList<>(); List handledIndexes = new ArrayList<>(); + List ignoredIndexes = new ArrayList<>(); + for (BulkWriteError writeError : writeErrors) { int code = writeError.getCode(); int index = writeError.getIndex(); @@ -249,8 +281,12 @@ private boolean handleBulkWriteError( } catch (Exception ignored) { } if (null != retryWriteModel) { - retryWriteModels.add(retryWriteModel); handledIndexes.add(index); + if (retryWriteModel == IgnoreWriteModel.INSTANCE) { + ignoredIndexes.add(index); + } else { + retryWriteModels.add(retryWriteModel); + } } else { cantHandleErrors.add(writeError); } @@ -261,11 +297,11 @@ private boolean handleBulkWriteError( if (CollectionUtils.isNotEmpty(cantHandleErrors)) { // Keep errors that cannot handle MongoBulkWriteException mongoBulkWriteException = new MongoBulkWriteException( - originMongoBulkWriteException.getWriteResult(), - cantHandleErrors, - originMongoBulkWriteException.getWriteConcernError(), - originMongoBulkWriteException.getServerAddress(), - originMongoBulkWriteException.getErrorLabels() + originMongoBulkWriteException.getWriteResult(), + cantHandleErrors, + originMongoBulkWriteException.getWriteConcernError(), + originMongoBulkWriteException.getServerAddress(), + originMongoBulkWriteException.getErrorLabels() ); List> errorWriteModels = new ArrayList<>(); cantHandleErrors.forEach(writeError -> errorWriteModels.add(bulkWriteModel.getWriteModels().get(writeError.getIndex()))); @@ -280,8 +316,13 @@ private boolean handleBulkWriteError( continue; } if (handledIndexes.contains(i)) { - newWriteModelList.add(retryWriteModels.get(0)); - retryWriteModels.remove(0); + if (ignoredIndexes.contains(i)) { + // skip ignored index + continue; + } else { + newWriteModelList.add(retryWriteModels.get(0)); + retryWriteModels.remove(0); + } } else { newWriteModelList.add(bulkWriteModel.getAllOpWriteModels().get(i)); } @@ -296,6 +337,163 @@ private boolean handleBulkWriteError( } } + protected String dumpBulkWriteErrorContext(MongoBulkWriteException ex, + BulkWriteModel bulkWriteModel, + BulkWriteOptions bulkWriteOptions, + MongoCollection collection) { + try { + String thread = Thread.currentThread().getName(); + String ns = collection != null ? collection.getNamespace().getFullName() : "unknown"; + String database = collection != null ? collection.getNamespace().getDatabaseName() : "unknown"; + String collectionName = collection != null ? collection.getNamespace().getCollectionName() : "unknown"; + String sanitizedThread = thread.replaceAll("[^a-zA-Z0-9_.-]", "_"); + String dateMinute = DateTimeFormatter.ofPattern("yyyyMMdd_HHmm").format(LocalDateTime.now()); + + String fileName = "mongodb_bulk_write_error_" + dateMinute + "_" + sanitizedThread + ".log"; + String baseTmp = System.getProperty("java.io.tmpdir", "."); + Path dir = Paths.get(baseTmp, "tapdata-mongodb-errors"); + String content = buildBulkWriteErrorContent(ex, bulkWriteModel, bulkWriteOptions, ns, database, collectionName, thread, dateMinute); + try { + Files.createDirectories(dir); + Path filePath = dir.resolve(fileName); + Files.write(filePath, content.getBytes(StandardCharsets.UTF_8), StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING, StandardOpenOption.WRITE); + // Trigger cleanup after successful write + cleanupErrorFilesIfNeeded(dir); + return filePath.toAbsolutePath().toString(); + } catch (Throwable t1) { + // Fallback to current working directory + String userDir = System.getProperty("user.dir", "."); + Path fallbackDir = Paths.get(userDir, "tapdata-mongodb-errors"); + try { + Files.createDirectories(fallbackDir); + Path filePath = fallbackDir.resolve(fileName); + Files.write(filePath, content.getBytes(StandardCharsets.UTF_8), StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING, StandardOpenOption.WRITE); + // Trigger cleanup after successful write + cleanupErrorFilesIfNeeded(fallbackDir); + return filePath.toAbsolutePath().toString(); + } catch (Throwable ignored) { + return null; + } + } + } catch (Throwable ignored) { + return null; + } + } + + private String buildBulkWriteErrorContent(MongoBulkWriteException ex, + BulkWriteModel bulkWriteModel, + BulkWriteOptions bulkWriteOptions, + String namespace, + String database, + String collectionName, + String thread, + String dateMinute) { + StringBuilder sb = new StringBuilder(8192); + sb.append("==== MongoDB Bulk Write Error Dump ====") + .append('\n'); + sb.append("dateMinute=").append(dateMinute).append(" thread=").append(thread).append('\n'); + sb.append("database=").append(database).append(" collection=").append(collectionName).append('\n'); + sb.append("namespace=").append(namespace).append('\n'); + sb.append("bulkWriteOptions.ordered=").append(bulkWriteOptions != null && bulkWriteOptions.isOrdered()).append('\n'); + sb.append("bulkWriteOptions.toString=").append(bulkWriteOptions).append('\n'); + Map> errorByIndex = new HashMap<>(); + + if (ex != null) { + sb.append("exceptionClass=").append(ex.getClass().getName()).append('\n'); + sb.append("exceptionMessage=").append(ex.getMessage()).append('\n'); + sb.append("serverAddress=").append(ex.getServerAddress()).append('\n'); + sb.append("writeConcernError=").append(ex.getWriteConcernError()).append('\n'); + List errs = ex.getWriteErrors(); + if (errs != null && !errs.isEmpty()) { + for (BulkWriteError we : errs) { + errorByIndex.computeIfAbsent(we.getIndex(), k -> new ArrayList<>()).add(we); + } + } + if (!errs.isEmpty()) { + sb.append("writeErrors(size=").append(errs.size()).append("):").append('\n'); + for (int i = 0; i < errs.size(); i++) { + BulkWriteError we = errs.get(i); + sb.append(" [").append(i).append("] index=").append(we.getIndex()) + .append(" code=").append(we.getCode()) + .append(" message=").append(we.getMessage()) + .append(" details=").append(we.getDetails()) + .append('\n'); + } + } + } + // Dump write models + try { + List> allOps = bulkWriteModel != null ? bulkWriteModel.getAllOpWriteModels() : null; + List> onlyInserts = bulkWriteModel != null ? bulkWriteModel.getOnlyInsertWriteModels() : null; + if (allOps != null) { + sb.append("allOpWriteModels(size=").append(allOps.size()).append("):").append('\n'); + for (int i = 0; i < allOps.size(); i++) { + sb.append(" [").append(i).append("] ").append(describeWriteModel(allOps.get(i))).append('\n'); + List idxErrs = errorByIndex.get(i); + if (idxErrs != null && !idxErrs.isEmpty()) { + for (BulkWriteError we : idxErrs) { + sb.append(" -> error index=").append(we.getIndex()) + .append(" code=").append(we.getCode()) + .append(" message=").append(we.getMessage()) + .append(" details=").append(we.getDetails()) + .append('\n'); + } + } + } + } + if (onlyInserts != null) { + sb.append("onlyInsertWriteModels(size=").append(onlyInserts.size()).append("):").append('\n'); + for (int i = 0; i < onlyInserts.size(); i++) { + sb.append(" [").append(i).append("] ").append(describeWriteModel(onlyInserts.get(i))).append('\n'); + } + } + } catch (Throwable ignored) { + // ignore model rendering errors + } + sb.append("==== END ====").append('\n'); + return sb.toString(); + } + + + private String describeWriteModel(WriteModel model) { + if (model == null) return "null"; + try { + if (model instanceof InsertOneModel) { + InsertOneModel m = (InsertOneModel) model; + return "InsertOneModel document=" + safeToJson(m.getDocument()); + } else if (model instanceof UpdateOneModel) { + UpdateOneModel m = (UpdateOneModel) model; + return "UpdateOneModel filter=" + safeToJson(m.getFilter()) + " update=" + safeToJson(m.getUpdate()) + " options=" + m.getOptions(); + } else if (model instanceof UpdateManyModel) { + UpdateManyModel m = (UpdateManyModel) model; + return "UpdateManyModel filter=" + safeToJson(m.getFilter()) + " update=" + safeToJson(m.getUpdate()) + " options=" + m.getOptions(); + } else if (model instanceof ReplaceOneModel) { + ReplaceOneModel m = (ReplaceOneModel) model; + return "ReplaceOneModel filter=" + safeToJson(m.getFilter()) + " replacement=" + safeToJson(m.getReplacement()) + " options=" + m.getReplaceOptions(); + } else if (model instanceof DeleteOneModel) { + DeleteOneModel m = (DeleteOneModel) model; + return "DeleteOneModel filter=" + safeToJson(m.getFilter()); + } else if (model instanceof DeleteManyModel) { + DeleteManyModel m = (DeleteManyModel) model; + return "DeleteManyModel filter=" + safeToJson(m.getFilter()); + } else { + return String.valueOf(model); + } + } catch (Throwable t) { + return String.valueOf(model); + } + } + + private String safeToJson(Object obj) { + try { + if (obj == null) return "null"; + if (obj instanceof Document) return ((Document) obj).toJson(); + return String.valueOf(obj); + } catch (Throwable ignored) { + return String.valueOf(obj); + } + } + private BulkWriteModel buildBulkWriteModel(List tapRecordEvents, TapTable table, AtomicLong inserted, AtomicLong updated, AtomicLong deleted, Collection pks) { BulkWriteModel bulkWriteModel = new BulkWriteModel(pks.contains("_id")); for (TapRecordEvent recordEvent : tapRecordEvents) { @@ -330,6 +528,18 @@ private static BulkWriteOptions buildBulkWriteOptions(BulkWriteModel bulkWriteMo return bulkWriteOptions; } + private boolean isShardedCollection(String tableId) { + return shardKeyMap != null && shardKeyMap.containsKey(tableId); + } + + private WriteModel createUpdateModel(String tableId, Document filter, Document update, UpdateOptions options) { + if (isShardedCollection(tableId)) { + return new UpdateOneModel<>(filter, update, options); + } else { + return new UpdateManyModel<>(filter, update, options); + } + } + protected List> normalWriteMode(AtomicLong inserted, AtomicLong updated, AtomicLong deleted, UpdateOptions options, TapTable tapTable, Collection pks, TapRecordEvent recordEvent) { List> writeModels = new ArrayList<>(); if (recordEvent instanceof TapInsertRecordEvent) { @@ -354,17 +564,17 @@ protected List> normalWriteMode(AtomicLong inserted, Atomic } MongodbUtil.removeIdIfNeed(pks, insertRecordEvent.getAfter()); Document update = new Document(operation, insertRecordEvent.getAfter()); - writeModels.add(new UpdateManyModel<>(pkFilter, update, options)); + writeModels.add(createUpdateModel(tapTable.getId(), pkFilter, update, options)); if (MapUtils.isNotEmpty(unsetDoc)) { - writeModels.add(new UpdateManyModel<>(pkFilter, new Document("$unset", unsetDoc), options)); + writeModels.add(createUpdateModel(tapTable.getId(), pkFilter, new Document("$unset", unsetDoc), options)); } } else { if (CollectionUtils.isNotEmpty(pks) && MapUtils.isNotEmpty(unsetDoc)) { Document pkFilter = getPkFilter(pks, insertRecordEvent.getAfter()); Document update = new Document("$set", insertRecordEvent.getAfter()); - writeModels.add(new UpdateManyModel<>(pkFilter, update, options)); + writeModels.add(createUpdateModel(tapTable.getId(), pkFilter, update, options)); if (MapUtils.isNotEmpty(unsetDoc)) { - writeModels.add(new UpdateManyModel<>(pkFilter, new Document("$unset", unsetDoc), options)); + writeModels.add(createUpdateModel(tapTable.getId(), pkFilter, new Document("$unset", unsetDoc), options)); } } else { writeModels.add(new InsertOneModel<>(new Document(insertRecordEvent.getAfter()))); @@ -375,24 +585,24 @@ protected List> normalWriteMode(AtomicLong inserted, Atomic Collection allColumn = tapTable.getNameFieldMap().keySet(); TapUpdateRecordEvent updateRecordEvent = (TapUpdateRecordEvent) recordEvent; Map after = updateRecordEvent.getAfter(); - Map before = updateRecordEvent.getBefore(); - before = DbKit.getBeforeForUpdate(after, before, allColumn, pks); - if (!((TapUpdateRecordEvent) recordEvent).getIsReplaceEvent()) { - after = DbKit.getAfterForUpdateMongo(after, before, allColumn, pks); - } + Map before = updateRecordEvent.getBefore(); + before = DbKit.getBeforeForUpdate(after, before, allColumn, pks); + if (!((TapUpdateRecordEvent) recordEvent).getIsReplaceEvent()) { + after = DbKit.getAfterForUpdateMongo(after, before, allColumn, pks); + } Map info = recordEvent.getInfo(); Document pkFilter; Document u = new Document(); if (info != null && info.get("$op") != null) { WriteModel writeModel; - Object id = info.get("_id"); - id = MongodbUtil.convertValue(id); - pkFilter = new Document("_id", id); - ((Map) info.get("$op")).forEach((k, v) -> u.put(k, MongodbUtil.convertValue(v))); + Object id = info.get("_id"); + id = MongodbUtil.convertValue(id); + pkFilter = new Document("_id", id); + ((Map) info.get("$op")).forEach((k, v) -> u.put(k, MongodbUtil.convertValue(v))); u.remove("$v"); // Exists '$v' in update operation of MongoDB(v3.6), remove it because can't apply in write model. boolean isUpdate = u.keySet().stream().anyMatch(k -> k.startsWith("$")); if (isUpdate) { - writeModel = new UpdateManyModel<>(pkFilter, u, options); + writeModel = createUpdateModel(tapTable.getId(), pkFilter, u, options); options.upsert(false); } else { writeModel = new ReplaceOneModel<>(pkFilter, u, new ReplaceOptions().upsert(false)); @@ -409,10 +619,10 @@ protected List> normalWriteMode(AtomicLong inserted, Atomic } MongodbUtil.removeIdIfNeed(pks, after); u.append("$set", after); - writeModels.add(new UpdateManyModel<>(pkFilter, u, options)); + writeModels.add(createUpdateModel(tapTable.getId(), pkFilter, u, options)); Document unsetDoc = wrapUnset(recordEvent); if (MapUtils.isNotEmpty(unsetDoc)) { - writeModels.add(new UpdateManyModel<>(pkFilter, new Document("$unset", unsetDoc), options)); + writeModels.add(createUpdateModel(tapTable.getId(), pkFilter, new Document("$unset", unsetDoc), options)); } } } @@ -460,9 +670,9 @@ protected Document wrapUnset(TapRecordEvent tapRecordEvent) { /** * 过滤层级字段,保留最高层级的字段 * 例如:[a, a.b, a.c] -> [a] - * [a.b, a.c] -> [a.b, a.c] - * [a, a.b, a.b.c] -> [a] - * [a.b, a.b.c, a.b.e] -> [a.b] + * [a.b, a.c] -> [a.b, a.c] + * [a, a.b, a.b.c] -> [a] + * [a.b, a.b.c, a.b.e] -> [a.b] */ private List filterHierarchicalFields(List fields) { if (CollectionUtils.isEmpty(fields)) { @@ -499,8 +709,8 @@ private List filterHierarchicalFields(List fields) { /** * 判断field是否是parentField的子字段 * 例如:isChildField("a.b", "a") -> true - * isChildField("a.b.c", "a.b") -> true - * isChildField("a", "a.b") -> false + * isChildField("a.b.c", "a.b") -> true + * isChildField("a", "a.b") -> false */ private boolean isChildField(String field, String parentField) { return field.startsWith(parentField + "."); @@ -509,8 +719,8 @@ private boolean isChildField(String field, String parentField) { /** * 判断parentField是否是field的父字段 * 例如:isParentField("a", "a.b") -> true - * isParentField("a.b", "a.b.c") -> true - * isParentField("a.b", "a") -> false + * isParentField("a.b", "a.b.c") -> true + * isParentField("a.b", "a") -> false */ private boolean isParentField(String parentField, String field) { return field.startsWith(parentField + "."); @@ -549,4 +759,86 @@ public MongodbWriter dmlPolicy(String insertPolicy, String updatePolicy) { return this; } + /** + * Cleanup old error files if needed (with frequency limit) + * This method is called after each error file write, but actual cleanup only happens once per hour + * + * @param errorDir The directory containing error files + */ + private void cleanupErrorFilesIfNeeded(Path errorDir) { + long now = System.currentTimeMillis(); + if (now - lastCleanupTime < CLEANUP_INTERVAL_MS) { + return; // Skip cleanup to avoid frequent execution + } + lastCleanupTime = now; + cleanupErrorFiles(errorDir, ERROR_FILE_RETENTION_DAYS, ERROR_FILE_MAX_COUNT); + } + + /** + * Cleanup error files based on retention days and max file count + * Strategy: + * 1. First delete files older than retention days + * 2. If remaining files still exceed max count, delete oldest files + * + * @param errorDir The directory containing error files + * @param retentionDays Keep files for this many days + * @param maxFiles Keep at most this many files + */ + private void cleanupErrorFiles(Path errorDir, int retentionDays, int maxFiles) { + try { + if (!Files.exists(errorDir) || !Files.isDirectory(errorDir)) { + return; + } + + long cutoffTime = System.currentTimeMillis() - (retentionDays * 24L * 60 * 60 * 1000); + + List allFiles = Files.list(errorDir) + .filter(Files::isRegularFile) + .filter(p -> p.getFileName().toString().startsWith("mongodb_bulk_write_error_")) + .collect(java.util.stream.Collectors.toList()); + + // Step 1: Delete files older than retention period + List validFiles = new ArrayList<>(); + for (Path p : allFiles) { + try { + if (Files.getLastModifiedTime(p).toMillis() < cutoffTime) { + Files.delete(p); + tapLogger.debug("Deleted old error file (older than {} days): {}", retentionDays, p.getFileName()); + } else { + validFiles.add(p); + } + } catch (IOException e) { + tapLogger.warn("Failed to delete old error file: {}, error: {}", p, e.getMessage()); + } + } + + // Step 2: If remaining files still exceed max count, delete oldest files + if (validFiles.size() > maxFiles) { + validFiles.sort(Comparator.comparingLong(p -> { + try { + return Files.getLastModifiedTime(p).toMillis(); + } catch (IOException e) { + return 0L; + } + })); + + int filesToDelete = validFiles.size() - maxFiles; + for (int i = 0; i < filesToDelete; i++) { + try { + Files.delete(validFiles.get(i)); + tapLogger.debug("Deleted excess error file (exceeds max count {}): {}", maxFiles, validFiles.get(i).getFileName()); + } catch (IOException e) { + tapLogger.warn("Failed to delete excess error file: {}, error: {}", validFiles.get(i), e.getMessage()); + } + } + } + + tapLogger.debug("Error file cleanup completed. Directory: {}, retention days: {}, max files: {}", + errorDir, retentionDays, maxFiles); + } catch (Throwable t) { + // Never affect main flow + tapLogger.warn("Error during error file cleanup: {}", t.getMessage()); + } + } + } diff --git a/connectors/mongodb-connector/src/main/java/io/tapdata/mongodb/writer/error/BulkWriteErrorCodeHandlerEnum.java b/connectors/mongodb-connector/src/main/java/io/tapdata/mongodb/writer/error/BulkWriteErrorCodeHandlerEnum.java index ba1f94666..a360ca9d5 100644 --- a/connectors/mongodb-connector/src/main/java/io/tapdata/mongodb/writer/error/BulkWriteErrorCodeHandlerEnum.java +++ b/connectors/mongodb-connector/src/main/java/io/tapdata/mongodb/writer/error/BulkWriteErrorCodeHandlerEnum.java @@ -3,6 +3,7 @@ import io.tapdata.mongodb.writer.error.handler.Code11000Handler; import io.tapdata.mongodb.writer.error.handler.Code28Handler; import io.tapdata.mongodb.writer.error.handler.Code2Handler; +import io.tapdata.mongodb.writer.error.handler.Code72Handler; import java.util.HashMap; import java.util.Map; @@ -14,7 +15,8 @@ **/ public enum BulkWriteErrorCodeHandlerEnum { CODE_28(28, new Code28Handler()), -// CODE_11000(11000, new Code11000Handler()), + CODE_72(72, new Code72Handler()), + CODE_11000(11000, new Code11000Handler()), CODE_2(2, new Code2Handler()), ; private final int code; diff --git a/connectors/mongodb-connector/src/main/java/io/tapdata/mongodb/writer/error/IgnoreWriteModel.java b/connectors/mongodb-connector/src/main/java/io/tapdata/mongodb/writer/error/IgnoreWriteModel.java new file mode 100644 index 000000000..6b78866db --- /dev/null +++ b/connectors/mongodb-connector/src/main/java/io/tapdata/mongodb/writer/error/IgnoreWriteModel.java @@ -0,0 +1,22 @@ +package io.tapdata.mongodb.writer.error; + +import com.mongodb.client.model.InsertOneModel; +import com.mongodb.client.model.WriteModel; +import org.bson.Document; + +/** + * Marker class for indicating that a write operation should be ignored (no retry, no error). + * Uses a special singleton InsertOneModel instance as a marker. + */ +public final class IgnoreWriteModel { + /** + * Special marker instance to indicate an operation should be ignored. + * This is a singleton InsertOneModel with a special marker document. + */ + public static final WriteModel INSTANCE = new InsertOneModel<>(new Document("__IGNORE_MARKER__", true)); + + private IgnoreWriteModel() { + // Prevent instantiation + } +} + diff --git a/connectors/mongodb-connector/src/main/java/io/tapdata/mongodb/writer/error/handler/Code11000Handler.java b/connectors/mongodb-connector/src/main/java/io/tapdata/mongodb/writer/error/handler/Code11000Handler.java index 62e89b867..0c90a0b2f 100644 --- a/connectors/mongodb-connector/src/main/java/io/tapdata/mongodb/writer/error/handler/Code11000Handler.java +++ b/connectors/mongodb-connector/src/main/java/io/tapdata/mongodb/writer/error/handler/Code11000Handler.java @@ -4,24 +4,19 @@ import com.mongodb.bulk.BulkWriteError; import com.mongodb.client.MongoCollection; import com.mongodb.client.model.BulkWriteOptions; -import com.mongodb.client.model.InsertOneModel; -import com.mongodb.client.model.UpdateManyModel; import com.mongodb.client.model.WriteModel; -import io.tapdata.entity.logger.TapLogger; +import io.tapdata.mongodb.util.MapUtil; import io.tapdata.mongodb.writer.BulkWriteModel; import io.tapdata.mongodb.writer.error.BulkWriteErrorHandler; -import org.apache.commons.collections4.CollectionUtils; +import io.tapdata.mongodb.writer.error.IgnoreWriteModel; import org.bson.Document; -import java.util.List; - /** * @author samuel * @Description Duplicate key error * @create 2023-04-23 19:10 **/ public class Code11000Handler implements BulkWriteErrorHandler { - private static final String TAG = Code11000Handler.class.getSimpleName(); @Override public WriteModel handle( @@ -32,54 +27,37 @@ public WriteModel handle( BulkWriteError writeError, MongoCollection collection ) { - if (bulkWriteModel.isAllInsert() || isContainDocument(bulkWriteModel, writeError)) { - int index = writeError.getIndex(); - List> allOpWriteModels = bulkWriteModel.getAllOpWriteModels(); - if (CollectionUtils.isEmpty(allOpWriteModels)) { + // When duplicate key error occurs, check by filter whether the target document already exists. + // If exists, mark current operation to be ignored (no retry, no error) + try { + Document filter = updateModelFilter(writeModel); + Document update = updateModelUpdate(writeModel); + if (filter == null || filter.isEmpty() || update == null || update.isEmpty() || collection == null) { return null; } - try { - return allOpWriteModels.get(index); - } catch (Exception ignored) { - return null; + Object set = update.get("$set"); + if (set instanceof Document) { + update = (Document) set; } - } else { - return null; - } - } - - private boolean isContainDocument(BulkWriteModel bulkWriteModel, BulkWriteError writeError) { - if (CollectionUtils.isEmpty(bulkWriteModel.getOnlyInsertWriteModels())) { - return false; - } - try { - WriteModel errorWriteModel; - int index = writeError.getIndex(); - List> allOpWriteModels = bulkWriteModel.getAllOpWriteModels(); - if (CollectionUtils.isEmpty(allOpWriteModels)) { - return false; + if (update.isEmpty()) { + return null; } - try { - errorWriteModel = allOpWriteModels.get(index); - if (errorWriteModel == null) { - return false; + filter = new Document(filter); + for (String key : filter.keySet()) { + Object value = MapUtil.getValueByKey(update, key); + if (null == value) { + return null; } - } catch (Exception ignored) { - return false; + filter.put(key, value); } - for (WriteModel writeModel : bulkWriteModel.getOnlyInsertWriteModels()) { - if (errorWriteModel instanceof UpdateManyModel) { - String id = ((Document) ((UpdateManyModel) errorWriteModel).getFilter()).get("_id").toString(); - String idTemp = ((Document) ((InsertOneModel) writeModel).getDocument()).get("_id").toString(); - if (id.equals(idTemp)) { - return true; - } - } + Document existed = collection.find(filter).limit(1).first(); + if (existed != null) { + // Mark as ignored + return IgnoreWriteModel.INSTANCE; } - } catch (Exception e) { - TapLogger.error(TAG, "Code11000Handler handle containDocument error", e); - return false; + } catch (Throwable ignored) { + // fall through to return null (can't handle) } - return false; + return null; } } diff --git a/connectors/mongodb-connector/src/main/java/io/tapdata/mongodb/writer/error/handler/Code72Handler.java b/connectors/mongodb-connector/src/main/java/io/tapdata/mongodb/writer/error/handler/Code72Handler.java new file mode 100644 index 000000000..93b609651 --- /dev/null +++ b/connectors/mongodb-connector/src/main/java/io/tapdata/mongodb/writer/error/handler/Code72Handler.java @@ -0,0 +1,46 @@ +package io.tapdata.mongodb.writer.error.handler; + +import com.mongodb.MongoBulkWriteException; +import com.mongodb.bulk.BulkWriteError; +import com.mongodb.client.MongoCollection; +import com.mongodb.client.model.*; +import io.tapdata.mongodb.writer.BulkWriteModel; +import io.tapdata.mongodb.writer.error.BulkWriteErrorHandler; +import org.bson.Document; + +/** + * @author Tapdata + * @Description Handle error code 72: Multi-update operations are not allowed when updating the shard key field + * This error occurs when using UpdateManyModel on a sharded collection where the update involves shard key fields. + * The solution is to convert UpdateManyModel to UpdateOneModel. + * @create 2026-03-27 + **/ +public class Code72Handler implements BulkWriteErrorHandler { + + @Override + public WriteModel handle( + BulkWriteModel bulkWriteModel, + WriteModel writeModel, + BulkWriteOptions bulkWriteOptions, + MongoBulkWriteException mongoBulkWriteException, + BulkWriteError writeError, + MongoCollection collection + ) { + // Convert UpdateManyModel to UpdateOneModel to avoid shard key update restriction + try { + if (writeModel instanceof UpdateManyModel) { + UpdateManyModel updateManyModel = (UpdateManyModel) writeModel; + Document filter = (Document) updateManyModel.getFilter(); + Document update = (Document) updateManyModel.getUpdate(); + UpdateOptions options = updateManyModel.getOptions(); + + // Convert to UpdateOneModel with the same filter, update and options + return new UpdateOneModel<>(filter, update, options); + } + } catch (Throwable ignored) { + // fall through to return null (can't handle) + } + return null; + } +} + diff --git a/connectors/mongodb-connector/src/main/resources/spec.json b/connectors/mongodb-connector/src/main/resources/spec.json index b26325303..e849b3596 100644 --- a/connectors/mongodb-connector/src/main/resources/spec.json +++ b/connectors/mongodb-connector/src/main/resources/spec.json @@ -1,6 +1,7 @@ { "properties": { "name": "MongoDB", + "realName": "MongoDB", "icon": "icons/mongodb.png", "doc" : "${doc}", "tags" : ["schema-free","Database","doubleActive"], @@ -447,6 +448,29 @@ ], "x-index": 6 }, + "preImage4Sink": { + "type": "boolean", + "title": "${preImage4Sink}", + "x-decorator": "FormItem", + "x-component": "Switch", + "default": false, + "x-decorator-props": { + "layout": "horizontal", + "colon": false, + "tooltip": "${preImage4SinkTips}" + }, + "x-reactions": { + "dependencies": [ + "$inputs" + ], + "fulfill": { + "state": { + "visible": "{{$deps[0].length>0}}" + } + } + }, + "x-index": 81 + }, "shardCollection": { "type": "boolean", "title": "${node_shard_collection}", @@ -574,6 +598,8 @@ "timeSeriesCollectionTip": "When the source table is a TimeSeries collection, it is synchronized to a TimeSeries collection. Only the source table is supported from MongoDB to MongoDB, and the version is MongoDB5.0 or above.", "preImage": "Document Preimages", "preImageTips": "Enable the preimage function:\\n1. Only MongoDB6.0 and above support enabling it;\\n2. Confirm that the collection has enabled changeStreamPreAndPostImages;\\n3. Confirm the expiration time of the preimage", + "preImage4Sink": "Document Preimages when create collection", + "preImage4SinkTips": "Enable the preimage function:\\n1. Only MongoDB6.0 and above support enabling it;\\n2. Confirm that the collection has enabled changeStreamPreAndPostImages;\\n3. Confirm the expiration time of the preimage", "writeConcern": "Write Concern", "writeConcernTip": "Write concern describes the level of acknowledgment requested from MongoDB for write operations to a standalone mongod, replica sets, or sharded clusters. In sharded clusters, mongos instances will pass the write concern on to the shards" }, @@ -614,6 +640,8 @@ "node_shard_collection_tip": "源表为Shard表时同步为Shard表,仅支持源表为MongoDB到MongoDB,目标为分片集群时此配置生效", "preImage": "文档原像", "preImageTips": "启用原像功能:\n1. 只有MongoDB6.0及以上才支持开启;\n2.确认集合已开启changeStreamPreAndPostImages;\n3.确认原像的过期时间", + "preImage4Sink": "建表开启文档原像", + "preImage4SinkTips": "启用原像功能:\n1. 只有MongoDB6.0及以上才支持开启;\n2.确认集合已开启changeStreamPreAndPostImages;\n3.确认原像的过期时间", "timeSeriesCollection": "时间序列集合属性", "timeSeriesCollectionTip": "源表为TimeSeries集合时同步为TimeSeries集合,仅支持源表为MongoDB到MongoDB,且版本为MongoDB5.0以上", "writeConcern": "写入确认", @@ -656,6 +684,8 @@ "node_shard_collection_tip": "源錶為Shard錶時同步為Shard錶,僅支持源錶為MongoDB到MongoDB,目標為分片集羣時此配寘生效", "preImage": "文檔原像", "preImageTips": "啟用原像功能:\n1. 只有MongoDB6.0以上才支援開啟;\n2.確認集合已開啟changeStreamPreAndPostImages;\n3.確認原像的過期時間", + "preImage4Sink": "建表開啟文檔原像", + "preImage4SinkTips": "啟用原像功能:\n1. 只有MongoDB6.0以上才支援開啟;\n2.確認集合已開啟changeStreamPreAndPostImages;\n3.確認原像的過期時間", "timeSeriesCollection": "時間序列集合屬性", "timeSeriesCollectionTip": "源表為TimeSeries集合時同步為TimeSeries集合,僅支持源表為MongoDB到MongoDB,且版本為MongoDB5.0以上", "writeConcern": "寫入確認", @@ -665,15 +695,19 @@ "dataTypes": { "DOUBLE": { "to": "TapNumber", + "precision": [ + 1, + 255 + ], + "scale": [ + 0, + 30 + ], "value": [ "-1.7976931348623157E+308", "1.7976931348623157E+308" ], - "preferPrecision": 20, - "preferScale": 8, - "scale": 17, - "precision": 309, - "fixed": true + "fixed": false }, "STRING[($byte)]": { "to": "TapString", @@ -733,6 +767,21 @@ ] }, "TIMESTAMP": { + "to": "TapDateTime", + "range": [ + "1970-01-01 00:00:00", + "2038-01-19 03:14:07" + ], + "pattern": "yyyy-MM-dd HH:mm:ss", + "fraction": [ + 0, + 3 + ], + "defaultFraction": 3, + "withTimeZone": true, + "queryOnly": true + }, + "REGULAR_EXPRESSION": { "to": "TapString", "queryOnly": true }, @@ -750,7 +799,17 @@ -1E+6145, 1E+6145 ], - "scale": 1000 + "precision": [ + 1, + 65 + ], + "scale": [ + 0, + 30 + ], + "defaultPrecision": 30, + "defaultScale": 10, + "fixed": true }, "MIN_KEY": { "to": "TapString", diff --git a/connectors/mongodb-connector/src/test/java/io/tapdata/mongodb/MongodbUtilTest.java b/connectors/mongodb-connector/src/test/java/io/tapdata/mongodb/MongodbUtilTest.java index 2314b5f83..c1306a98f 100644 --- a/connectors/mongodb-connector/src/test/java/io/tapdata/mongodb/MongodbUtilTest.java +++ b/connectors/mongodb-connector/src/test/java/io/tapdata/mongodb/MongodbUtilTest.java @@ -72,4 +72,56 @@ void test_load_oplog(){ verify(callback,times(1)).accept(any()); } } + + @Nested + class appendDefaultHaTimeoutOptionsTest { + @Test + void noQueryString_appendsAllThree() { + String uri = "mongodb://host:27017/db"; + String result = MongodbUtil.appendDefaultHaTimeoutOptions(uri,false); + Assertions.assertEquals( + "mongodb://host:27017/db?serverSelectionTimeoutMS=15000&socketTimeoutMS=15000&maxIdleTimeMS=30000", + result); + } + + @Test + void existingAuthSource_appendsWithAmpersand() { + String uri = "mongodb://u:p@host/db?authSource=admin"; + String result = MongodbUtil.appendDefaultHaTimeoutOptions(uri,false); + Assertions.assertEquals( + "mongodb://u:p@host/db?authSource=admin&serverSelectionTimeoutMS=15000&socketTimeoutMS=15000&maxIdleTimeMS=30000", + result); + } + + @Test + void userSocketTimeoutPreserved_otherTwoAdded() { + String uri = "mongodb://host/db?socketTimeoutMS=5000"; + String result = MongodbUtil.appendDefaultHaTimeoutOptions(uri,false); + Assertions.assertTrue(result.contains("socketTimeoutMS=5000")); + Assertions.assertFalse(result.contains("socketTimeoutMS=15000")); + Assertions.assertTrue(result.contains("serverSelectionTimeoutMS=15000")); + Assertions.assertTrue(result.contains("maxIdleTimeMS=30000")); + } + + @Test + void allThreeAlreadySet_returnsUnchanged() { + String uri = "mongodb://host/db?serverSelectionTimeoutMS=20000&socketTimeoutMS=10000&maxIdleTimeMS=45000"; + String result = MongodbUtil.appendDefaultHaTimeoutOptions(uri,false); + Assertions.assertEquals(uri, result); + } + + @Test + void caseInsensitiveKey_notDuplicated() { + String uri = "mongodb://host/db?SocketTimeoutMS=5000"; + String result = MongodbUtil.appendDefaultHaTimeoutOptions(uri,false); + Assertions.assertTrue(result.contains("SocketTimeoutMS=5000")); + Assertions.assertFalse(result.contains("socketTimeoutMS=15000")); + } + + @Test + void blankUri_returnedAsIs() { + Assertions.assertNull(MongodbUtil.appendDefaultHaTimeoutOptions(null,false)); + Assertions.assertEquals("", MongodbUtil.appendDefaultHaTimeoutOptions("",false)); + } + } } diff --git a/connectors/mongodb-connector/src/test/java/io/tapdata/mongodb/writer/MongodbWriterTest.java b/connectors/mongodb-connector/src/test/java/io/tapdata/mongodb/writer/MongodbWriterTest.java index 7ef8387aa..c754271b4 100644 --- a/connectors/mongodb-connector/src/test/java/io/tapdata/mongodb/writer/MongodbWriterTest.java +++ b/connectors/mongodb-connector/src/test/java/io/tapdata/mongodb/writer/MongodbWriterTest.java @@ -96,6 +96,102 @@ public void reset() { mongodbWriter = new MongodbWriter(globalStateMap, mongodbConfig, mongoClient, log, shardKeyMap, new HashMap<>()); } + @Nested + @DisplayName("Method dumpBulkWriteErrorContext test") + class dumpBulkWriteErrorContextTest { + @Test + @DisplayName("should write dump file with thread/database/collection/namespace and minute-level filename") + void testDumpToFileIncludesMetadata() throws Exception { + // Prepare + com.mongodb.client.MongoCollection collection = org.mockito.Mockito.mock(com.mongodb.client.MongoCollection.class); + org.mockito.Mockito.when(collection.getNamespace()).thenReturn(new com.mongodb.MongoNamespace("ut_db", "ut_col")); + BulkWriteModel bulkWriteModel = new BulkWriteModel(false); + // mock 100 mixed write models: insert / update / upsert / delete + for (int i = 0; i < 100; i++) { + if (i < 30) { + org.bson.Document doc = new org.bson.Document("_id", i).append("k", "v" + i); + com.mongodb.client.model.InsertOneModel insert = new com.mongodb.client.model.InsertOneModel<>(doc); + bulkWriteModel.addAnyOpModel(insert); + } else if (i < 60) { + // update without upsert + com.mongodb.client.model.UpdateOneModel update = + new com.mongodb.client.model.UpdateOneModel<>( + com.mongodb.client.model.Filters.eq("_id", i), + new org.bson.Document("$set", new org.bson.Document("k", "upd" + i))); + bulkWriteModel.addAnyOpModel(update); + } else if (i < 90) { + // update with upsert + com.mongodb.client.model.UpdateOneModel upsert = + new com.mongodb.client.model.UpdateOneModel<>( + com.mongodb.client.model.Filters.eq("_id", i), + new org.bson.Document("$set", new org.bson.Document("k", "upsert" + i)), + new com.mongodb.client.model.UpdateOptions().upsert(true)); + bulkWriteModel.addAnyOpModel(upsert); + } else { + // delete + com.mongodb.client.model.DeleteOneModel delete = + new com.mongodb.client.model.DeleteOneModel<>( + com.mongodb.client.model.Filters.eq("_id", i)); + bulkWriteModel.addAnyOpModel(delete); + } + } + com.mongodb.client.model.BulkWriteOptions bulkWriteOptions = new com.mongodb.client.model.BulkWriteOptions(); + String threadName = Thread.currentThread().getName(); + String sanitizedThread = threadName.replaceAll("[^a-zA-Z0-9_.-]", "_"); + + // fabricate a MongoBulkWriteException with random index errors + java.util.Random rnd = new java.util.Random(); + java.util.List errors = new java.util.ArrayList<>(); + int opsSize = bulkWriteModel.getAllOpWriteModels() != null ? bulkWriteModel.getAllOpWriteModels().size() : 0; + if (opsSize > 0) { + int maxErr = Math.min(5, opsSize); + int errCount = 1 + rnd.nextInt(maxErr); + java.util.Set usedIdx = new java.util.HashSet<>(); + for (int i = 0; i < errCount; i++) { + int idx; + do { + idx = rnd.nextInt(opsSize); + } while (!usedIdx.add(idx)); + int[] codes = new int[]{28, 2, 11000}; + int code = codes[rnd.nextInt(codes.length)]; + org.bson.BsonDocument details = new org.bson.BsonDocument("reason", new org.bson.BsonString("ut-error")); + errors.add(new com.mongodb.bulk.BulkWriteError(code, "ut error at " + idx, details, idx)); + } + } + com.mongodb.MongoBulkWriteException ex = org.mockito.Mockito.mock(com.mongodb.MongoBulkWriteException.class); + org.mockito.Mockito.when(ex.getWriteErrors()).thenReturn(errors); + org.mockito.Mockito.when(ex.getServerAddress()).thenReturn(new com.mongodb.ServerAddress("localhost", 27017)); + org.mockito.Mockito.when(ex.getWriteConcernError()).thenReturn(null); + + String pathObj = mongodbWriter.dumpBulkWriteErrorContext(ex, bulkWriteModel, bulkWriteOptions, collection); + + assertNotNull(pathObj, "dumpBulkWriteErrorContext should return file path"); + String filePath = String.valueOf(pathObj); + java.nio.file.Path path = java.nio.file.Paths.get(filePath); + assertTrue(java.nio.file.Files.exists(path), "dump file should exist"); + + try { + String content = new String(java.nio.file.Files.readAllBytes(path), java.nio.charset.StandardCharsets.UTF_8); + // Verify filename pattern + String fileName = path.getFileName().toString(); + assertTrue(fileName.startsWith("mongodb_bulk_write_error_")); + assertTrue(fileName.endsWith("_" + sanitizedThread + ".log")); + + // Verify content contains metadata + assertTrue(content.contains("thread=" + threadName)); + assertTrue(content.contains("database=ut_db collection=ut_col")); + assertTrue(content.contains("namespace=ut_db.ut_col")); + } finally { + // Clean up test artifact file + try { + java.nio.file.Files.deleteIfExists(path); + } catch (Throwable ignored) { + } + } + } + } + + @Nested @DisplayName("Method removeOidIfNeed test") class removeOidIfNeedTest { diff --git a/connectors/mongodb-lower-connector/src/main/resources/mongo3-spec.json b/connectors/mongodb-lower-connector/src/main/resources/mongo3-spec.json index e11a57f9c..ad30bcddb 100644 --- a/connectors/mongodb-lower-connector/src/main/resources/mongo3-spec.json +++ b/connectors/mongodb-lower-connector/src/main/resources/mongo3-spec.json @@ -1,6 +1,7 @@ { "properties": { "name": "MongoDB Below 3.4", + "realName": "MongoDB Below 3.4", "icon": "icons/mongodb3.png", "doc" : "${doc}", "tags" : ["schema-free","Database"], diff --git a/connectors/mysql-connector/pom.xml b/connectors/mysql-connector/pom.xml index 2076f4998..4b36c0eb2 100644 --- a/connectors/mysql-connector/pom.xml +++ b/connectors/mysql-connector/pom.xml @@ -23,7 +23,7 @@ 8 - 2.0.2-SNAPSHOT + 2.0.5-SNAPSHOT diff --git a/connectors/mysql-connector/src/main/java/io/tapdata/connector/mysql/MysqlConnector.java b/connectors/mysql-connector/src/main/java/io/tapdata/connector/mysql/MysqlConnector.java index 98221139a..80560f912 100644 --- a/connectors/mysql-connector/src/main/java/io/tapdata/connector/mysql/MysqlConnector.java +++ b/connectors/mysql-connector/src/main/java/io/tapdata/connector/mysql/MysqlConnector.java @@ -15,6 +15,7 @@ import io.tapdata.connector.mysql.dml.MysqlWriteRecorder; import io.tapdata.connector.mysql.dml.sqlmaker.MysqlSqlMaker; import io.tapdata.connector.mysql.entity.MysqlBinlogPosition; +import io.tapdata.connector.mysql.util.MysqlBinlogPositionUtil; import io.tapdata.connector.mysql.util.MysqlUtil; import io.tapdata.connector.mysql.writer.MysqlSqlBatchWriter; import io.tapdata.connector.mysql.writer.MysqlWriter; @@ -42,6 +43,7 @@ import io.tapdata.partition.DatabaseReadPartitionSplitter; import io.tapdata.pdk.apis.annotations.TapConnectorClass; import io.tapdata.pdk.apis.consumer.StreamReadConsumer; +import io.tapdata.pdk.apis.consumer.StreamReadOneByOneConsumer; import io.tapdata.pdk.apis.context.TapConnectionContext; import io.tapdata.pdk.apis.context.TapConnectorContext; import io.tapdata.pdk.apis.entity.*; @@ -104,6 +106,18 @@ public class MysqlConnector extends CommonDbConnector { public void onStart(TapConnectionContext tapConnectionContext) throws Throwable { mysqlConfig = new MysqlConfig().load(tapConnectionContext.getConnectionConfig()); mysqlConfig.load(tapConnectionContext.getNodeConfig()); + isConnectorStarted(tapConnectionContext, tapConnectorContext -> { + firstConnectorId = (String) tapConnectorContext.getStateMap().get("firstConnectorId"); + if (EmptyKit.isNull(firstConnectorId)) { + firstConnectorId = UUID.randomUUID().toString().replace("-", ""); + tapConnectorContext.getStateMap().put("firstConnectorId", firstConnectorId); + } + }); + tapLogger = tapConnectionContext.getLog(); + if (mysqlConfig.getFileLog()) { + tapLogger.info("Starting Jdbc Logging, connectorId: {}", firstConnectorId); + mysqlConfig.startJdbcLog(firstConnectorId); + } contextMapForMasterSlave = MysqlUtil.buildContextMapForMasterSlave(mysqlConfig); MysqlUtil.buildMasterNode(mysqlConfig, contextMapForMasterSlave); MysqlJdbcContextV2 contextV2 = contextMapForMasterSlave.get(mysqlConfig.getHost() + mysqlConfig.getPort()); @@ -121,7 +135,6 @@ public void onStart(TapConnectionContext tapConnectionContext) throws Throwable if (Boolean.TRUE.equals(mysqlConfig.getApplyDefault())) { commonSqlMaker.applyDefault(true); } - tapLogger = tapConnectionContext.getLog(); exceptionCollector = new MysqlExceptionCollector(); ((MysqlExceptionCollector) exceptionCollector).setMysqlConfig(mysqlConfig); this.version = mysqlJdbcContext.queryVersion(); @@ -272,6 +285,7 @@ public void registerCapabilities(ConnectorFunctions connectorFunctions, TapCodec connectorFunctions.supportBatchCount(this::batchCount); connectorFunctions.supportBatchRead(this::batchReadWithoutOffset); connectorFunctions.supportStreamRead(this::streamRead); + connectorFunctions.supportOneByOneStreamRead(this::streamReadOneByOne); connectorFunctions.supportTimestampToStreamOffset(this::timestampToStreamOffset); connectorFunctions.supportQueryByAdvanceFilter(this::queryByAdvanceFilterWithOffset); connectorFunctions.supportCountByPartitionFilterFunction(this::countByAdvanceFilter); @@ -837,7 +851,18 @@ protected Set dateFields(TapTable tapTable) { private void streamRead(TapConnectorContext tapConnectorContext, List tables, Object offset, int batchSize, StreamReadConsumer consumer) throws Throwable { throwNonSupportWhenLightInit(); - mysqlReader.readBinlog(tapConnectorContext, tables, offset, batchSize, DDLParserType.MYSQL_CCJ_SQL_PARSER, consumer, contextMapForMasterSlave); + if (mysqlConfig.getHighPerformance()) { + MysqlReaderV2 mysqlReaderV2 = new MysqlReaderV2(mysqlJdbcContext, tapLogger, dbTimeZone); + mysqlReaderV2.init(tables, tapConnectorContext.getTableMap(), offset, batchSize, consumer); + mysqlReaderV2.startMiner(this::isAlive); + } else { + mysqlReader.readBinlog(tapConnectorContext, tables, offset, batchSize, DDLParserType.MYSQL_CCJ_SQL_PARSER, consumer, contextMapForMasterSlave); + } + } + + private void streamReadOneByOne(TapConnectorContext context, List tables, Object offset, StreamReadOneByOneConsumer consumer) throws Throwable { + throwNonSupportWhenLightInit(); + mysqlReader.readBinlog(context, tables, offset, consumer.getBatchSize(), DDLParserType.MYSQL_CCJ_SQL_PARSER, consumer, contextMapForMasterSlave); } @@ -866,7 +891,21 @@ private Object timestampToStreamOffset(TapConnectorContext tapConnectorContext, } return this.mysqlJdbcContext.readBinlogPosition(); } - return startTime; + if (mysqlConfig.getHighPerformance()) { + try (MysqlBinlogPositionUtil ins = new MysqlBinlogPositionUtil( + mysqlConfig.getHost(), + mysqlConfig.getPort(), + mysqlConfig.getUser(), + mysqlConfig.getPassword())) { + MysqlBinlogPosition mysqlBinlogPosition = ins.findByLessTimestamp(startTime, true); + if (null == mysqlBinlogPosition) { + throw new RuntimeException("Not found binlog of sync time: " + startTime); + } + return mysqlBinlogPosition; + } + } else { + return startTime; + } } @@ -1055,7 +1094,7 @@ protected List getAfterUniqueAutoIncrementFields(TapTable tapTable, List protected int getLowerCaseTableNames() throws SQLException { AtomicInteger res = new AtomicInteger(0); mysqlJdbcContext.normalQuery("show variables like 'lower_case_table_names'", resultSet -> { - if(resultSet.next()) { + if (resultSet.next()) { res.set(resultSet.getInt("Value")); } }); diff --git a/connectors/mysql-connector/src/main/resources/mysql-spec.json b/connectors/mysql-connector/src/main/resources/mysql-spec.json index 5ac971f18..e3fdf7ba6 100644 --- a/connectors/mysql-connector/src/main/resources/mysql-spec.json +++ b/connectors/mysql-connector/src/main/resources/mysql-spec.json @@ -1,6 +1,7 @@ { "properties": { "name": "Mysql", + "realName": "MySQL", "icon": "icons/mysql.png", "id": "mysql", "doc": "${doc}", @@ -338,6 +339,24 @@ "node": { "type": "object", "properties": { + "highPerformance": { + "type": "boolean", + "title": "${highPerformance}", + "default": false, + "x-index": 4, + "x-decorator": "FormItem", + "x-component": "Switch", + "x-reactions": [ + { + "dependencies": ["$inputs"], + "fulfill": { + "state": { + "display": "{{!$deps[0].length ? \"visible\":\"hidden\"}}" + } + } + } + ] + }, "createAutoInc": { "type": "boolean", "title": "${createAutoInc}", @@ -527,7 +546,8 @@ "batchReadThreadSize": "Batch read thread size", "maximumQueueSize": "Maximum queue size", "maximumQueueSizeTip": "The queue size for reading incremental data in MySQL. If the downstream synchronization is slow or individual records in the table are too large, please lower this setting.", - "lowerCaseTableNames": "Lower Case TableNames" + "lowerCaseTableNames": "Lower Case TableNames", + "highPerformance": "High Performance Mode" }, "zh_CN": { "host": "地址", @@ -564,7 +584,8 @@ "batchReadThreadSize": "批量读取线程数", "maximumQueueSize": "最大队列大小", "maximumQueueSizeTip": "MySql读取增量数据队列大小,如果下游同步较慢或表的单条数据过大,请调低此配置。", - "lowerCaseTableNames": "大小写敏感" + "lowerCaseTableNames": "大小写敏感", + "highPerformance": "高性能模式" }, "zh_TW": { "host": "地址", @@ -601,7 +622,8 @@ "batchReadThreadSize": "批量讀取線程數", "maximumQueueSize": "最大隊列大小", "maximumQueueSizeTip": "MySql 讀取增量數據隊列大小。如果下游同步較慢或表的單條數據過大,請調低此配置。", - "lowerCaseTableNames": "大小寫敏感" + "lowerCaseTableNames": "大小寫敏感", + "highPerformance": "高性能模式" } }, "dataTypes": { diff --git a/connectors/mysql-pxc-connector/pom.xml b/connectors/mysql-pxc-connector/pom.xml index 4470f58fe..ad348a027 100644 --- a/connectors/mysql-pxc-connector/pom.xml +++ b/connectors/mysql-pxc-connector/pom.xml @@ -23,7 +23,7 @@ 8 - 2.0.0-SNAPSHOT + 2.0.5-SNAPSHOT diff --git a/connectors/mysql-pxc-connector/src/main/resources/mysql-pxc-spec.json b/connectors/mysql-pxc-connector/src/main/resources/mysql-pxc-spec.json index 9c24597d7..30af5bd3c 100644 --- a/connectors/mysql-pxc-connector/src/main/resources/mysql-pxc-spec.json +++ b/connectors/mysql-pxc-connector/src/main/resources/mysql-pxc-spec.json @@ -1,6 +1,7 @@ { "properties": { "name": "MySQL PXC", + "realName": "MySQL Percona XtraDB Cluster", "icon": "icons/mysql_pxc.png", "id": "mysql-pxc", "doc": "${doc}", diff --git a/connectors/oceanbase-mysql-connector/pom.xml b/connectors/oceanbase-mysql-connector/pom.xml index 03e83e950..4841002c0 100644 --- a/connectors/oceanbase-mysql-connector/pom.xml +++ b/connectors/oceanbase-mysql-connector/pom.xml @@ -21,9 +21,10 @@ 4.0.3 3.12.0 31.0.1-jre - 2.0.0-SNAPSHOT + 2.0.5-SNAPSHOT 3.19.1 1.43.0 + com.oceanbase.jdbc.Driver oceanbase-mysql-connector @@ -236,6 +237,27 @@ ${project.basedir}/../../.git + + + org.apache.maven.plugins + maven-antrun-plugin + 1.8 + + + generate-log4jdbc-properties + process-resources + + run + + + + + log4jdbc.drivers = ${origin.driver.class} + + + + + maven-resources-plugin 3.1.0 diff --git a/connectors/oceanbase-mysql-connector/src/main/java/io/tapdata/oceanbase/connector/OceanbaseConnector.java b/connectors/oceanbase-mysql-connector/src/main/java/io/tapdata/oceanbase/connector/OceanbaseConnector.java index 2de0d1de4..5e8634bb7 100644 --- a/connectors/oceanbase-mysql-connector/src/main/java/io/tapdata/oceanbase/connector/OceanbaseConnector.java +++ b/connectors/oceanbase-mysql-connector/src/main/java/io/tapdata/oceanbase/connector/OceanbaseConnector.java @@ -199,8 +199,6 @@ public void onStart(TapConnectionContext tapConnectionContext) throws Throwable mysqlConfig = new OceanbaseConfig().load(tapConnectionContext.getConnectionConfig()); mysqlConfig.load(tapConnectionContext.getNodeConfig()); - mysqlJdbcContext = new OceanbaseJdbcContext(mysqlConfig); - commonDbConfig = mysqlConfig; isConnectorStarted(tapConnectionContext, connectorContext -> { firstConnectorId = (String) connectorContext.getStateMap().get("firstConnectorId"); if (EmptyKit.isNull(firstConnectorId)) { @@ -208,8 +206,14 @@ public void onStart(TapConnectionContext tapConnectionContext) throws Throwable connectorContext.getStateMap().put("firstConnectorId", firstConnectorId); } }); - jdbcContext = mysqlJdbcContext; tapLogger = tapConnectionContext.getLog(); + if (mysqlConfig.getFileLog()) { + tapLogger.info("Starting Jdbc Logging, connectorId: {}", firstConnectorId); + mysqlConfig.startJdbcLog(firstConnectorId); + } + mysqlJdbcContext = new OceanbaseJdbcContext(mysqlConfig); + commonDbConfig = mysqlConfig; + jdbcContext = mysqlJdbcContext; commonSqlMaker = new CommonSqlMaker('`'); exceptionCollector = new MysqlExceptionCollector(); if (tapConnectionContext instanceof TapConnectorContext) { diff --git a/connectors/oceanbase-mysql-connector/src/main/resources/oceanbase-spec.json b/connectors/oceanbase-mysql-connector/src/main/resources/oceanbase-spec.json index 90b2ab8ca..436e2ec3c 100644 --- a/connectors/oceanbase-mysql-connector/src/main/resources/oceanbase-spec.json +++ b/connectors/oceanbase-mysql-connector/src/main/resources/oceanbase-spec.json @@ -1,6 +1,7 @@ { "properties": { "name": "Oceanbase", + "realName": "OceanBase", "icon": "icons/oceanbase.png", "id": "oceanbase", "doc": "${doc}", diff --git a/connectors/opengauss-connector/src/main/resources/spec_opengauss.json b/connectors/opengauss-connector/src/main/resources/spec_opengauss.json index 1377d7556..655ea3d38 100644 --- a/connectors/opengauss-connector/src/main/resources/spec_opengauss.json +++ b/connectors/opengauss-connector/src/main/resources/spec_opengauss.json @@ -1,6 +1,7 @@ { "properties": { "name": "OpenGauss", + "realName": "openGauss", "icon": "icons/open-gauss.png", "doc" : "${doc}", "id": "open-gauss", diff --git a/connectors/paimon-connector/pom.xml b/connectors/paimon-connector/pom.xml index cd963925e..626f60dd4 100644 --- a/connectors/paimon-connector/pom.xml +++ b/connectors/paimon-connector/pom.xml @@ -13,12 +13,13 @@ paimon-connector - 11 - 11 + 8 + 8 UTF-8 - 11 - 1.2.0 + 8 + 1.3.1 3.3.6 + 2.0.6-SNAPSHOT @@ -32,20 +33,11 @@ tapdata-pdk-api provided - - io.tapdata - tapdata-common - provided - io.tapdata sql-core 1.0-SNAPSHOT - - io.tapdata - connector-core - io.tapdata pdk-error-code @@ -211,6 +203,12 @@ **/org/slf4j/** + + org.apache.paimon:paimon-api + + org/apache/paimon/utils/ThreadUtils + + @@ -274,8 +272,8 @@ maven-compiler-plugin 3.7.0 - 8 - 8 + ${java.version} + ${java.version} @@ -311,6 +309,33 @@ + + org.codehaus.mojo + build-helper-maven-plugin + 3.2.0 + + + add-source + generate-sources + + add-source + + + + src/main/overwrite/ + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + 11 + 11 + + \ No newline at end of file diff --git a/connectors/paimon-connector/src/main/java/io/tapdata/connector/paimon/PaimonConnector.java b/connectors/paimon-connector/src/main/java/io/tapdata/connector/paimon/PaimonConnector.java index 5bf1e67e1..4e58c1bb1 100644 --- a/connectors/paimon-connector/src/main/java/io/tapdata/connector/paimon/PaimonConnector.java +++ b/connectors/paimon-connector/src/main/java/io/tapdata/connector/paimon/PaimonConnector.java @@ -4,27 +4,36 @@ import io.tapdata.connector.paimon.config.PaimonConfig; import io.tapdata.connector.paimon.service.PaimonService; import io.tapdata.entity.codec.TapCodecsRegistry; +import io.tapdata.entity.event.TapCallbackOffset; +import io.tapdata.entity.event.TapEvent; +import io.tapdata.entity.event.control.ControlEvent; +import io.tapdata.entity.event.control.HeartbeatEvent; import io.tapdata.entity.event.ddl.index.TapCreateIndexEvent; import io.tapdata.entity.event.ddl.table.TapCreateTableEvent; import io.tapdata.entity.event.ddl.table.TapDropTableEvent; import io.tapdata.entity.event.dml.TapRecordEvent; import io.tapdata.entity.logger.Log; import io.tapdata.entity.schema.TapTable; -import io.tapdata.entity.schema.value.TapArrayValue; -import io.tapdata.entity.schema.value.TapMapValue; -import io.tapdata.entity.schema.value.TapRawValue; +import io.tapdata.entity.schema.value.*; import io.tapdata.entity.utils.DataMap; +import io.tapdata.entity.utils.cache.Entry; +import io.tapdata.entity.utils.cache.Iterator; +import io.tapdata.kit.EmptyKit; import io.tapdata.pdk.apis.annotations.TapConnectorClass; import io.tapdata.pdk.apis.context.TapConnectionContext; import io.tapdata.pdk.apis.context.TapConnectorContext; import io.tapdata.pdk.apis.entity.ConnectionOptions; import io.tapdata.pdk.apis.entity.TestItem; import io.tapdata.pdk.apis.entity.WriteListResult; +import io.tapdata.pdk.apis.entity.TapAdvanceFilter; +import io.tapdata.pdk.apis.entity.FilterResults; import io.tapdata.pdk.apis.functions.ConnectorFunctions; import io.tapdata.pdk.apis.functions.connector.target.CreateTableOptions; import org.apache.commons.collections4.MapUtils; +import java.util.ArrayList; import java.util.List; +import java.util.function.BiConsumer; import java.util.function.Consumer; /** @@ -40,6 +49,7 @@ public class PaimonConnector extends ConnectorBase { private PaimonConfig paimonConfig; private PaimonService paimonService; + private Consumer flushOffsetCallback; /** * Initialize connection when connector starts @@ -60,10 +70,15 @@ public void onStart(TapConnectionContext connectionContext) throws Throwable { if (MapUtils.isNotEmpty(nodeConfig)) { nodeConfig.remove("database"); paimonConfig.load(nodeConfig); + paimonConfig.setTableConfig(connectionContext.getTableNodeConfig()); + } + this.flushOffsetCallback = connectionContext.getFlushOffsetCallback(); + if (this.flushOffsetCallback != null) { + connectionContext.getLog().info("Flush offset callback registered for StarRocks connector"); } - // Initialize Paimon service - paimonService = new PaimonService(paimonConfig); + paimonService = new PaimonService(paimonConfig, connectionContext.getLog()); + paimonService.setFlushOffsetCallback(flushOffsetCallback); paimonService.init(); connectionContext.getLog().info("Paimon connector started successfully"); @@ -79,6 +94,7 @@ public void onStop(TapConnectionContext connectionContext) { if (paimonService != null) { try { paimonService.close(); + paimonService = null; } catch (Exception e) { connectionContext.getLog().warn("Error closing Paimon service: " + e.getMessage(), e); } @@ -96,10 +112,9 @@ public void onStop(TapConnectionContext connectionContext) { @Override public ConnectionOptions connectionTest(TapConnectionContext connectionContext, Consumer consumer) throws Throwable { ConnectionOptions connectionOptions = ConnectionOptions.create(); - try { onStart(connectionContext); - + connectionOptions.connectionString(paimonConfig.getConnectionString()); // Test warehouse accessibility boolean warehouseAccessible = paimonService.testWarehouseAccess(); if (warehouseAccessible) { @@ -161,13 +176,22 @@ public void discoverSchema(TapConnectionContext connectionContext, List */ @Override public void registerCapabilities(ConnectorFunctions connectorFunctions, TapCodecsRegistry codecRegistry) { + // Source capabilities + connectorFunctions.supportBatchRead(this::batchRead); + connectorFunctions.supportBatchCount(this::batchCount); + connectorFunctions.supportStreamRead(this::streamRead); + connectorFunctions.supportTimestampToStreamOffset(this::timestampToStreamOffset); + connectorFunctions.supportQueryByAdvanceFilter(this::queryByAdvanceFilter); + // Target capabilities connectorFunctions.supportWriteRecord(this::writeRecord); connectorFunctions.supportCreateTableV2(this::createTable); connectorFunctions.supportDropTable(this::dropTable); connectorFunctions.supportCreateIndex(this::createIndex); connectorFunctions.supportClearTable(this::clearTable); - + connectorFunctions.supportProcessControlFunction(this::processControl); + connectorFunctions.supportAfterInitialSync(this::afterInitialSync); + // Register codec for data type conversions registerCodecs(codecRegistry); } @@ -201,6 +225,10 @@ private void registerCodecs(TapCodecsRegistry codecRegistry) { } return null; }); + codecRegistry.registerFromTapValue(TapDateTimeValue.class, tapDateTimeValue -> tapDateTimeValue.getValue().toTimestamp()); + codecRegistry.registerFromTapValue(TapDateValue.class, tapDateValue -> (int) (tapDateValue.getValue().getSeconds() / 86400)); + codecRegistry.registerFromTapValue(TapTimeValue.class, tapTimeValue -> (int) (tapTimeValue.getValue().getSeconds() * 1000 + tapTimeValue.getValue().getNano() / 1000_000)); + codecRegistry.registerFromTapValue(TapYearValue.class, "CHAR(4)", TapValue::getOriginValue); } /** @@ -284,6 +312,68 @@ private void createIndex(TapConnectorContext connectorContext, TapTable table, } } + /** + * Batch read records from Paimon table + * + * @param connectorContext connector context + * @param table table to read from + * @param offsetState offset state for resuming read + * @param eventBatchSize batch size for events + * @param eventsOffsetConsumer consumer for events and offset + * @throws Throwable if read fails + */ + private void batchRead(TapConnectorContext connectorContext, TapTable table, Object offsetState, + int eventBatchSize, BiConsumer, Object> eventsOffsetConsumer) throws Throwable { + final Log log = connectorContext.getLog(); + + try { + log.info("Starting batch read from table: " + table.getName()); + + // Read records using Paimon service + paimonService.batchRead(table, offsetState, eventBatchSize, eventsOffsetConsumer, connectorContext); + + log.info("Batch read completed for table: " + table.getName()); + + } catch (Exception e) { + log.error("Error reading records from table " + table.getName() + ": " + e.getMessage(), e); + throw e; + } + } + + /** + * Stream read records from Paimon table (CDC mode) + * + * @param connectorContext connector context + * @param tables list of tables to read from + * @param offsetState offset state for resuming read + * @param eventBatchSize batch size for events + * @param eventsOffsetConsumer consumer for events and offset + * @throws Throwable if read fails + */ + private void streamRead(TapConnectorContext connectorContext, List tables, Object offsetState, + int eventBatchSize, BiConsumer, Object> eventsOffsetConsumer) throws Throwable { + final Log log = connectorContext.getLog(); + + try { + log.info("Starting stream read from tables: " + tables); + + // Stream read records using Paimon service + paimonService.streamRead(tables, offsetState, eventBatchSize, eventsOffsetConsumer, connectorContext, this::isAlive); + + log.info("Stream read completed for tables: " + tables); + + } catch (Exception e) { + log.error("Error in stream read from tables " + tables + ": " + e.getMessage(), e); + throw e; + } + } + + protected void afterInitialSync(TapConnectorContext connectorContext, TapTable tapTable) throws Throwable { + if (paimonService != null) { + paimonService.afterInitialSync(connectorContext, tapTable); + } + } + /** * Write records to Paimon table * @@ -296,31 +386,128 @@ private void createIndex(TapConnectorContext connectorContext, TapTable table, private void writeRecord(TapConnectorContext connectorContext, List tapRecordEvents, TapTable table, Consumer> writeListResultConsumer) throws Throwable { final Log log = connectorContext.getLog(); - + try { - // Get DML policies - String insertPolicy = connectorContext.getConnectorCapabilities() - .getCapabilityAlternative(ConnectionOptions.DML_INSERT_POLICY); - if (insertPolicy == null) { - insertPolicy = ConnectionOptions.DML_INSERT_POLICY_UPDATE_ON_EXISTS; - } - - String updatePolicy = connectorContext.getConnectorCapabilities() - .getCapabilityAlternative(ConnectionOptions.DML_UPDATE_POLICY); - if (updatePolicy == null) { - updatePolicy = ConnectionOptions.DML_UPDATE_POLICY_IGNORE_ON_NON_EXISTS; - } - // Write records using Paimon service WriteListResult result = paimonService.writeRecords( - tapRecordEvents, table, insertPolicy, updatePolicy); - + tapRecordEvents, table, connectorContext); + writeListResultConsumer.accept(result); - + } catch (Exception e) { log.error("Error writing records to table " + table.getName() + ": " + e.getMessage(), e); throw e; } } + + /** + * Convert timestamp to stream offset (snapshot ID) for each table + * This allows resuming stream read from a specific point in time + * + * @param connectorContext connector context + * @param timestamp timestamp in milliseconds (null for current time) + * @return offset object containing snapshot IDs for each table + * @throws Throwable if conversion fails + */ + private Object timestampToStreamOffset(TapConnectorContext connectorContext, Long timestamp) throws Throwable { + final Log log = connectorContext.getLog(); + + // Build table list from context + List tableList = new ArrayList<>(); + Iterator> iterator = connectorContext.getTableMap().iterator(); + while (iterator.hasNext()) { + tableList.add(iterator.next().getKey()); + } + + try { + // Use current time if timestamp is null + Long effectiveTimestamp = timestamp != null ? timestamp : System.currentTimeMillis(); + log.info("Converting timestamp {} to stream offset for {} tables", effectiveTimestamp, tableList.size()); + + // Get snapshot IDs for specified tables at the given timestamp + return paimonService.timestampToStreamOffset(tableList, effectiveTimestamp, log); + + } catch (Exception e) { + log.error("Error converting timestamp to stream offset: " + e.getMessage(), e); + throw e; + } + } + + /** + * Count records in a table + * + * @param connectorContext connector context + * @param table table to count + * @return record count + * @throws Throwable if count fails + */ + private long batchCount(TapConnectorContext connectorContext, TapTable table) throws Throwable { + final Log log = connectorContext.getLog(); + + try { + log.info("Counting records in table: " + table.getName()); + + // Count records using Paimon service + long count = paimonService.batchCount(table, log); + + log.info("Table {} has {} records", table.getName(), count); + return count; + + } catch (Exception e) { + log.error("Error counting records in table " + table.getName() + ": " + e.getMessage(), e); + throw e; + } + } + + /** + * Query records by advance filter + * + * @param connectorContext connector context + * @param filter advance filter with conditions + * @param table table to query + * @param consumer consumer for filter results + * @throws Throwable if query fails + */ + private void queryByAdvanceFilter(TapConnectorContext connectorContext, TapAdvanceFilter filter, + TapTable table, Consumer consumer) throws Throwable { + final Log log = connectorContext.getLog(); + + try { + log.info("Querying table {} with advance filter", table.getName()); + + // Query records using Paimon service + paimonService.queryByAdvanceFilter(table, filter, consumer, log); + + log.info("Query completed for table: " + table.getName()); + + } catch (Exception e) { + log.error("Error querying table " + table.getName() + ": " + e.getMessage(), e); + throw e; + } + } + + protected void processControl(TapConnectorContext tapConnectorContext, ControlEvent controlEvent) { + if (controlEvent instanceof HeartbeatEvent) { + if (paimonService != null && EmptyKit.isEmpty(paimonService.getFirstOffsetByTable())) { + TapCallbackOffset tapOffset = new TapCallbackOffset(); + // 从 TapRecordEvent.info 中提取 offset 信息 + // 这些信息由 HazelcastTargetPdkBaseNode.handleTapdataEventDML 方法添加 + Object batchOffset = controlEvent.getInfo("batchOffset"); + Object streamOffset = controlEvent.getInfo("streamOffset"); + Object syncStage = controlEvent.getInfo("syncStage"); + Object sourceTime = controlEvent.getInfo("sourceTime"); + Object nodeIds = controlEvent.getInfo("nodeIds"); + + // 填充 TapOffset + tapOffset.batchOffset(batchOffset) + .streamOffset(streamOffset) + .syncStage(syncStage != null ? syncStage.toString() : null) + .sourceTime(sourceTime instanceof Long ? (Long) sourceTime : null) + .eventTime(((HeartbeatEvent) controlEvent).getReferenceTime()) + .nodeIds(nodeIds); + tapConnectorContext.getFlushOffsetCallback().accept(tapOffset); + } + } + } } diff --git a/connectors/paimon-connector/src/main/java/io/tapdata/connector/paimon/config/PaimonConfig.java b/connectors/paimon-connector/src/main/java/io/tapdata/connector/paimon/config/PaimonConfig.java index ccda69746..09a087cce 100644 --- a/connectors/paimon-connector/src/main/java/io/tapdata/connector/paimon/config/PaimonConfig.java +++ b/connectors/paimon-connector/src/main/java/io/tapdata/connector/paimon/config/PaimonConfig.java @@ -1,8 +1,12 @@ package io.tapdata.connector.paimon.config; import io.tapdata.common.CommonDbConfig; +import io.tapdata.kit.EmptyKit; import java.io.Serializable; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; /** @@ -19,6 +23,7 @@ public class PaimonConfig extends CommonDbConfig implements Serializable { // Storage type: s3, hdfs, oss, local private String storageType = "local"; + private List> s3Properties = new ArrayList<>(); // S3 configuration private String s3Endpoint; @@ -39,6 +44,9 @@ public class PaimonConfig extends CommonDbConfig implements Serializable { // Database name (Paimon database) private String database = "default"; + private Boolean hashKey = false; + private List partitionKey; + // Bucket mode: "dynamic" or "fixed" // Dynamic mode: better for general use, uses StreamTableWrite // Fixed mode: better performance, uses BatchTableWrite @@ -48,6 +56,56 @@ public class PaimonConfig extends CommonDbConfig implements Serializable { // Must be > 0 when using fixed mode private Integer bucketCount = 4; + private String fileFormat = ""; + + private String compression = ""; + + private List> tableProperties = new ArrayList<>(); + + // ===== Performance Optimization Settings ===== + + // Write buffer size in MB (default: 256MB) + // Larger buffer = better performance but more memory usage + private Integer writeBufferSize = 256; + + private Boolean diskOverflowWrite = false; + + private Integer diskMaxSize = 1; + + private String diskTmpDir = "/tmp"; + + // Batch accumulation size before commit (default: 10000 records) + // 0 = commit immediately (no batching) + private Integer batchAccumulationSize = 100000; + + // Commit interval in milliseconds (default: 30000ms = 30s) + // 0 = no time-based commit, only size-based + private Integer commitIntervalMs = 30000; + + // Enable async commit (default: true) + // Async commit improves throughput by not blocking writes + private Boolean enableAsyncCommit = true; + + // Number of write threads for parallel writing (default: 4) + // More threads = better parallelism but more resource usage + private Integer writeThreads = 4; + + // Enable auto compaction (default: true) + // Compaction merges small files for better query performance + private Boolean enableAutoCompaction = true; + + // Full Compaction interval in minutes (default: 60 minutes) + private Integer compactionIntervalMinutes = 60; + + // Target file size in MB (default: 128MB) + // Paimon will try to create files of this size + private Integer targetFileSize = 128; + + // Enable primary key update detection (default: false) + // When enabled, automatically detects primary key changes and converts update operations to delete+insert + // Requires source database to provide before-update data + private Boolean enablePrimaryKeyUpdate = false; + public String getWarehouse() { return warehouse; } @@ -64,6 +122,14 @@ public void setStorageType(String storageType) { this.storageType = storageType; } + public List> getS3Properties() { + return s3Properties; + } + + public void setS3Properties(List> s3Properties) { + this.s3Properties = s3Properties; + } + public String getS3Endpoint() { return s3Endpoint; } @@ -154,10 +220,38 @@ public void setDatabase(String database) { this.database = database; } + public Boolean getHashKey() { + return hashKey; + } + + public Boolean getHashKey(String key) { + return getTableConfigValue(key, "hashKey", hashKey); + } + + public void setHashKey(Boolean hashKey) { + this.hashKey = hashKey; + } + + public List getPartitionKey() { + return partitionKey; + } + + public List getPartitionKey(String key) { + return getTableConfigValue(key, "partitionKey", partitionKey); + } + + public void setPartitionKey(List partitionKey) { + this.partitionKey = partitionKey; + } + public String getBucketMode() { return bucketMode; } + public String getBucketMode(String key) { + return getTableConfigValue(key, "bucketMode", bucketMode); + } + public void setBucketMode(String bucketMode) { this.bucketMode = bucketMode; } @@ -166,6 +260,10 @@ public Integer getBucketCount() { return bucketCount; } + public Integer getBucketCount(String key) { + return getTableConfigValue(key, "bucketCount", bucketCount); + } + public void setBucketCount(Integer bucketCount) { this.bucketCount = bucketCount; } @@ -179,6 +277,158 @@ public boolean isDynamicBucketMode() { return "dynamic".equalsIgnoreCase(bucketMode); } + public String getFileFormat() { + return fileFormat; + } + + public String getFileFormat(String key) { + return getTableConfigValue(key, "fileFormat", fileFormat); + } + + public void setFileFormat(String fileFormat) { + this.fileFormat = fileFormat; + } + + public String getCompression() { + return compression; + } + + public String getCompression(String key) { + return getTableConfigValue(key, "compression", compression); + } + + public void setCompression(String compression) { + this.compression = compression; + } + + public List> getTableProperties() { + return tableProperties; + } + + public List> getTableProperties(String key) { + return getTableConfigValue(key, "tableProperties", tableProperties); + } + + public void setTableProperties(List> tableProperties) { + this.tableProperties = tableProperties; + } + + public Integer getWriteBufferSize() { + return writeBufferSize; + } + + public void setWriteBufferSize(Integer writeBufferSize) { + this.writeBufferSize = writeBufferSize; + } + + public Boolean getDiskOverflowWrite() { + return diskOverflowWrite; + } + + public void setDiskOverflowWrite(Boolean diskOverflowWrite) { + this.diskOverflowWrite = diskOverflowWrite; + } + + public Integer getDiskMaxSize() { + return diskMaxSize; + } + + public void setDiskMaxSize(Integer diskMaxSize) { + this.diskMaxSize = diskMaxSize; + } + + public String getDiskTmpDir() { + return diskTmpDir; + } + + public String getDiskTmpDir(String key) { + return getTableConfigValue(key, "diskTmpDir", diskTmpDir); + } + + public void setDiskTmpDir(String diskTmpDir) { + this.diskTmpDir = diskTmpDir; + } + + public Integer getBatchAccumulationSize() { + return batchAccumulationSize; + } + + public void setBatchAccumulationSize(Integer batchAccumulationSize) { + this.batchAccumulationSize = batchAccumulationSize; + } + + public Integer getCommitIntervalMs() { + return commitIntervalMs; + } + + public void setCommitIntervalMs(Integer commitIntervalMs) { + this.commitIntervalMs = commitIntervalMs; + } + + public Boolean getEnableAsyncCommit() { + return enableAsyncCommit; + } + + public void setEnableAsyncCommit(Boolean enableAsyncCommit) { + this.enableAsyncCommit = enableAsyncCommit; + } + + public Integer getWriteThreads() { + return writeThreads; + } + + public void setWriteThreads(Integer writeThreads) { + this.writeThreads = writeThreads; + } + + public Boolean getEnableAutoCompaction() { + return enableAutoCompaction; + } + + public Boolean getEnableAutoCompaction(String key) { + return getTableConfigValue(key, "enableAutoCompaction", enableAutoCompaction); + } + + public void setEnableAutoCompaction(Boolean enableAutoCompaction) { + this.enableAutoCompaction = enableAutoCompaction; + } + + public Integer getCompactionIntervalMinutes() { + return compactionIntervalMinutes; + } + + public Integer getCompactionIntervalMinutes(String key) { + return getTableConfigValue(key, "compactionIntervalMinutes", compactionIntervalMinutes); + } + + public void setCompactionIntervalMinutes(Integer compactionIntervalMinutes) { + this.compactionIntervalMinutes = compactionIntervalMinutes; + } + + public Integer getTargetFileSize() { + return targetFileSize; + } + + public Integer getTargetFileSize(String key) { + return getTableConfigValue(key, "targetFileSize", targetFileSize); + } + + public void setTargetFileSize(Integer targetFileSize) { + this.targetFileSize = targetFileSize; + } + + public Boolean getEnablePrimaryKeyUpdate() { + return enablePrimaryKeyUpdate; + } + + public Boolean getEnablePrimaryKeyUpdate(String key) { + return getTableConfigValue(key, "enablePrimaryKeyUpdate", enablePrimaryKeyUpdate); + } + + public void setEnablePrimaryKeyUpdate(Boolean enablePrimaryKeyUpdate) { + this.enablePrimaryKeyUpdate = enablePrimaryKeyUpdate; + } + /** * Override load method to return PaimonConfig type * @@ -262,6 +512,33 @@ public String getFullWarehousePath() { } } + + public String getConnectionString() { + String st = storageType == null ? "" : storageType.trim().toLowerCase(); + switch (st) { + case "s3": + // Prefer native s3:// if paimon-s3 is available, otherwise use Hadoop S3A + if (isPaimonS3Available()) { + return "s3://" + removeProtocol(s3Endpoint) + "/" + removeProtocol(warehouse.trim()); + } else { + return "s3a://" + removeProtocol(s3Endpoint) + "/" + removeProtocol(warehouse.trim()); + } + case "hdfs": + return "hdfs://" + hdfsHost + ":" + hdfsPort + "/" + removeProtocol(warehouse.trim()); + case "oss": + return "oss://" + removeProtocol(ossEndpoint) + "/" + removeProtocol(warehouse.trim()); + case "local": + default: + return "file://" + warehouse.trim(); + } + } + + private String removeProtocol(String str) { + if(EmptyKit.isEmpty(str)) { + return ""; + } + return str.substring(str.indexOf("://") + 3); + } /** * Validate configuration * diff --git a/connectors/paimon-connector/src/main/java/io/tapdata/connector/paimon/service/ManagedIOStreamTableWrite.java b/connectors/paimon-connector/src/main/java/io/tapdata/connector/paimon/service/ManagedIOStreamTableWrite.java new file mode 100644 index 000000000..cc20c9bf4 --- /dev/null +++ b/connectors/paimon-connector/src/main/java/io/tapdata/connector/paimon/service/ManagedIOStreamTableWrite.java @@ -0,0 +1,119 @@ +package io.tapdata.connector.paimon.service; + +import org.apache.paimon.data.BinaryRow; +import org.apache.paimon.data.InternalRow; +import org.apache.paimon.disk.IOManager; +import org.apache.paimon.io.BundleRecords; +import org.apache.paimon.memory.MemoryPoolFactory; +import org.apache.paimon.metrics.MetricRegistry; +import org.apache.paimon.table.sink.CommitMessage; +import org.apache.paimon.table.sink.StreamTableWrite; +import org.apache.paimon.table.sink.TableWrite; +import org.apache.paimon.types.RowType; + +import java.util.List; + +/** + * StreamTableWrite wrapper that owns IOManager lifecycle for non-Flink usage. + * + *

Close order is strict: close write first, then close IOManager in finally block. + */ +public class ManagedIOStreamTableWrite implements StreamTableWrite { + + private final StreamTableWrite delegate; + private final IOManager ioManager; + + public ManagedIOStreamTableWrite(StreamTableWrite delegate, IOManager ioManager) { + this.delegate = delegate; + this.ioManager = ioManager; + } + + @Override + public TableWrite withIOManager(IOManager ioManager) { + delegate.withIOManager(ioManager); + return this; + } + + @Override + public TableWrite withWriteType(RowType writeType) { + delegate.withWriteType(writeType); + return this; + } + + @Override + public TableWrite withMemoryPoolFactory(MemoryPoolFactory memoryPoolFactory) { + delegate.withMemoryPoolFactory(memoryPoolFactory); + return this; + } + + @Override + public BinaryRow getPartition(InternalRow row) { + return delegate.getPartition(row); + } + + @Override + public int getBucket(InternalRow row) { + return delegate.getBucket(row); + } + + @Override + public void write(InternalRow row) throws Exception { + delegate.write(row); + } + + @Override + public void write(InternalRow row, int bucket) throws Exception { + delegate.write(row, bucket); + } + + @Override + public void writeBundle(BinaryRow partition, int bucket, BundleRecords bundle) throws Exception { + delegate.writeBundle(partition, bucket, bundle); + } + + @Override + public void compact(BinaryRow partition, int bucket, boolean fullCompaction) throws Exception { + delegate.compact(partition, bucket, fullCompaction); + } + + @Override + public TableWrite withMetricRegistry(MetricRegistry registry) { + delegate.withMetricRegistry(registry); + return this; + } + + @Override + public List prepareCommit(boolean waitCompaction, long commitIdentifier) throws Exception { + return delegate.prepareCommit(waitCompaction, commitIdentifier); + } + + @Override + public void close() throws Exception { + Exception writeError = null; + try { + delegate.close(); + } catch (Exception e) { + writeError = e; + } + + Exception ioManagerError = null; + if (ioManager != null) { + try { + ioManager.close(); + } catch (Exception e) { + ioManagerError = e; + } + } + + if (writeError != null) { + if (ioManagerError != null) { + writeError.addSuppressed(ioManagerError); + } + throw writeError; + } + if (ioManagerError != null) { + throw ioManagerError; + } + } +} + diff --git a/connectors/paimon-connector/src/main/java/io/tapdata/connector/paimon/service/PaimonService.java b/connectors/paimon-connector/src/main/java/io/tapdata/connector/paimon/service/PaimonService.java index 13c03b816..449fea248 100644 --- a/connectors/paimon-connector/src/main/java/io/tapdata/connector/paimon/service/PaimonService.java +++ b/connectors/paimon-connector/src/main/java/io/tapdata/connector/paimon/service/PaimonService.java @@ -1,40 +1,66 @@ package io.tapdata.connector.paimon.service; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; import io.tapdata.connector.paimon.config.PaimonConfig; +import io.tapdata.entity.event.TapCallbackOffset; import io.tapdata.entity.event.dml.TapDeleteRecordEvent; import io.tapdata.entity.event.dml.TapInsertRecordEvent; import io.tapdata.entity.event.dml.TapRecordEvent; import io.tapdata.entity.event.dml.TapUpdateRecordEvent; +import io.tapdata.entity.logger.Log; import io.tapdata.entity.schema.TapField; import io.tapdata.entity.schema.TapIndex; +import io.tapdata.entity.schema.TapIndexField; import io.tapdata.entity.schema.TapTable; +import io.tapdata.exception.TapPdkRetryableEx; +import io.tapdata.kit.EmptyKit; +import io.tapdata.kit.ErrorKit; +import io.tapdata.kit.StringKit; +import io.tapdata.pdk.apis.context.TapConnectorContext; import io.tapdata.pdk.apis.entity.WriteListResult; +import io.tapdata.pdk.core.utils.CommonUtils; +import org.apache.commons.lang3.StringUtils; import org.apache.hadoop.conf.Configuration; -import org.apache.paimon.catalog.Catalog; -import org.apache.paimon.catalog.CatalogContext; -import org.apache.paimon.catalog.CatalogFactory; -import org.apache.paimon.catalog.Identifier; -import org.apache.paimon.data.BinaryString; -import org.apache.paimon.data.Decimal; -import org.apache.paimon.data.GenericRow; -import org.apache.paimon.data.Timestamp; +import org.apache.hadoop.fs.FileSystem; +import org.apache.paimon.Snapshot; +import org.apache.paimon.catalog.*; +import org.apache.paimon.data.*; +import org.apache.paimon.disk.IOManager; +import org.apache.paimon.fs.FileIO; +import org.apache.paimon.fs.hadoop.HadoopFileIO; import org.apache.paimon.options.Options; -import io.tapdata.entity.schema.value.DateTime; +import org.apache.paimon.reader.RecordReader; import org.apache.paimon.schema.Schema; import org.apache.paimon.table.Table; -import org.apache.paimon.table.sink.BatchTableCommit; -import org.apache.paimon.table.sink.BatchTableWrite; -import org.apache.paimon.table.sink.BatchWriteBuilder; -import org.apache.paimon.table.sink.StreamTableCommit; -import org.apache.paimon.table.sink.StreamTableWrite; -import org.apache.paimon.table.sink.StreamWriteBuilder; -import org.apache.paimon.types.DataField; -import org.apache.paimon.types.DataType; -import org.apache.paimon.types.DataTypes; - +import org.apache.paimon.table.sink.*; +import org.apache.paimon.table.source.*; +import org.apache.paimon.table.source.TableScan.Plan; +import org.apache.paimon.types.*; +import org.apache.paimon.utils.Pair; +import org.apache.paimon.utils.SnapshotManager; + +import java.io.ByteArrayOutputStream; import java.io.Closeable; +import java.io.IOException; +import java.lang.reflect.Field; import java.math.BigDecimal; +import java.security.MessageDigest; +import java.time.LocalDate; +import java.time.LocalTime; +import java.time.ZoneOffset; import java.util.*; +import java.util.concurrent.*; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Consumer; +import java.util.function.Supplier; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +import static org.apache.paimon.disk.IOManagerImpl.splitPaths; /** * Service class for Paimon operations @@ -43,1093 +69,2659 @@ */ public class PaimonService implements Closeable { - private final PaimonConfig config; - private Catalog catalog; - // Note: Both BatchTableWrite and BatchTableCommit only support one-time committing - // We don't cache them and create new instances for each write operation - - public PaimonService(PaimonConfig config) { - this.config = config; - } - - /** - * Initialize Paimon catalog - * - * @throws Exception if initialization fails - */ - public void init() throws Exception { - config.validate(); - - Options options = new Options(); - options.set("warehouse", config.getFullWarehousePath()); - - // Configure storage based on type - configureStorage(options); - - // Create catalog context with Hadoop configuration (for S3A, etc.) - Configuration hadoopConf = buildHadoopConfiguration(); - CatalogContext context = CatalogContext.create(options, hadoopConf); - - // Create catalog - catalog = CatalogFactory.createCatalog(context); - } - - /** - * Configure storage options based on storage type - * - * @param options Paimon options - */ - private void configureStorage(Options options) { - String storageType = config.getStorageType().toLowerCase(); - - switch (storageType) { - case "s3": - options.set("s3.endpoint", config.getS3Endpoint()); - options.set("s3.access-key", config.getS3AccessKey()); - options.set("s3.secret-key", config.getS3SecretKey()); - if (config.getS3Region() != null && !config.getS3Region().isEmpty()) { - options.set("s3.region", config.getS3Region()); - } - options.set("s3.path.style.access", "true"); - break; - case "hdfs": - options.set("fs.defaultFS", "hdfs://" + config.getHdfsHost() + ":" + config.getHdfsPort()); - if (config.getHdfsUser() != null && !config.getHdfsUser().isEmpty()) { - options.set("hadoop.user.name", config.getHdfsUser()); - } - break; - case "oss": - options.set("fs.oss.endpoint", config.getOssEndpoint()); - options.set("fs.oss.accessKeyId", config.getOssAccessKey()); - options.set("fs.oss.accessKeySecret", config.getOssSecretKey()); - break; - case "local": - // No additional configuration needed for local storage - break; - default: - throw new IllegalArgumentException("Unsupported storage type: " + storageType); - } - } - - /** - * Build Hadoop Configuration when needed (e.g., S3A) - */ - private Configuration buildHadoopConfiguration() { - Configuration conf = new Configuration(); - String storageType = config.getStorageType() == null ? "" : config.getStorageType().toLowerCase(); - if ("s3".equals(storageType)) { - String endpoint = config.getS3Endpoint(); - String accessKey = config.getS3AccessKey(); - String secretKey = config.getS3SecretKey(); - String region = config.getS3Region(); - - if (endpoint != null && !endpoint.isEmpty()) { - // Strip scheme for fs.s3a.endpoint, and set SSL flag accordingly - String ep = endpoint.trim(); - boolean https = false; - if (ep.startsWith("http://")) { - ep = ep.substring("http://".length()); - } else if (ep.startsWith("https://")) { - ep = ep.substring("https://".length()); - https = true; - } - conf.set("fs.s3a.endpoint", ep); - conf.setBoolean("fs.s3a.connection.ssl.enabled", https); - } - if (accessKey != null) { - conf.set("fs.s3a.access.key", accessKey); - } - if (secretKey != null) { - conf.set("fs.s3a.secret.key", secretKey); - } - if (region != null && !region.isEmpty()) { - conf.set("fs.s3a.region", region); - } - // Path-style access is typically needed for MinIO - conf.setBoolean("fs.s3a.path.style.access", true); - // Use simple static credentials to avoid picking up instance profiles accidentally - conf.set("fs.s3a.aws.credentials.provider", "org.apache.hadoop.fs.s3a.SimpleAWSCredentialsProvider"); - // Do NOT force-map s3 scheme to S3A here. Paimon S3 plugin shades Hadoop classes - // and handles scheme registration internally. Forcing mappings can cause - // NoClassDefFoundError due to classloader/version conflicts. - // Ensure S3A filesystem is used when scheme is s3a - conf.set("fs.s3a.impl", "org.apache.hadoop.fs.s3a.S3AFileSystem"); - conf.set("fs.AbstractFileSystem.s3a.impl", "org.apache.hadoop.fs.s3a.S3A"); - - } - return conf; - } - - /** - * Test warehouse accessibility - * - * @return true if warehouse is accessible - */ - public boolean testWarehouseAccess() { - try { - // Try to list databases - catalog.listDatabases(); - return true; - } catch (Exception e) { - return false; - } - } - - /** - * Test write permission - * - * @return true if write permission is available - */ - public boolean testWritePermission() { - try { - // Try to create a test database if it doesn't exist - String testDb = config.getDatabase(); - try { - catalog.getDatabase(testDb); - // Database exists - } catch (Catalog.DatabaseNotExistException e) { - // Database does not exist, create it - catalog.createDatabase(testDb, true); - } - return true; - } catch (Exception e) { - return false; - } - } - - /** - * Get table count in the database - * - * @return number of tables - * @throws Exception if query fails - */ - public int getTableCount() throws Exception { - String database = config.getDatabase(); - - // Check if database exists - try { - catalog.getDatabase(database); - } catch (Catalog.DatabaseNotExistException e) { - // Database does not exist - return 0; - } - - // Get all tables in database - List tables = catalog.listTables(database); - return tables != null ? tables.size() : 0; - } - - /** - * Discover tables in Paimon - * - * @param tableNames list of table names to discover (null for all) - * @return list of discovered tables - * @throws Exception if discovery fails - */ - public List discoverTables(List tableNames) throws Exception { - List tables = new ArrayList<>(); - String database = config.getDatabase(); - - // Ensure database exists - try { - catalog.getDatabase(database); - } catch (Catalog.DatabaseNotExistException e) { - // Database does not exist - return tables; - } - - // Get all tables in database - List allTables = catalog.listTables(database); - - // Filter tables if specific names provided - if (tableNames != null && !tableNames.isEmpty()) { - allTables.retainAll(tableNames); - } - - // Load schema for each table - for (String tableName : allTables) { - try { - Identifier identifier = Identifier.create(database, tableName); - Table paimonTable = catalog.getTable(identifier); - - TapTable tapTable = convertToTapTable(tableName, paimonTable); - tables.add(tapTable); - } catch (Exception e) { - // Skip tables that cannot be loaded - } - } - - return tables; - } - - /** - * Convert Paimon table to TapTable - * - * @param tableName table name - * @param paimonTable Paimon table - * @return TapTable - */ - private TapTable convertToTapTable(String tableName, Table paimonTable) { - TapTable tapTable = new TapTable(tableName); - - // Convert fields - List fields = paimonTable.rowType().getFields(); - for (DataField field : fields) { - TapField tapField = new TapField(field.name(), convertDataType(field.type())); - tapField.setNullable(field.type().isNullable()); - tapTable.add(tapField); - } - - // Set primary keys - List primaryKeys = paimonTable.primaryKeys(); - if (primaryKeys != null && !primaryKeys.isEmpty()) { - tapTable.add(new io.tapdata.entity.schema.TapIndex() - .name("PRIMARY") - .unique(true) - .primary(true)); - } - - return tapTable; - } - - /** - * Convert Paimon data type to Tapdata type name - * - * @param dataType Paimon data type - * @return Tapdata type name - */ - private String convertDataType(DataType dataType) { - String typeString = dataType.toString().toUpperCase(); - - if (dataType.equals(DataTypes.BOOLEAN())) { - return "BOOLEAN"; - } else if (dataType.equals(DataTypes.TINYINT())) { - return "TINYINT"; - } else if (dataType.equals(DataTypes.SMALLINT())) { - return "SMALLINT"; - } else if (dataType.equals(DataTypes.INT())) { - return "INT"; - } else if (dataType.equals(DataTypes.BIGINT())) { - return "BIGINT"; - } else if (dataType.equals(DataTypes.FLOAT())) { - return "FLOAT"; - } else if (dataType.equals(DataTypes.DOUBLE())) { - return "DOUBLE"; - } else if (dataType.equals(DataTypes.STRING())) { - return "STRING"; - } else if (dataType.equals(DataTypes.DATE())) { - return "DATE"; - } else if (dataType.equals(DataTypes.TIMESTAMP())) { - return "TIMESTAMP"; - } else if (typeString.startsWith("ARRAY")) { - return "ARRAY"; - } else if (typeString.startsWith("MAP")) { - return "MAP"; - } else if (typeString.startsWith("ROW")) { - return "ROW"; - } else { - return "STRING"; // Default to STRING for unknown types - } - } - - /** - * Create table in Paimon - * - * @param tapTable table definition - * @return true if created, false if already exists - * @throws Exception if creation fails - */ - public boolean createTable(TapTable tapTable) throws Exception { - String database = config.getDatabase(); - String tableName = tapTable.getName(); - - // Ensure database exists - try { - catalog.getDatabase(database); - } catch (Catalog.DatabaseNotExistException e) { - // Database does not exist, create it - catalog.createDatabase(database, true); - } - - Identifier identifier = Identifier.create(database, tableName); - - // Check if table already exists - try { - catalog.getTable(identifier); - // Table exists, check if bucket mode matches - boolean existingIsDynamic = isTableDynamicBucket(identifier); - boolean configIsDynamic = config.isDynamicBucketMode(); - - if (existingIsDynamic != configIsDynamic) { - // Bucket mode mismatch, need to recreate table - // WARNING: This will delete all existing data - catalog.dropTable(identifier, true); - // Continue to create table with new bucket mode - } else { - // Table exists and bucket mode matches, no need to recreate - return false; - } - } catch (Catalog.TableNotExistException e) { - // Table does not exist, continue to create - } - - // Build schema - Schema.Builder schemaBuilder = Schema.newBuilder(); - - // Add fields - Map fields = tapTable.getNameFieldMap(); - if (fields != null) { - for (Map.Entry entry : fields.entrySet()) { - String fieldName = entry.getKey(); - TapField tapField = entry.getValue(); - DataType dataType = convertToPaimonDataType(tapField); - schemaBuilder.column(fieldName, dataType); - } - } - - // Set primary keys - Collection primaryKeys = tapTable.primaryKeys(); - if (primaryKeys != null && !primaryKeys.isEmpty()) { - schemaBuilder.primaryKey(new ArrayList<>(primaryKeys)); - } - - // Set bucket configuration based on bucket mode - if (config.isDynamicBucketMode()) { - // Dynamic bucket mode: set bucket to -1 - // This mode uses StreamTableWrite and provides better flexibility - schemaBuilder.option("bucket", "-1"); - } else { - // Fixed bucket mode: set specific bucket count - // This mode uses BatchTableWrite and provides better performance - Integer bucketCount = config.getBucketCount(); - if (bucketCount == null || bucketCount <= 0) { - bucketCount = 4; // Default to 4 buckets if not configured - } - schemaBuilder.option("bucket", String.valueOf(bucketCount)); - } - - // Create table - catalog.createTable(identifier, schemaBuilder.build(), false); - - return true; - } - - /** - * Convert TapField to Paimon DataType - * - * @param tapField TapField - * @return Paimon DataType - */ - private DataType convertToPaimonDataType(TapField tapField) { - String dataType = tapField.getDataType(); - if (dataType == null) { - return DataTypes.STRING(); - } - - dataType = dataType.toUpperCase(); - - if (dataType.contains("BOOLEAN") || dataType.contains("BOOL")) { - return DataTypes.BOOLEAN(); - } else if (dataType.contains("TINYINT") || dataType.contains("INT8")) { - return DataTypes.TINYINT(); - } else if (dataType.contains("SMALLINT") || dataType.contains("INT16")) { - return DataTypes.SMALLINT(); - } else if (dataType.contains("BIGINT") || dataType.contains("INT64") || dataType.contains("LONG")) { - return DataTypes.BIGINT(); - } else if (dataType.contains("INT") || dataType.contains("INT32") || dataType.contains("INTEGER")) { - return DataTypes.INT(); - } else if (dataType.contains("FLOAT")) { - return DataTypes.FLOAT(); - } else if (dataType.contains("DOUBLE") || dataType.contains("NUMBER")) { - return DataTypes.DOUBLE(); - } else if (dataType.contains("DECIMAL")) { - return DataTypes.DECIMAL(38, 10); - } else if (dataType.contains("DATE")) { - return DataTypes.DATE(); - } else if (dataType.contains("TIMESTAMP") || dataType.contains("DATETIME")) { - return DataTypes.TIMESTAMP(); - } else if (dataType.contains("BINARY") || dataType.contains("BYTES")) { - return DataTypes.BYTES(); - } else if (dataType.contains("ARRAY")) { - // For ARRAY type, use STRING to store JSON representation - // Paimon ARRAY requires element type specification which we don't have here - return DataTypes.STRING(); - } else if (dataType.contains("MAP") || dataType.contains("ROW")) { - // For MAP/ROW type, use STRING to store JSON representation - // Paimon MAP requires key/value type specification which we don't have here - return DataTypes.STRING(); - } else { - return DataTypes.STRING(); - } - } - - /** - * Drop table from Paimon - * - * @param tableName table name - * @throws Exception if drop fails - */ - public void dropTable(String tableName) throws Exception { - String database = config.getDatabase(); - Identifier identifier = Identifier.create(database, tableName); - - try { - catalog.getTable(identifier); - // Table exists, proceed to drop - catalog.dropTable(identifier, true); - } catch (Catalog.TableNotExistException e) { - // Table does not exist, do nothing - } - } - - /** - * Clear all data from table - * - * @param tableName table name - * @throws Exception if clear fails - */ - public void clearTable(String tableName) throws Exception { - String database = config.getDatabase(); - Identifier identifier = Identifier.create(database, tableName); - - // Get table, if not exists, return - Table table; - try { - table = catalog.getTable(identifier); - } catch (Catalog.TableNotExistException e) { - // Table does not exist, nothing to clear - return; - } - - // Drop and recreate table to clear data - - // Rebuild schema from table - Schema.Builder schemaBuilder = Schema.newBuilder(); - - // Add fields from rowType - List fields = table.rowType().getFields(); - for (DataField field : fields) { - schemaBuilder.column(field.name(), field.type()); - } - - // Add primary keys - List primaryKeys = table.primaryKeys(); - if (primaryKeys != null && !primaryKeys.isEmpty()) { - schemaBuilder.primaryKey(primaryKeys); - } - - // Preserve all table options (including bucket configuration) - // But exclude options that cannot be used when creating table with FileSystemCatalog - Map options = table.options(); - if (options != null && !options.isEmpty()) { - for (Map.Entry entry : options.entrySet()) { - String key = entry.getKey(); - // Skip 'path' option as FileSystemCatalog doesn't support custom table path - if ("path".equals(key)) { - continue; - } - schemaBuilder.option(key, entry.getValue()); - } - } - - Schema schema = schemaBuilder.build(); - - catalog.dropTable(identifier, true); - catalog.createTable(identifier, schema, false); - } - - /** - * Create index on table - * Note: Paimon doesn't support traditional indexes, but we can log the request - * - * @param table table definition - * @param indexList list of indexes to create - */ - public void createIndex(TapTable table, List indexList) { - // Paimon doesn't support traditional indexes - // Primary keys are already handled during table creation - // This method is a no-op but required by the interface - } - - /** - * Write records to Paimon table - * - * @param recordEvents list of record events - * @param table target table - * @param insertPolicy insert policy - * @param updatePolicy update policy - * @return write result - * @throws Exception if write fails - */ - public WriteListResult writeRecords(List recordEvents, - TapTable table, - String insertPolicy, - String updatePolicy) throws Exception { - // Detect actual bucket mode from the table - String database = config.getDatabase(); - String tableName = table.getName(); - Identifier identifier = Identifier.create(database, tableName); - - boolean isDynamicBucket = isTableDynamicBucket(identifier); - - // Choose write method based on actual table bucket mode - if (isDynamicBucket) { - return writeRecordsWithStreamWrite(recordEvents, table, insertPolicy, updatePolicy); - } else { - return writeRecordsWithBatchWrite(recordEvents, table, insertPolicy, updatePolicy); - } - } - - /** - * Check if table is using dynamic bucket mode - * - * @param identifier table identifier - * @return true if dynamic bucket mode, false if fixed bucket mode - * @throws Exception if check fails - */ - private boolean isTableDynamicBucket(Identifier identifier) throws Exception { - Table paimonTable = catalog.getTable(identifier); - // Get bucket option from table options - String bucketOption = paimonTable.options().get("bucket"); - - // If bucket is -1 or not set, it's dynamic bucket mode - if (bucketOption == null) { - return true; // Default is dynamic - } - - try { - int bucket = Integer.parseInt(bucketOption); - return bucket == -1; - } catch (NumberFormatException e) { - return true; // If parse fails, assume dynamic - } - } - - /** - * Write records using BatchTableWrite (for fixed bucket mode) - * - * @param recordEvents list of record events - * @param table target table - * @param insertPolicy insert policy - * @param updatePolicy update policy - * @return write result - * @throws Exception if write fails - */ - private WriteListResult writeRecordsWithBatchWrite(List recordEvents, - TapTable table, - String insertPolicy, - String updatePolicy) throws Exception { - WriteListResult result = new WriteListResult<>(); - String database = config.getDatabase(); - String tableName = table.getName(); - Identifier identifier = Identifier.create(database, tableName); - - // Create new writer and commit for each batch - // Note: Both BatchTableWrite and BatchTableCommit only support one-time committing - BatchTableWrite writer = createBatchWriter(identifier); - BatchTableCommit commit = createBatchCommit(identifier); - - try { - for (TapRecordEvent event : recordEvents) { - if (event instanceof TapInsertRecordEvent) { - handleBatchInsert((TapInsertRecordEvent) event, writer, table); - result.incrementInserted(1); - } else if (event instanceof TapUpdateRecordEvent) { - handleBatchUpdate((TapUpdateRecordEvent) event, writer, table, updatePolicy); - result.incrementModified(1); - } else if (event instanceof TapDeleteRecordEvent) { - handleBatchDelete((TapDeleteRecordEvent) event, writer, table); - result.incrementRemove(1); - } - } - - // Commit the batch - commit.commit(writer.prepareCommit()); - - } catch (Exception e) { - throw new RuntimeException("Failed to write records to table " + tableName, e); - } finally { - // Close writer and commit after use since they only support one-time committing - try { - writer.close(); - } catch (Exception e) { - // Ignore close errors - } - try { - commit.close(); - } catch (Exception e) { - // Ignore close errors - } - } - - return result; - } - - /** - * Write records using StreamTableWrite (for dynamic bucket mode) - * - * @param recordEvents list of record events - * @param table target table - * @param insertPolicy insert policy - * @param updatePolicy update policy - * @return write result - * @throws Exception if write fails - */ - private WriteListResult writeRecordsWithStreamWrite(List recordEvents, - TapTable table, - String insertPolicy, - String updatePolicy) throws Exception { - WriteListResult result = new WriteListResult<>(); - String database = config.getDatabase(); - String tableName = table.getName(); - Identifier identifier = Identifier.create(database, tableName); - - // Create stream writer and commit - StreamTableWrite writer = createStreamWriter(identifier); - StreamTableCommit commit = createStreamCommit(identifier); - - try { - for (TapRecordEvent event : recordEvents) { - if (event instanceof TapInsertRecordEvent) { - handleStreamInsert((TapInsertRecordEvent) event, writer, table); - result.incrementInserted(1); - } else if (event instanceof TapUpdateRecordEvent) { - handleStreamUpdate((TapUpdateRecordEvent) event, writer, table, updatePolicy); - result.incrementModified(1); - } else if (event instanceof TapDeleteRecordEvent) { - handleStreamDelete((TapDeleteRecordEvent) event, writer, table); - result.incrementRemove(1); - } - } - - // Prepare commit with commitIdentifier - // Use current timestamp as commitIdentifier for simplicity - long commitIdentifier = System.currentTimeMillis(); - List messages = writer.prepareCommit(false, commitIdentifier); - - // Commit the batch - commit.commit(commitIdentifier, messages); - - } catch (Exception e) { - throw new RuntimeException("Failed to write records to table " + tableName, e); - } finally { - // Close writer and commit after use - try { - writer.close(); - } catch (Exception e) { - // Ignore close errors - } - try { - commit.close(); - } catch (Exception e) { - // Ignore close errors - } - } - - return result; - } - - /** - * Create a new batch writer for table - * Note: BatchTableWrite only supports one-time committing, so we create a new writer each time - * - * @param identifier table identifier - * @return batch table writer - * @throws Exception if creation fails - */ - private BatchTableWrite createBatchWriter(Identifier identifier) throws Exception { - Table table = catalog.getTable(identifier); - BatchWriteBuilder writeBuilder = table.newBatchWriteBuilder(); - return writeBuilder.newWrite(); - } - - /** - * Create a new batch commit for table - * Note: BatchTableCommit only supports one-time committing, so we create a new commit each time - * - * @param identifier table identifier - * @return batch table commit - * @throws Exception if creation fails - */ - private BatchTableCommit createBatchCommit(Identifier identifier) throws Exception { - Table table = catalog.getTable(identifier); - BatchWriteBuilder writeBuilder = table.newBatchWriteBuilder(); - return writeBuilder.newCommit(); - } - - /** - * Create a new stream writer for table - * - * @param identifier table identifier - * @return stream table writer - * @throws Exception if creation fails - */ - private StreamTableWrite createStreamWriter(Identifier identifier) throws Exception { - Table table = catalog.getTable(identifier); - StreamWriteBuilder writeBuilder = table.newStreamWriteBuilder(); - return writeBuilder.newWrite(); - } - - /** - * Create a new stream commit for table - * - * @param identifier table identifier - * @return stream table commit - * @throws Exception if creation fails - */ - private StreamTableCommit createStreamCommit(Identifier identifier) throws Exception { - Table table = catalog.getTable(identifier); - StreamWriteBuilder writeBuilder = table.newStreamWriteBuilder(); - return writeBuilder.newCommit(); - } - - /** - * Handle insert event with batch writer - * - * @param event insert event - * @param writer batch writer - * @param table table definition - * @throws Exception if insert fails - */ - private void handleBatchInsert(TapInsertRecordEvent event, BatchTableWrite writer, TapTable table) throws Exception { - Map after = event.getAfter(); - String database = config.getDatabase(); - Identifier identifier = Identifier.create(database, table.getName()); - GenericRow row = convertToGenericRow(after, table, identifier); - writer.write(row); - } - - /** - * Handle update event with batch writer - * - * @param event update event - * @param writer batch writer - * @param table table definition - * @param updatePolicy update policy - * @throws Exception if update fails - */ - private void handleBatchUpdate(TapUpdateRecordEvent event, BatchTableWrite writer, - TapTable table, String updatePolicy) throws Exception { - Map after = event.getAfter(); - String database = config.getDatabase(); - Identifier identifier = Identifier.create(database, table.getName()); - GenericRow row = convertToGenericRow(after, table, identifier); - writer.write(row); - } - - /** - * Handle delete event with batch writer - * - * @param event delete event - * @param writer batch writer - * @param table table definition - * @throws Exception if delete fails - */ - private void handleBatchDelete(TapDeleteRecordEvent event, BatchTableWrite writer, TapTable table) throws Exception { - Map before = event.getBefore(); - String database = config.getDatabase(); - Identifier identifier = Identifier.create(database, table.getName()); - GenericRow row = convertToGenericRow(before, table, identifier); - // Set row kind to DELETE - row.setRowKind(org.apache.paimon.types.RowKind.DELETE); - writer.write(row); - } - - /** - * Handle insert event with stream writer - * - * @param event insert event - * @param writer stream writer - * @param table table definition - * @throws Exception if insert fails - */ - private void handleStreamInsert(TapInsertRecordEvent event, StreamTableWrite writer, TapTable table) throws Exception { - Map after = event.getAfter(); - String database = config.getDatabase(); - Identifier identifier = Identifier.create(database, table.getName()); - GenericRow row = convertToGenericRow(after, table, identifier); - int bucket = selectBucketForDynamic(after, table); - writer.write(row, bucket); - } - - /** - * Handle update event with stream writer - * - * @param event update event - * @param writer stream writer - * @param table table definition - * @param updatePolicy update policy - * @throws Exception if update fails - */ - private void handleStreamUpdate(TapUpdateRecordEvent event, StreamTableWrite writer, - TapTable table, String updatePolicy) throws Exception { - Map after = event.getAfter(); - String database = config.getDatabase(); - Identifier identifier = Identifier.create(database, table.getName()); - GenericRow row = convertToGenericRow(after, table, identifier); - int bucket = selectBucketForDynamic(after, table); - writer.write(row, bucket); - } - - /** - * Handle delete event with stream writer - * - * @param event delete event - * @param writer stream writer - * @param table table definition - * @throws Exception if delete fails - */ - private void handleStreamDelete(TapDeleteRecordEvent event, StreamTableWrite writer, TapTable table) throws Exception { - Map before = event.getBefore(); - String database = config.getDatabase(); - Identifier identifier = Identifier.create(database, table.getName()); - GenericRow row = convertToGenericRow(before, table, identifier); - // Set row kind to DELETE - row.setRowKind(org.apache.paimon.types.RowKind.DELETE); - int bucket = selectBucketForDynamic(before, table); - writer.write(row, bucket); - } - - /** - * Select deterministic bucket for dynamic-bucket tables. - * Use primary keys if present; otherwise hash all fields (sorted by name). - */ - private int selectBucketForDynamic(Map data, TapTable table) { - int hint = (config.getBucketCount() != null && config.getBucketCount() > 0) ? config.getBucketCount() : 4; - int hash = 0; - Collection pks = table.primaryKeys(); - if (pks != null && !pks.isEmpty()) { - for (String key : pks) { - Object v = data.get(key); - hash = 31 * hash + (v == null ? 0 : v.hashCode()); - } - } else { - Map fields = table.getNameFieldMap(); - if (fields != null && !fields.isEmpty()) { - List names = new ArrayList<>(fields.keySet()); - Collections.sort(names); - for (String name : names) { - Object v = data.get(name); - hash = 31 * hash + (v == null ? 0 : v.hashCode()); - } - } else { - for (Map.Entry e : data.entrySet()) { - Object v = e.getValue(); - hash = 31 * hash + (v == null ? 0 : v.hashCode()); - } - } - } - return Math.floorMod(hash, hint); - } - - /** - * Convert map to GenericRow - * - * @param data data map - * @param table table definition - * @param identifier table identifier - * @return GenericRow - * @throws Exception if conversion fails - */ - private GenericRow convertToGenericRow(Map data, TapTable table, Identifier identifier) throws Exception { - // Get Paimon table to access actual field types - Table paimonTable = catalog.getTable(identifier); - List paimonFields = paimonTable.rowType().getFields(); - - Map tapFields = table.getNameFieldMap(); - int fieldCount = tapFields.size(); - Object[] values = new Object[fieldCount]; - - int index = 0; - for (Map.Entry entry : tapFields.entrySet()) { - String fieldName = entry.getKey(); - Object value = data.get(fieldName); - - // Get corresponding Paimon field type - DataType paimonType = null; - for (DataField paimonField : paimonFields) { - if (paimonField.name().equals(fieldName)) { - paimonType = paimonField.type(); - break; - } - } - - // Convert value to Paimon-compatible type - values[index++] = convertValueToPaimonType(value, paimonType); - } - - return GenericRow.of(values); - } - - /** - * Convert value to Paimon-compatible type - * - * @param value original value - * @param paimonType target Paimon data type - * @return converted value - */ - private Object convertValueToPaimonType(Object value, DataType paimonType) { - if (value == null || paimonType == null) { - return null; - } - - // Get the type root for comparison (ignores nullable attribute) - String typeString = paimonType.toString().toUpperCase(); - - // Handle STRING type - convert to BinaryString - if (typeString.contains("STRING") || typeString.contains("VARCHAR") || typeString.contains("CHAR")) { - if (value instanceof String) { - return BinaryString.fromString((String) value); - } else { - return BinaryString.fromString(String.valueOf(value)); - } - } - - // Handle TIMESTAMP type - if (typeString.contains("TIMESTAMP")) { - if (value instanceof DateTime) { - DateTime dateTime = (DateTime) value; - // Convert DateTime to Paimon Timestamp - // DateTime.getSeconds() returns seconds since epoch (can be negative for dates before 1970) - // DateTime.getNano() returns nanoseconds part - long epochSecond = dateTime.getSeconds(); - int nanoSecond = dateTime.getNano(); - - // Convert to milliseconds and nanos-of-millisecond - // Similar to Timestamp.fromInstant() implementation - long millisecond = epochSecond * 1000L + nanoSecond / 1_000_000; - int nanoOfMillisecond = nanoSecond % 1_000_000; - - // Ensure nanoOfMillisecond is always positive (0-999,999) - if (nanoOfMillisecond < 0) { - millisecond -= 1; - nanoOfMillisecond += 1_000_000; - } - - return Timestamp.fromEpochMillis(millisecond, nanoOfMillisecond); - } else if (value instanceof java.sql.Timestamp) { - java.sql.Timestamp ts = (java.sql.Timestamp) value; - return Timestamp.fromEpochMillis(ts.getTime()); - } else if (value instanceof java.util.Date) { - java.util.Date date = (java.util.Date) value; - return Timestamp.fromEpochMillis(date.getTime()); - } else if (value instanceof Long) { - return Timestamp.fromEpochMillis((Long) value); - } - } - - // Handle DATE type - if (typeString.contains("DATE") && !typeString.contains("TIMESTAMP")) { - if (value instanceof DateTime) { - DateTime dateTime = (DateTime) value; - // Convert to days since epoch (1970-01-01) - long millis = dateTime.getSeconds() * 1000L; - return (int) (millis / (1000 * 60 * 60 * 24)); - } else if (value instanceof java.sql.Date) { - java.sql.Date date = (java.sql.Date) value; - // Convert to days since epoch (1970-01-01) - return (int) (date.getTime() / (1000 * 60 * 60 * 24)); - } else if (value instanceof java.util.Date) { - java.util.Date date = (java.util.Date) value; - return (int) (date.getTime() / (1000 * 60 * 60 * 24)); - } - } - - // Handle numeric types - ensure correct Java type - if (typeString.contains("TINYINT")) { - if (value instanceof Number) { - return ((Number) value).byteValue(); - } - } - - if (typeString.contains("SMALLINT")) { - if (value instanceof Number) { - return ((Number) value).shortValue(); - } - } - - if (typeString.contains("INT") && !typeString.contains("BIGINT") && !typeString.contains("SMALLINT") && !typeString.contains("TINYINT")) { - if (value instanceof Number) { - return ((Number) value).intValue(); - } - } - - if (typeString.contains("BIGINT")) { - if (value instanceof Number) { - return ((Number) value).longValue(); - } - } - - if (typeString.contains("FLOAT")) { - if (value instanceof Number) { - return ((Number) value).floatValue(); - } - } - - if (typeString.contains("DOUBLE")) { - if (value instanceof Number) { - return ((Number) value).doubleValue(); - } - } - - // Handle DECIMAL type - if (typeString.contains("DECIMAL")) { - if (value instanceof BigDecimal) { - java.math.BigDecimal bigDecimal = (BigDecimal) value; - // Extract precision and scale from the type string - // Format: DECIMAL(precision, scale) - int precision = 38; // default precision - int scale = 10; // default scale - - try { - int startIdx = typeString.indexOf("("); - int commaIdx = typeString.indexOf(","); - int endIdx = typeString.indexOf(")"); - - if (startIdx > 0 && commaIdx > 0 && endIdx > 0) { - precision = Integer.parseInt(typeString.substring(startIdx + 1, commaIdx).trim()); - scale = Integer.parseInt(typeString.substring(commaIdx + 1, endIdx).trim()); - } - } catch (Exception e) { - // Use default values if parsing fails - } - - return Decimal.fromBigDecimal(bigDecimal, precision, scale); - } else if (value instanceof Number) { - // Convert other numeric types to BigDecimal first - BigDecimal bigDecimal = new BigDecimal(value.toString()); - return Decimal.fromBigDecimal(bigDecimal, 38, 10); - } else if (value instanceof String) { - // Convert string to BigDecimal - BigDecimal bigDecimal = new BigDecimal((String) value); - return Decimal.fromBigDecimal(bigDecimal, 38, 10); - } - } - - if (typeString.contains("BOOLEAN")) { - if (value instanceof Boolean) { - return value; - } else if (value instanceof Number) { - return ((Number) value).intValue() != 0; - } else if (value instanceof String) { - return Boolean.parseBoolean((String) value); - } - } - - // For other types, return as-is - return value; - } - - @Override - public void close() { - // Close catalog - if (catalog != null) { - try { - catalog.close(); - } catch (Exception e) { - // Ignore close errors + private static final String TAG = PaimonService.class.getName(); + private static final String HASH_KEY = "_hash_key"; + public static final String HASH_ALGORITHM = "MD5"; + public static final byte SPLIT_CHAR = ','; + private final Map computeHashKey = new ConcurrentHashMap<>(); + private final Map> primaryKeyMap = new ConcurrentHashMap<>(); + private final PaimonConfig config; + private Catalog catalog; + + // Cache writers and commits per table for long lifecycle + // Key: database.tableName + private final Map streamWriterCache = new ConcurrentHashMap<>(); + private final Map streamCommitCache = new ConcurrentHashMap<>(); + + // Atomic counter for generating unique, incrementing commit identifiers + // This ensures no duplicate commit identifiers even in high-concurrency scenarios + private final AtomicLong commitIdentifierGenerator = new AtomicLong(0); + + // ===== Batch Accumulation for Performance ===== + // Track accumulated records per table before commit + private final Map accumulatedRecordCount = new ConcurrentHashMap<>(); + // Track last commit time per table + private final Map lastCommitTime = new ConcurrentHashMap<>(); + // Lock for commit operations per table + private final Map commitLocks = new ConcurrentHashMap<>(); + + // ===== Async Commit Support ===== + // Background thread for async commits + private ScheduledExecutorService asyncCommitExecutor; + private final Map firstOffsetByTable; + private Consumer flushOffsetCallback; + + // ===== Paimon Field Cache for Performance ===== + // LRU cache for Paimon field mappings: Key = "database.tableName", Value = Map + // Limit to 5 tables to avoid excessive memory usage + private final Map> paimonFieldCache = Collections.synchronizedMap( + new LinkedHashMap>(5, 0.75f, true) { + private static final long serialVersionUID = 1L; + + @Override + protected boolean removeEldestEntry(Map.Entry> eldest) { + return size() > 10; + } + } + ); + + // LRU cache for field index mappings: Key = "database.tableName", Value = Map + // Limit to 5 tables to avoid excessive memory usage + private final Map> fieldIndexCache = Collections.synchronizedMap( + new LinkedHashMap>(5, 0.75f, true) { + private static final long serialVersionUID = 1L; + + @Override + protected boolean removeEldestEntry(Map.Entry> eldest) { + return size() > 10; + } + } + ); + /** + * save tapContext log + */ + private Log log; + + public PaimonService(PaimonConfig config, Log log) { + this.log = log; + this.config = config; + this.firstOffsetByTable = Collections.synchronizedMap(new LinkedHashMap<>()); + } + + /** + * Initialize Paimon catalog + * + * @throws Exception if initialization fails + */ + public void init() throws Exception { + config.validate(); + + Options options = new Options(); + options.set("warehouse", config.getFullWarehousePath()); + + // Configure storage based on type + configureStorage(options); + + // Create catalog context with Hadoop configuration (for S3A, etc.) + Configuration hadoopConf = buildHadoopConfiguration(); + CatalogContext context = CatalogContext.create(options, hadoopConf); + + // Create catalog + catalog = CatalogFactory.createCatalog(context); + + // Initialize async commit if enabled + initAsyncCommit(); + } + + /** + * Initialize async commit executor if enabled in config + */ + private void initAsyncCommit() { + Boolean enableAsync = config.getEnableAsyncCommit(); + Integer commitInterval = config.getCommitIntervalMs(); + + if (enableAsync != null && enableAsync && commitInterval != null && commitInterval > 0) { + // Create scheduled executor with single thread + asyncCommitExecutor = Executors.newSingleThreadScheduledExecutor(r -> { + Thread t = new Thread(r, "paimon-async-commit"); + t.setDaemon(true); // Daemon thread won't prevent JVM shutdown + return t; + }); + + // Schedule periodic commit task + asyncCommitExecutor.scheduleAtFixedRate(() -> { + try { + // Commit all tables that have accumulated data + for (String tableKey : new ArrayList<>(accumulatedRecordCount.keySet())) { + AtomicInteger count = accumulatedRecordCount.get(tableKey); + if (count != null && count.get() > 0) { + // Check if enough time has passed since last commit + AtomicLong lastCommit = lastCommitTime.get(tableKey); + if (lastCommit != null) { + long timeSinceLastCommit = System.currentTimeMillis() - lastCommit.get(); + if (timeSinceLastCommit >= commitInterval) { + flushTable(tableKey); + } + } + } + } + } catch (Exception e) { + // Log error but don't stop the scheduler + log.error("Error in async commit: {}", e.getMessage(), e); + } + }, commitInterval, commitInterval, TimeUnit.MILLISECONDS); + } + } + + /** + * Configure storage options based on storage type + * + * @param options Paimon options + */ + private void configureStorage(Options options) { + String storageType = config.getStorageType().toLowerCase(); + + switch (storageType) { + case "s3": + options.set("s3.endpoint", config.getS3Endpoint()); + options.set("s3.access-key", config.getS3AccessKey()); + options.set("s3.secret-key", config.getS3SecretKey()); + if (config.getS3Region() != null && !config.getS3Region().isEmpty()) { + options.set("s3.region", config.getS3Region()); + } + options.set("s3.path.style.access", "true"); + options.set("s3.upload.max-concurrency", "20"); + options.set("s3.upload.part-size", "16mb"); + options.set("s3.fast-upload", "true"); + options.set("s3.accelerate-mode", "true"); + // 解决连接重置:调低并发、增大超时 +// options.set("fs.s3a.connection.maximum", "32"); +// options.set("fs.s3a.connection.timeout", "300000"); +// options.set("fs.s3a.socket.timeout", "300000"); +// // 重试机制(解决临时连接失败) +// options.set("fs.s3a.retry.limit", "5"); +// options.set("fs.s3a.retry.interval", "1000"); + + break; + case "hdfs": + options.set("fs.defaultFS", "hdfs://" + config.getHdfsHost() + ":" + config.getHdfsPort()); + if (config.getHdfsUser() != null && !config.getHdfsUser().isEmpty()) { + options.set("hadoop.user.name", config.getHdfsUser()); + } + break; + case "oss": + options.set("fs.oss.endpoint", config.getOssEndpoint()); + options.set("fs.oss.accessKeyId", config.getOssAccessKey()); + options.set("fs.oss.accessKeySecret", config.getOssSecretKey()); + break; + case "local": + // No additional configuration needed for local storage + break; + default: + throw new IllegalArgumentException("Unsupported storage type: " + storageType); + } + } + + /** + * Build Hadoop Configuration when needed (e.g., S3A) + */ + private Configuration buildHadoopConfiguration() { + Configuration conf = new Configuration(); + String storageType = config.getStorageType() == null ? "" : config.getStorageType().toLowerCase(); + if ("s3".equals(storageType)) { + String endpoint = config.getS3Endpoint(); + String accessKey = config.getS3AccessKey(); + String secretKey = config.getS3SecretKey(); + String region = config.getS3Region(); + + if (endpoint != null && !endpoint.isEmpty()) { + // Strip scheme for fs.s3a.endpoint, and set SSL flag accordingly + String ep = endpoint.trim(); + boolean https = false; + if (ep.startsWith("http://")) { + ep = ep.substring("http://".length()); + } else if (ep.startsWith("https://")) { + ep = ep.substring("https://".length()); + https = true; + } + conf.set("fs.s3a.endpoint", ep); + conf.setBoolean("fs.s3a.connection.ssl.enabled", https); + } + if (accessKey != null) { + conf.set("fs.s3a.access.key", accessKey); + } + if (secretKey != null) { + conf.set("fs.s3a.secret.key", secretKey); + } + if (region != null && !region.isEmpty()) { + conf.set("fs.s3a.region", region); + } + // Path-style access is typically needed for MinIO + conf.setBoolean("fs.s3a.path.style.access", true); + // Use simple static credentials to avoid picking up instance profiles accidentally + conf.set("fs.s3a.aws.credentials.provider", "org.apache.hadoop.fs.s3a.SimpleAWSCredentialsProvider"); + // Do NOT force-map s3 scheme to S3A here. Paimon S3 plugin shades Hadoop classes + // and handles scheme registration internally. Forcing mappings can cause + // NoClassDefFoundError due to classloader/version conflicts. + // Ensure S3A filesystem is used when scheme is s3a + conf.set("fs.s3a.impl", "org.apache.hadoop.fs.s3a.S3AFileSystem"); + conf.set("fs.s3a.impl.disable.cache", "true"); + conf.set("fs.AbstractFileSystem.s3a.impl", "org.apache.hadoop.fs.s3a.S3A"); + if (EmptyKit.isNotEmpty(config.getS3Properties())) { + config.getS3Properties().forEach(v -> conf.set(v.get("propKey"), v.get("propValue"))); + } + } + return conf; + } + + /** + * Test warehouse accessibility + * + * @return true if warehouse is accessible + */ + public boolean testWarehouseAccess() { + try { + // Try to list databases + catalog.listDatabases(); + return true; + } catch (Exception e) { + return false; + } + } + + /** + * Test write permission + * + * @return true if write permission is available + */ + public boolean testWritePermission() { + try { + // Try to create a test database if it doesn't exist + String testDb = config.getDatabase(); + try { + catalog.getDatabase(testDb); + // Database exists + } catch (Catalog.DatabaseNotExistException e) { + // Database does not exist, create it + catalog.createDatabase(testDb, true); + } + return true; + } catch (Exception e) { + return false; + } + } + + /** + * Get table count in the database + * + * @return number of tables + * @throws Exception if query fails + */ + public int getTableCount() throws Exception { + String database = config.getDatabase(); + + // Check if database exists + try { + catalog.getDatabase(database); + } catch (Catalog.DatabaseNotExistException e) { + // Database does not exist + return 0; + } + + // Get all tables in database + List tables = catalog.listTables(database); + return tables != null ? tables.size() : 0; + } + + /** + * Discover tables in Paimon + * + * @param tableNames list of table names to discover (null for all) + * @return list of discovered tables + * @throws Exception if discovery fails + */ + public List discoverTables(List tableNames) throws Exception { + List tables = new ArrayList<>(); + String database = config.getDatabase(); + + // Ensure database exists + try { + catalog.getDatabase(database); + } catch (Catalog.DatabaseNotExistException e) { + // Database does not exist + return tables; + } + + // Get all tables in database + List allTables = catalog.listTables(database); + + // Filter tables if specific names provided + if (tableNames != null && !tableNames.isEmpty()) { + allTables.retainAll(tableNames); + } + + // Load schema for each table + for (String tableName : allTables) { + try { + Identifier identifier = Identifier.create(database, tableName); + Table paimonTable = catalog.getTable(identifier); + + TapTable tapTable = convertToTapTable(tableName, paimonTable); + tables.add(tapTable); + } catch (Exception e) { + // Skip tables that cannot be loaded + } + } + + return tables; + } + + /** + * Convert Paimon table to TapTable + * + * @param tableName table name + * @param paimonTable Paimon table + * @return TapTable + */ + private TapTable convertToTapTable(String tableName, Table paimonTable) { + TapTable tapTable = new TapTable(tableName); + + // Convert fields + List fields = paimonTable.rowType().getFields(); + List primaryKeys = paimonTable.primaryKeys(); + for (DataField field : fields) { + TapField tapField = new TapField(field.name(), field.type().asSQLString().replace("NOT NULL", "").trim()); + tapField.setNullable(field.type().isNullable()); + if (primaryKeys.contains(field.name())) { + tapField.setPrimaryKey(true); + tapField.setPrimaryKeyPos(primaryKeys.indexOf(field.name()) + 1); + } + tapTable.add(tapField); + } + + // Set primary keys + if (primaryKeys != null && !primaryKeys.isEmpty()) { + TapIndex tapIndex = new TapIndex().name("PRIMARY").unique(true).coreUnique(true).primary(true); + tapIndex.setIndexFields(primaryKeys.stream().map(key -> new TapIndexField().name(key).fieldAsc(true)).collect(Collectors.toList())); + tapTable.add(tapIndex); + } + + return tapTable; + } + + /** + * Convert Paimon data type to Tapdata type name + * + * @param dataType Paimon data type + * @return Tapdata type name + */ + private String convertDataType(DataType dataType) { + String typeString = dataType.toString().toUpperCase(); + + if (dataType.equals(DataTypes.BOOLEAN())) { + return "BOOLEAN"; + } else if (dataType.equals(DataTypes.TINYINT())) { + return "TINYINT"; + } else if (dataType.equals(DataTypes.SMALLINT())) { + return "SMALLINT"; + } else if (dataType.equals(DataTypes.INT())) { + return "INT"; + } else if (dataType.equals(DataTypes.BIGINT())) { + return "BIGINT"; + } else if (dataType.equals(DataTypes.FLOAT())) { + return "FLOAT"; + } else if (dataType.equals(DataTypes.DOUBLE())) { + return "DOUBLE"; + } else if (dataType.equals(DataTypes.STRING())) { + return "STRING"; + } else if (dataType.equals(DataTypes.DATE())) { + return "DATE"; + } else if (dataType.equals(DataTypes.TIMESTAMP())) { + return "TIMESTAMP"; + } else if (typeString.startsWith("ARRAY")) { + return "ARRAY"; + } else if (typeString.startsWith("MAP")) { + return "MAP"; + } else if (typeString.startsWith("ROW")) { + return "ROW"; + } else { + return "STRING"; // Default to STRING for unknown types + } + } + + /** + * Create table in Paimon + * + * @param tapTable table definition + * @return true if created, false if already exists + * @throws Exception if creation fails + */ + public boolean createTable(TapTable tapTable) throws Exception { + String database = config.getDatabase(); + String tableName = tapTable.getName(); + + // Ensure database exists + try { + catalog.getDatabase(database); + } catch (Catalog.DatabaseNotExistException e) { + // Database does not exist, create it + catalog.createDatabase(database, true); + } + + Identifier identifier = Identifier.create(database, tableName); + + // Check if table already exists + try { + catalog.getTable(identifier); + // Table exists, check if bucket mode matches + boolean existingIsDynamic = isTableDynamicBucket(identifier); + boolean configIsDynamic = config.isDynamicBucketMode(); + + if (existingIsDynamic != configIsDynamic) { + // Bucket mode mismatch, log warning and continue with existing table + String existingMode = existingIsDynamic ? "dynamic" : "fixed"; + String configMode = configIsDynamic ? "dynamic" : "fixed"; + log.warn("Table {} already exists with {} bucket mode, but config specifies {} bucket mode. " + + "Cannot switch bucket mode for existing table. Using existing table configuration.", + tableName, existingMode, configMode); + } + // Table exists, no need to recreate + return false; + } catch (Catalog.TableNotExistException e) { + // Table does not exist, continue to create + } + + // Build schema + Schema.Builder schemaBuilder = Schema.newBuilder(); + + // Set primary keys + Collection primaryKeys = tapTable.primaryKeys(true); + if (primaryKeys != null && !primaryKeys.isEmpty()) { + if (config.getHashKey(tableName) && primaryKeys.size() > 5) { + schemaBuilder.primaryKey(Collections.singletonList(HASH_KEY)); + } else { + schemaBuilder.primaryKey(new ArrayList<>(primaryKeys)); + } + } + + // Add fields + Map fields = tapTable.getNameFieldMap(); + if (fields != null) { + if (config.getHashKey(tableName) && EmptyKit.isNotEmpty(primaryKeys) && primaryKeys.size() > 5) { + schemaBuilder.column(HASH_KEY, DataTypes.VARCHAR(32)); + } + for (Map.Entry entry : fields.entrySet()) { + String fieldName = entry.getKey(); + TapField tapField = entry.getValue(); + DataType dataType = convertToPaimonDataType(tapField); + schemaBuilder.column(fieldName, dataType); + } + } + + if (EmptyKit.isNotEmpty(config.getPartitionKey(tableName))) { + schemaBuilder.partitionKeys(config.getPartitionKey(tableName)); + } + + // Set bucket configuration based on bucket mode + if (config.isDynamicBucketMode()) { + // Dynamic bucket mode: set bucket to -1 + // This mode provides better flexibility + schemaBuilder.option("bucket", "-1"); + } else { + // Fixed bucket mode: set specific bucket count + Integer bucketCount = config.getBucketCount(tableName); + if (bucketCount == null || bucketCount <= 0) { + bucketCount = 4; // Default to 4 buckets if not configured + } + schemaBuilder.option("bucket", String.valueOf(bucketCount)); + } + if (EmptyKit.isNotBlank(config.getFileFormat(tableName))) { + schemaBuilder.option("file.format", config.getFileFormat(tableName)); + } + if (EmptyKit.isNotBlank(config.getCompression(tableName))) { + schemaBuilder.option("compression", config.getCompression(tableName)); + } + + // ===== Performance Optimization Options ===== + + // 1. Write buffer size - controls memory buffer for writes + // Larger buffer = better performance but more memory usage + if (config.getWriteBufferSize() != null && config.getWriteBufferSize() > 0) { + schemaBuilder.option("write-buffer-size", config.getWriteBufferSize() + "mb"); + } + + if (Boolean.TRUE.equals(config.getDiskOverflowWrite())) { + schemaBuilder.option("write-buffer-spillable", "true"); + schemaBuilder.option("write-buffer-spill.max-disk-size", config.getDiskMaxSize() + "gb"); + } + + // 2. Target file size - Paimon will try to create files of this size + // Larger files = fewer files but slower compaction + if (config.getTargetFileSize(tableName) != null && config.getTargetFileSize(tableName) > 0) { + schemaBuilder.option("target-file-size", config.getTargetFileSize(tableName) + "mb"); + } + + // 3. Compaction settings + if (config.getEnableAutoCompaction(tableName) != null) { + if (config.getEnableAutoCompaction(tableName)) { + // Enable full compaction for better query performance + schemaBuilder.option("compaction.optimization-interval", config.getCompactionIntervalMinutes(tableName) + "min"); + + // Set compaction strategy + schemaBuilder.option("changelog-producer", "input"); + + // Compact small files more aggressively + schemaBuilder.option("num-sorted-run.compaction-trigger", "30"); + schemaBuilder.option("num-sorted-run.stop-trigger", "2147483647"); + } else { + // Disable auto compaction + schemaBuilder.option("write-only", "true"); + } + } + + // 4. Snapshot settings for better performance + // Keep more snapshots in memory for faster access + schemaBuilder.option("snapshot.num-retained.min", "2"); + schemaBuilder.option("snapshot.num-retained.max", "5"); + schemaBuilder.option("snapshot.time-retained", "30min"); + + // 5. Commit settings + // Force compact on commit for better read performance + schemaBuilder.option("commit.force-compact", "false"); + + // 6. Scan settings for better read performance + schemaBuilder.option("scan.plan-sort-partition", "true"); + + // 7. Changelog settings for CDC scenarios + schemaBuilder.option("changelog-producer.lookup-wait", "false"); // Don't wait for lookup + + // 8. Memory settings + schemaBuilder.option("sink.parallelism", String.valueOf(config.getWriteThreads())); + + if (EmptyKit.isNotEmpty(config.getTableProperties(tableName))) { + config.getTableProperties(tableName).forEach(v -> { + if (StringUtils.isEmpty(v.get("propKey")) + || StringUtils.isEmpty(v.get("propValue")) + ) { + log.warn("tapdata paimon config error", "key or value exists null in tableProperties"); + } else { + schemaBuilder.option(v.get("propKey"), v.get("propValue")); + } + }); + } + // Create table + catalog.createTable(identifier, schemaBuilder.build(), false); + + // log schema builder variables + Gson gson = new GsonBuilder().setPrettyPrinting().create(); + log.info("Created table {} with schema: {}", identifier.getFullName(), gson.toJson(schemaBuilder.build())); + + return true; + } + + /** + * Convert TapField to Paimon DataType + * + * @param tapField TapField + * @return Paimon DataType + */ + private DataType convertToPaimonDataType(TapField tapField) { + String dataType = tapField.getDataType(); + if (dataType == null) { + return DataTypes.STRING(); + } + + dataType = dataType.toUpperCase(); + String pureDataType = StringKit.removeParentheses(dataType); + switch (pureDataType) { + case "BOOLEAN": + return DataTypes.BOOLEAN(); + case "TINYINT": + return DataTypes.TINYINT(); + case "SMALLINT": + return DataTypes.SMALLINT(); + case "INTEGER": + return DataTypes.INT(); + case "BIGINT": + return DataTypes.BIGINT(); + case "FLOAT": + return DataTypes.FLOAT(); + case "DOUBLE": + return DataTypes.DOUBLE(); + case "DECIMAL": + return DataTypes.DECIMAL(getFieldPrecisionAndScale(dataType).getLeft(), getFieldPrecisionAndScale(dataType).getRight()); + case "DATE": + return DataTypes.DATE(); + case "TIME": + return DataTypes.TIME(getFieldFraction(dataType)); + case "TIMESTAMP": + return DataTypes.TIMESTAMP(getFieldFraction(dataType)); + case "TIMESTAMP WITH LOCAL TIME ZONE": + return DataTypes.TIMESTAMP_WITH_LOCAL_TIME_ZONE(getFieldFraction(dataType)); + case "BINARY": + return DataTypes.BINARY(getFieldLength(dataType)); + case "VARBINARY": + return DataTypes.VARBINARY(getFieldLength(dataType)); + case "BYTES": + return DataTypes.BYTES(); + case "CHAR": + return DataTypes.CHAR(getFieldLength(dataType)); + case "VARCHAR": + return DataTypes.VARCHAR(getFieldLength(dataType)); + case "ARRAY": + return DataTypes.ARRAY(DataTypes.STRING()); + case "MAP": + return DataTypes.MAP(DataTypes.STRING(), DataTypes.STRING()); + case "ROW": + return DataTypes.ROW(DataTypes.STRING()); + case "MULTISET": + return DataTypes.MULTISET(DataTypes.STRING()); + case "VARIANT": + return DataTypes.VARIANT(); + default: + return DataTypes.STRING(); + } + } + + public Integer getFieldLength(String dataType) { + //提取括号里的值 + Pattern pattern = Pattern.compile("\\(([^)]+)\\)"); + Matcher matcher = pattern.matcher(dataType); + if (matcher.find()) { + long length = Long.parseLong(matcher.group(1)); + if (length > Integer.MAX_VALUE) { + return Integer.MAX_VALUE; + } else { + return (int) length; + } + } + return Integer.MAX_VALUE; + } + + public Integer getFieldFraction(String dataType) { + //提取括号里的值 + Pattern pattern = Pattern.compile("\\(([^)]+)\\)"); + Matcher matcher = pattern.matcher(dataType); + if (matcher.find()) { + return Integer.parseInt(matcher.group(1)); + } + return 6; + } + + public Pair getFieldPrecisionAndScale(String dataType) { + //提取括号里的值,逗号的前一个和后一个 + Pattern pattern = Pattern.compile("\\(([^,]+),([^)]+)\\)"); + Matcher matcher = pattern.matcher(dataType); + if (matcher.find()) { + return Pair.of(Integer.parseInt(matcher.group(1).trim()), Integer.parseInt(matcher.group(2).trim())); + } + return Pair.of(38, 10); + } + + /** + * Drop table from Paimon + * + * @param tableName table name + * @throws Exception if drop fails + */ + public void dropTable(String tableName) throws Exception { + String database = config.getDatabase(); + Identifier identifier = Identifier.create(database, tableName); + + try { + catalog.getTable(identifier); + // Table exists, proceed to drop + catalog.dropTable(identifier, true); + } catch (Catalog.TableNotExistException e) { + // Table does not exist, do nothing + } + } + + /** + * Clear all data from table + * + * @param tableName table name + * @throws Exception if clear fails + */ + public void clearTable(String tableName) throws Exception { + String database = config.getDatabase(); + Identifier identifier = Identifier.create(database, tableName); + + // Get table, if not exists, return + Table table; + try { + table = catalog.getTable(identifier); + } catch (Catalog.TableNotExistException e) { + // Table does not exist, nothing to clear + return; + } + + // Drop and recreate table to clear data + + // Rebuild schema from table + Schema.Builder schemaBuilder = Schema.newBuilder(); + + // Add fields from rowType + List fields = table.rowType().getFields(); + for (DataField field : fields) { + schemaBuilder.column(field.name(), field.type()); + } + + // Add primary keys + List primaryKeys = table.primaryKeys(); + if (primaryKeys != null && !primaryKeys.isEmpty()) { + schemaBuilder.primaryKey(primaryKeys); + } + + // Preserve all table options (including bucket configuration) + // But exclude options that cannot be used when creating table with FileSystemCatalog + Map options = table.options(); + if (options != null && !options.isEmpty()) { + for (Map.Entry entry : options.entrySet()) { + String key = entry.getKey(); + // Skip 'path' option as FileSystemCatalog doesn't support custom table path + if ("path".equals(key)) { + continue; + } + schemaBuilder.option(key, entry.getValue()); + } + } + + Schema schema = schemaBuilder.build(); + + catalog.dropTable(identifier, true); + catalog.createTable(identifier, schema, false); + } + + /** + * Create index on table + * Note: Paimon doesn't support traditional indexes, but we can log the request + * + * @param table table definition + * @param indexList list of indexes to create + */ + public void createIndex(TapTable table, List indexList) { + // Paimon doesn't support traditional indexes + // Primary keys are already handled during table creation + // This method is a no-op but required by the interface + } + + /** + * Write records to Paimon table using stream write + * + * @param recordEvents list of record events + * @param table target table + * @return write result + * @throws Exception if write fails + */ + public WriteListResult writeRecords(List recordEvents, + TapTable table, + TapConnectorContext connectorContext) throws Exception { + if (!computeHashKey.containsKey(table.getId())) { + computeHashKey.put(table.getId(), config.getHashKey(table.getId()) && EmptyKit.isNotEmpty(table.primaryKeys(true)) && table.primaryKeys(true).size() > 5); + primaryKeyMap.put(table.getId(), table.primaryKeys(true)); + } + return writeRecordsWithStreamWriteInternal(recordEvents, table, connectorContext); + } + + /** + * Check if table is using dynamic bucket mode + * + * @param identifier table identifier + * @return true if dynamic bucket mode, false if fixed bucket mode + * @throws Exception if check fails + */ + private boolean isTableDynamicBucket(Identifier identifier) throws Exception { + Table paimonTable = catalog.getTable(identifier); + // Get bucket option from table options + String bucketOption = paimonTable.options().get("bucket"); + + // If bucket is -1 or not set, it's dynamic bucket mode + if (bucketOption == null) { + return true; // Default is dynamic + } + + try { + int bucket = Integer.parseInt(bucketOption); + return bucket == -1; + } catch (NumberFormatException e) { + return true; // If parse fails, assume dynamic + } + } + + public void afterInitialSync(TapConnectorContext connectorContext, TapTable tapTable) throws Exception { + String tableName = tapTable.getId(); + String database = config.getDatabase(); + Identifier identifier = Identifier.create(database, tableName); + StreamTableWrite writer = getOrCreateStreamWriter(tableName, identifier); + StreamTableCommit commit = getOrCreateStreamCommit(tableName, identifier); + Object lock = commitLocks.computeIfAbsent(tapTable.getId(), k -> new Object()); + synchronized (lock) { + // Prepare commit with commitIdentifier + // Use atomic counter to generate unique, incrementing commit identifier + long commitIdentifier = commitIdentifierGenerator.incrementAndGet(); + List messages = writer.prepareCommit(false, commitIdentifier); + + // Commit the batch + commit.commit(commitIdentifier, messages); + } +// initAsyncCommit(); + } + + /** + * Internal implementation of stream write with retry support + * + * @param recordEvents list of record events + * @param table target table + * @return write result + * @throws Exception if write fails + */ + private WriteListResult writeRecordsWithStreamWriteInternal(List recordEvents, + TapTable table, + TapConnectorContext connectorContext) throws Exception { + String database = config.getDatabase(); + String tableName = table.getName(); + String tableKey = database + "." + tableName; + + // Use loop instead of recursion for retry + int maxRetries = 3; + int retryCount = 0; + + while (true) { + WriteListResult result = new WriteListResult<>(); + Identifier identifier = Identifier.create(database, tableName); + + try { + // Get or create cached writer and commit ghv + StreamTableWrite writer = getOrCreateStreamWriter(tableKey, identifier); + StreamTableCommit commit = getOrCreateStreamCommit(tableKey, identifier); + Object lock = commitLocks.computeIfAbsent(tableKey, k -> new Object()); + synchronized (lock) { + for (TapRecordEvent event : recordEvents) { + if (!firstOffsetByTable.containsKey(tableName)) { + TapCallbackOffset tapOffset = new TapCallbackOffset(); + // 从 TapRecordEvent.info 中提取 offset 信息 + // 这些信息由 HazelcastTargetPdkBaseNode.handleTapdataEventDML 方法添加 + Object batchOffset = event.getInfo("batchOffset"); + Object streamOffset = event.getInfo("streamOffset"); + Object syncStage = event.getInfo("syncStage"); + Object sourceTime = event.getInfo("sourceTime"); + Object nodeIds = event.getInfo("nodeIds"); + + // 填充 TapOffset + tapOffset.batchOffset(batchOffset) + .streamOffset(streamOffset) + .tableId(event.getTableId()) + .syncStage(syncStage != null ? syncStage.toString() : null) + .sourceTime(sourceTime instanceof Long ? (Long) sourceTime : null) + .eventTime(event.getReferenceTime()) + .nodeIds(nodeIds); + if (tapOffset.hasValidOffset()) { + firstOffsetByTable.put(tableName, tapOffset); + } + } + if (event instanceof TapInsertRecordEvent) { + handleStreamInsert((TapInsertRecordEvent) event, writer, table); + result.incrementInserted(1); + } else if (event instanceof TapUpdateRecordEvent) { + handleStreamUpdate((TapUpdateRecordEvent) event, writer, table); + result.incrementModified(1); + } else if (event instanceof TapDeleteRecordEvent) { + handleStreamDelete((TapDeleteRecordEvent) event, writer, table); + result.incrementRemove(1); + } + } + + // Update accumulated record count + AtomicInteger recordCount = accumulatedRecordCount.computeIfAbsent(tableKey, k -> new AtomicInteger(0)); + int currentCount = recordCount.addAndGet(recordEvents.size()); + + // Initialize last commit time if not exists + AtomicLong lastCommit = lastCommitTime.computeIfAbsent(tableKey, k -> new AtomicLong(System.currentTimeMillis())); + + // Determine if we should commit based on: + // 1. Accumulated record count exceeds threshold + // 2. Time since last commit exceeds interval + // 3. Batch accumulation is disabled (size = 0) + boolean shouldCommit = false; + Integer batchSize = config.getBatchAccumulationSize(); + Integer commitInterval = config.getCommitIntervalMs(); + + if (batchSize == null || batchSize <= 0) { + // Batch accumulation disabled, commit immediately + shouldCommit = true; + } else if (currentCount >= batchSize) { + // Record count threshold reached + shouldCommit = true; + } else if (commitInterval != null && commitInterval > 0) { + // Check time-based commit + long timeSinceLastCommit = System.currentTimeMillis() - lastCommit.get(); + if (timeSinceLastCommit >= commitInterval) { + shouldCommit = true; + } + } + + // Perform commit if needed + if (shouldCommit) { + // init sync stage just commit once, for batch commit + spill disk + if (!"CDC".equals(recordEvents.get(0).getInfo().get(TapRecordEvent.INFO_KEY_SYNC_STAGE))) { + return result; + } + // Double-check if we still need to commit (another thread might have committed) + int finalCount = recordCount.get(); + if (finalCount > 0) { + long commitStartTime = System.currentTimeMillis(); + // Prepare commit with commitIdentifier + // Use atomic counter to generate unique, incrementing commit identifier + long commitIdentifier = commitIdentifierGenerator.incrementAndGet(); + List messages = writer.prepareCommit(false, commitIdentifier); + + // Commit the batch + commit.commit(commitIdentifier, messages); + commitCallback(tableName); + // Reset counters after successful commit + recordCount.set(0); + lastCommit.set(System.currentTimeMillis()); + + long commitDuration = System.currentTimeMillis() - commitStartTime; + connectorContext.getLog().debug("Committed {} accumulated records for table {} in {} ms", + finalCount, tableKey, commitDuration); + } + } + } + + // StreamTableWrite can be reused, so we don't clean up here + return result; + + } catch (Exception e) { + if (retryCount < maxRetries) { + if (isThreadGroupDestroyedError(e)) { + connectorContext.getLog().warn("ThreadGroup destroyed in stream write, retrying... (attempt {}/{})", retryCount + 1, maxRetries, e); + } else if (isPaimonConflict(e)) { + connectorContext.getLog().warn("Commit conflict detected, retrying... (attempt {}/{})", retryCount + 1, maxRetries, e); + } else { + connectorContext.getLog().warn("Failed to write records to table {}, error message: {}, retrying... (attempt {}/{})", tableName, e.getMessage(), retryCount + 1, maxRetries, e); + } + retryCount++; +// reinitCatalog(); + CommonUtils.ignoreAnyError(() -> TimeUnit.SECONDS.sleep(1L), TAG); + continue; + } + + throw new TapPdkRetryableEx("paimon", ErrorKit.getLastCause(e)); + } + } + } + + private boolean isPaimonConflict(Throwable e) { + Throwable t = e; + while (t != null) { + String msg = t.getMessage(); + if (msg != null) { + if (msg.contains("File deletion conflicts detected") + || msg.contains("Trying to delete file") + || msg.contains("noConflictsOrFail") + || msg.contains("assertNoDelete")) { + return true; + } + } + if (t instanceof IllegalStateException + && msg != null + && msg.contains("not previously added")) { + return true; + } + t = t.getCause(); + } + return false; + } + + /** + * Reinitialize the Paimon catalog. + * This is used to recover from ThreadGroup destroyed errors caused by classloader unloading. + * This method completely rebuilds all resources including catalog and all cached writers/commits. + * + * @throws Exception if reinitialization fails + */ + private synchronized void reinitCatalog() throws Exception { + // Clean up all resources + cleanupAllResources(); + + // Reinitialize catalog + init(); + } + + /** + * Clean up all cached resources including writers, commits, and catalog. + * This method ensures proper cleanup with delays to allow internal threads to terminate. + */ + private void cleanupAllResources() { + // Shutdown async commit executor first + if (asyncCommitExecutor != null) { + asyncCommitExecutor.shutdown(); + try { + if (!asyncCommitExecutor.awaitTermination(5, TimeUnit.SECONDS)) { + asyncCommitExecutor.shutdownNow(); + } + } catch (InterruptedException e) { + asyncCommitExecutor.shutdownNow(); + Thread.currentThread().interrupt(); + } + asyncCommitExecutor = null; + } + + // Close all cached writers and commits first + for (String tableKey : new ArrayList<>(streamWriterCache.keySet())) { + cleanupTableResources(tableKey); + } + + // Clear all caches + streamWriterCache.clear(); + streamCommitCache.clear(); + + // Clear batch accumulation tracking + accumulatedRecordCount.clear(); + lastCommitTime.clear(); + commitLocks.clear(); + + // Clear Paimon field cache + paimonFieldCache.clear(); + fieldIndexCache.clear(); + + // Close old catalog if exists + if (catalog != null) { + try { + if (catalog instanceof CachingCatalog) { + CachingCatalog cachingCatalog = (CachingCatalog) catalog; + Catalog wrapped = cachingCatalog.wrapped(); + if (wrapped instanceof FileSystemCatalog) { + FileSystemCatalog fileSystemCatalog = (FileSystemCatalog) wrapped; + FileIO fileIO = null; + try { + fileIO = fileSystemCatalog.fileIO(); + } catch (Throwable ignore) { + // Ignore fileIO lookup errors + } + + // Best-effort close: proactively close FileSystem instances cached by HadoopFileIO + closeHadoopFileIOCachedFileSystems(fileIO); + closeQuietly(fileIO); + } + } + + catalog.close(); + } catch (Throwable e) { + // Ignore close errors + } finally { + catalog = null; + } + } + + // Wait a bit to ensure all internal threads are cleaned up + // This is critical to avoid ThreadGroup destroyed errors + try { + Thread.sleep(500); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + + private void closeQuietly(Closeable closeable) { + if (closeable == null) { + return; + } + try { + closeable.close(); + } catch (Exception ignore) { + // Ignore close errors + } + } + + /** + * Best-effort close for cached Hadoop FileSystem instances inside Paimon HadoopFileIO. + *

+ * HadoopFileIO may cache FileSystem instances (e.g., in a field named "fsMap"). Even if + * Hadoop global FileSystem cache is disabled, this internal cache can still keep an S3A + * FileSystem whose thread factory captured a Task ThreadGroup that will be destroyed later. + */ + private void closeHadoopFileIOCachedFileSystems(Object fileIO) { + if (!(fileIO instanceof HadoopFileIO)) { + return; + } + + try { + Field fsMapField = fileIO.getClass().getDeclaredField("fsMap"); + fsMapField.setAccessible(true); + Object fsMapObject = fsMapField.get(fileIO); + if (!(fsMapObject instanceof Map)) { + return; + } + + Map fsMap = (Map) fsMapObject; + if (fsMap.isEmpty()) { + return; + } + + // Copy values first to avoid ConcurrentModificationException in case close triggers internal updates. + List fileSystems = new ArrayList<>(fsMap.values()); + for (Object fs : fileSystems) { + if (fs instanceof FileSystem) { + try { + ((FileSystem) fs).close(); + } catch (Exception ignore) { + // Ignore close errors + } + } + } + + try { + fsMap.clear(); + } catch (Exception ignore) { + // Ignore clear errors + } + } catch (NoSuchFieldException ignore) { + // HadoopFileIO implementation differs; ignore. + } catch (Throwable ignore) { + // Best-effort only + } + } + + /** + * Check if the exception is caused by ThreadGroup being destroyed. + * This typically happens when the classloader that created Paimon's thread factory + * has been unloaded, causing the captured ThreadGroup to be destroyed. + * + * @param e the exception to check + * @return true if it's a ThreadGroup destroyed error + */ + private boolean isThreadGroupDestroyedError(Throwable e) { + Throwable cause = e; + while (cause != null) { + Throwable illegalThreadStateException = CommonUtils.matchThrowable(e, IllegalThreadStateException.class); + if (illegalThreadStateException != null) { + return true; + } + cause = cause.getCause(); + } + return false; + } + + + /** + * Create a new stream writer for table + * + * @param identifier table identifier + * @return stream table writer + * @throws Exception if creation fails + */ + private StreamTableWrite createStreamWriter(Identifier identifier) throws Exception { + Table table = catalog.getTable(identifier); + StreamWriteBuilder writeBuilder = table.newStreamWriteBuilder(); + String tmpDirs = config.getDiskTmpDir(table.name()); + if (StringUtils.isEmpty(tmpDirs)) { + return new ManagedIOStreamTableWrite(writeBuilder.newWrite(), null); + } else { + IOManager ioManager = IOManager.create(splitPaths(tmpDirs)); + StreamTableWrite streamTableWrite = (StreamTableWrite) writeBuilder.newWrite().withIOManager(ioManager); + return new ManagedIOStreamTableWrite(streamTableWrite, ioManager); + } + } + + /** + * Create a new stream commit for table + * + * @param identifier table identifier + * @return stream table commit + * @throws Exception if creation fails + */ + private StreamTableCommit createStreamCommit(Identifier identifier) throws Exception { + Table table = catalog.getTable(identifier); + StreamWriteBuilder writeBuilder = table.newStreamWriteBuilder(); + return writeBuilder.newCommit(); + } + + /** + * Get or create cached stream writer for table + * ConcurrentHashMap.computeIfAbsent() ensures thread-safe creation without additional locking + * + * @param tableKey table key (database.tableName) + * @param identifier table identifier + * @return stream table writer + * @throws Exception if creation fails + */ + private StreamTableWrite getOrCreateStreamWriter(String tableKey, Identifier identifier) throws Exception { + return streamWriterCache.computeIfAbsent(tableKey, k -> { + try { + return createStreamWriter(identifier); + } catch (Exception e) { + throw new RuntimeException("Failed to create stream writer for table " + tableKey, e); + } + }); + } + + /** + * Get or create cached stream commit for table + * ConcurrentHashMap.computeIfAbsent() ensures thread-safe creation without additional locking + * + * @param tableKey table key (database.tableName) + * @param identifier table identifier + * @return stream table commit + * @throws Exception if creation fails + */ + private StreamTableCommit getOrCreateStreamCommit(String tableKey, Identifier identifier) throws Exception { + return streamCommitCache.computeIfAbsent(tableKey, k -> { + try { + return createStreamCommit(identifier); + } catch (Exception e) { + throw new RuntimeException("Failed to create stream commit for table " + tableKey, e); + } + }); + } + + + /** + * Clean up all cached resources for a specific table + * + * @param tableKey table key (database.tableName) + */ + private void cleanupTableResources(String tableKey) { + // Close and remove stream commit first (before writer) + StreamTableCommit streamCommit = streamCommitCache.remove(tableKey); + if (streamCommit != null) { + try { + streamCommit.close(); + } catch (Exception e) { + // Ignore close errors + } + } + + // Close and remove stream writer + StreamTableWrite streamWriter = streamWriterCache.remove(tableKey); + if (streamWriter != null) { + try { + streamWriter.close(); + } catch (Exception e) { + // Ignore close errors, especially IllegalThreadStateException + // which can occur if ThreadGroup is already destroyed + } + // Force wait a bit to ensure internal threads are cleaned up + try { + Thread.sleep(100); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + } + + + /** + * Handle insert event with stream writer + * + * @param event insert event + * @param writer stream writer + * @param table table definition + * @throws Exception if insert fails + */ + private void handleStreamInsert(TapInsertRecordEvent event, StreamTableWrite writer, TapTable table) throws Exception { + Map after = event.getAfter(); + String database = config.getDatabase(); + Identifier identifier = Identifier.create(database, table.getName()); + GenericRow row = convertToGenericRow(after, table, identifier); + if (config.getBucketMode(table.getName()).equals("fixed")) { + writer.write(row); + } else { + int bucket = selectBucketForDynamic(row, table); + writer.write(row, bucket); + } + } + + /** + * Handle update event with stream writer + * Uses RowKind.UPDATE_BEFORE (U-) and RowKind.UPDATE_AFTER (U+) to implement update + * + * @param event update event + * @param writer stream writer + * @param table table definition + * @throws Exception if update fails + */ + private void handleStreamUpdate(TapUpdateRecordEvent event, StreamTableWrite writer, TapTable table) throws Exception { + String database = config.getDatabase(); + Identifier identifier = Identifier.create(database, table.getName()); + + Map before = event.getBefore(); + Map after = event.getAfter(); + + // Convert before and after data to GenericRow first to avoid duplicate conversion + GenericRow beforeRow = null; + if (before != null && !before.isEmpty()) { + beforeRow = convertToGenericRow(before, table, identifier); + } + GenericRow afterRow = convertToGenericRow(after, table, identifier); + + // Check if primary key update detection is enabled + Boolean enablePkUpdate = config.getEnablePrimaryKeyUpdate(table.getName()); + if (enablePkUpdate != null && enablePkUpdate) { + // Validate that before data is available when primary key update detection is enabled + if (beforeRow == null) { + throw new RuntimeException("Primary key update detection is enabled but before data is not available. " + + "Please ensure the source database can provide before-update data or disable this feature."); + } + + // Check if primary key has changed + if (isPrimaryKeyChanged(beforeRow, afterRow, table)) { + // Convert update to delete + insert + // First, write DELETE using before data + beforeRow.setRowKind(RowKind.DELETE); + if (config.getBucketMode(table.getName()).equals("fixed")) { + writer.write(beforeRow); + } else { + int bucket = selectBucketForDynamic(beforeRow, table); + writer.write(beforeRow, bucket); + } + + // Then, write INSERT using after data + afterRow.setRowKind(RowKind.INSERT); + if (config.getBucketMode(table.getName()).equals("fixed")) { + writer.write(afterRow); + } else { + int bucket = selectBucketForDynamic(afterRow, table); + writer.write(afterRow, bucket); + } + return; + } + } + + // Normal update logic: Write U- (UPDATE_BEFORE) if before data exists + if (beforeRow != null) { + beforeRow.setRowKind(RowKind.UPDATE_BEFORE); + if (config.getBucketMode(table.getName()).equals("fixed")) { + writer.write(beforeRow); + } else { + int bucket = selectBucketForDynamic(beforeRow, table); + writer.write(beforeRow, bucket); + } + } + + // Write U+ (UPDATE_AFTER) using after data + afterRow.setRowKind(RowKind.UPDATE_AFTER); + if (config.getBucketMode(table.getName()).equals("fixed")) { + writer.write(afterRow); + } else { + int bucket = selectBucketForDynamic(afterRow, table); + writer.write(afterRow, bucket); + } + } + + /** + * Check if primary key values have changed between before and after GenericRow + * Uses converted GenericRow values to ensure consistent comparison + * + * @param beforeRow before GenericRow (must not be null) + * @param afterRow after GenericRow (must not be null) + * @param table table definition + * @return true if primary key has changed, false otherwise + */ + private boolean isPrimaryKeyChanged(GenericRow beforeRow, GenericRow afterRow, TapTable table) { + // Get primary key fields + Collection primaryKeys = table.primaryKeys(true); + if (primaryKeys == null || primaryKeys.isEmpty()) { + // No primary key defined, no change detection needed + return false; + } + + // Get field index mapping + Map fields = table.getNameFieldMap(); + String cacheKey = table.getId(); + Map indexMap = getFieldIndexMap(cacheKey, fields); + + // Build concatenated string of primary key values from before and after + // Use same order for comparison + List pkList = new ArrayList<>(primaryKeys); + StringBuilder beforePkStr = new StringBuilder(); + StringBuilder afterPkStr = new StringBuilder(); + + for (String pkField : pkList) { + Integer fieldIndex = indexMap.get(pkField); + if (fieldIndex == null || fieldIndex < 0 || fieldIndex >= beforeRow.getFieldCount()) { + continue; + } + + Object beforeValue = beforeRow.getField(fieldIndex); + Object afterValue = afterRow.getField(fieldIndex); + + // Convert to string for comparison + String beforeStr = beforeValue == null ? "NULL" : String.valueOf(beforeValue); + String afterStr = afterValue == null ? "NULL" : String.valueOf(afterValue); + + beforePkStr.append(beforeStr).append("|"); + afterPkStr.append(afterStr).append("|"); + } + + // Compare concatenated primary key strings + return !beforePkStr.toString().contentEquals(afterPkStr); + } + + /** + * Handle delete event with stream writer + * + * @param event delete event + * @param writer stream writer + * @param table table definition + * @throws Exception if delete fails + */ + private void handleStreamDelete(TapDeleteRecordEvent event, StreamTableWrite writer, TapTable table) throws Exception { + Map before = event.getBefore(); + String database = config.getDatabase(); + Identifier identifier = Identifier.create(database, table.getName()); + GenericRow row = convertToGenericRow(before, table, identifier); + // Set row kind to DELETE + row.setRowKind(RowKind.DELETE); + if (config.getBucketMode(table.getName()).equals("fixed")) { + writer.write(row); + } else { + int bucket = selectBucketForDynamic(row, table); + writer.write(row, bucket); + } + } + + /** + * Select deterministic bucket for dynamic-bucket tables. + * Use primary keys if present; otherwise hash all fields (sorted by name). + *

+ * Note: This method uses the converted GenericRow values to ensure consistent + * bucket selection across insert/update/delete operations, especially for + * Date/DateTime types that are converted to int/long values. + * + * @param row converted GenericRow with Paimon-compatible values + * @param table table definition + * @return bucket number + */ + private int selectBucketForDynamic(GenericRow row, TapTable table) { + int hint = (config.getBucketCount(table.getName()) != null && config.getBucketCount(table.getName()) > 0) ? config.getBucketCount(table.getName()) : 4; + int hash = 0; + Collection pks = table.primaryKeys(true); + Map fields = table.getNameFieldMap(); + + // Get or build field index mapping from cache + String cacheKey = table.getId(); + Map indexMap = getFieldIndexMap(cacheKey, fields); + + if (pks != null && !pks.isEmpty()) { + // Use primary key fields for hashing + for (String key : pks) { + Integer fieldIndex = indexMap.get(key); + if (fieldIndex != null && fieldIndex >= 0 && fieldIndex < row.getFieldCount()) { + Object v = row.getField(fieldIndex); + hash = 31 * hash + (v == null ? 0 : v.hashCode()); + } + } + } else { + // Use all fields for hashing (sorted by name) + if (fields != null && !fields.isEmpty()) { + List names = new ArrayList<>(fields.keySet()); + Collections.sort(names); + for (String name : names) { + Integer fieldIndex = indexMap.get(name); + if (fieldIndex != null && fieldIndex >= 0 && fieldIndex < row.getFieldCount()) { + Object v = row.getField(fieldIndex); + hash = 31 * hash + (v == null ? 0 : v.hashCode()); + } + } + } else { + // Fallback: hash all fields in order + for (int i = 0; i < row.getFieldCount(); i++) { + Object v = row.getField(i); + hash = 31 * hash + (v == null ? 0 : v.hashCode()); + } + } + } + return Math.floorMod(hash, hint); + } + + /** + * Get or build field index mapping from cache + * + * @param cacheKey cache key (table ID) + * @param fields field map + * @return map of field name to index + */ + private Map getFieldIndexMap(String cacheKey, Map fields) { + Map indexMap = fieldIndexCache.get(cacheKey); + + if (indexMap == null) { + // Cache miss - build field index mapping + indexMap = new HashMap<>(fields.size()); + int index = 0; + for (String name : fields.keySet()) { + indexMap.put(name, index++); + } + + // Store in cache + fieldIndexCache.put(cacheKey, indexMap); + } + + return indexMap; + } + + /** + * Get field index by field name (deprecated - use getFieldIndexMap instead) + * + * @param fieldName field name + * @param fields field map + * @return field index, or -1 if not found + * @deprecated Use getFieldIndexMap for better performance with caching + */ + @Deprecated + private int getFieldIndex(String fieldName, Map fields) { + int index = 0; + for (String name : fields.keySet()) { + if (name.equals(fieldName)) { + return index; + } + index++; + } + return -1; + } + + /** + * Convert map to GenericRow + * + * @param data data map + * @param table table definition + * @param identifier table identifier + * @return GenericRow + * @throws Exception if conversion fails + */ + private GenericRow convertToGenericRow(Map data, TapTable table, Identifier identifier) throws Exception { + // Get or build field type mapping from cache + String cacheKey = identifier.getFullName(); + List paimonFields = paimonFieldCache.get(cacheKey); + + if (paimonFields == null) { + // Cache miss - build field type mapping + Table paimonTable = catalog.getTable(identifier); + paimonFields = paimonTable.rowType().getFields(); + + // Store in cache + paimonFieldCache.put(cacheKey, paimonFields); + } + + GenericRow genericRow = new GenericRow(paimonFields.size()); + int i = 0; + if (computeHashKey.get(table.getName())) { + genericRow.setField(i++, BinaryString.fromString(toHash(primaryKeyMap.get(table.getName()), data))); + } + for (; i < paimonFields.size(); i++) { + DataField dataField = paimonFields.get(i); + String fieldName = dataField.name(); + Object value = data.get(fieldName); + + // Get corresponding Paimon field type from cache + DataType paimonType = dataField.type(); + + genericRow.setField(i, convertValueToPaimonType(value, paimonType)); + } + + return genericRow; + } + + protected String toHash(Collection keys, Map data) { + try { + MessageDigest md = MessageDigest.getInstance(HASH_ALGORITHM); + try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) { + boolean isFirst = true; + baos.write('['); + for (String key : keys) { + if (isFirst) { + isFirst = false; + } else { + baos.write(SPLIT_CHAR); + } + + Object val = data.get(key); + byte[] bytes = toBytes(val); + baos.write(bytes); + } + baos.write(']'); + + byte[] hashBytes = md.digest(baos.toByteArray()); + StringBuilder hashHex = new StringBuilder(); + for (byte b : hashBytes) { + hashHex.append(String.format("%02x", b)); + } + return hashHex.toString(); // 返回 128 位(32 个字符)的哈希值 + } + } catch (Exception e) { + throw new RuntimeException("Failed to compute hash key for data: " + data, e); + } + } + + protected byte[] toBytes(Object data) throws IOException { + if (null == data) return new byte[0]; + if (data instanceof byte[]) return (byte[]) data; + if (data.getClass().isArray()) return arrayToBytes(Arrays.asList((Object[]) data)); + if (data instanceof Collection) return arrayToBytes((Collection) data); + if (data instanceof Map) return mapToBytes((Map) data); + return data.toString().getBytes(); + } + + protected byte[] arrayToBytes(Collection collection) throws IOException { + try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) { + boolean isFirst = true; + baos.write('['); + for (Object o : collection) { + if (isFirst) { + isFirst = false; + } else { + baos.write(SPLIT_CHAR); + } + baos.write(toBytes(o)); + } + baos.write(']'); + return baos.toByteArray(); + } + } + + protected byte[] mapToBytes(Map map) throws IOException { + try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) { + baos.write('{'); + for (Map.Entry en : map.entrySet()) { + baos.write(toBytes(en.getKey())); + baos.write(':'); + baos.write(toBytes(en.getValue())); + } + baos.write('}'); + return baos.toByteArray(); + } + } + + /** + * Convert value to Paimon-compatible type + * + * @param value original value + * @param paimonType target Paimon data type + * @return converted value + */ + private Object convertValueToPaimonType(Object value, DataType paimonType) { + if (value == null || paimonType == null) { + return null; + } + + // Get the type root for comparison (ignores nullable attribute) + String rooType = paimonType.getTypeRoot().name(); + switch (rooType) { + case "CHAR": + case "VARCHAR": + case "STRING": + return BinaryString.fromString(String.valueOf(value)); + case "TINYINT": + return ((Number) value).byteValue(); + case "SMALLINT": + return ((Number) value).shortValue(); + case "BIGINT": + return ((Number) value).longValue(); + case "DOUBLE": + return ((Number) value).doubleValue(); + case "FLOAT": + return ((Number) value).floatValue(); + case "DECIMAL": + Pair fieldPrecisionAndScale = getFieldPrecisionAndScale(paimonType.asSQLString()); + return Decimal.fromBigDecimal((BigDecimal) value, fieldPrecisionAndScale.getLeft(), fieldPrecisionAndScale.getRight()); + case "TIMESTAMP_WITHOUT_TIME_ZONE": + case "TIMESTAMP_WITH_LOCAL_TIME_ZONE": + java.sql.Timestamp sqlTimestamp = (java.sql.Timestamp) value; + return Timestamp.fromEpochMillis(sqlTimestamp.getTime(), (sqlTimestamp.getNanos() % 1000000)); + } + return value; + } + + /** + * Flush all accumulated records for all tables + * This should be called before closing the connector to ensure all data is committed + */ + public void flushAll() throws Exception { + for (String tableKey : new ArrayList<>(streamWriterCache.keySet())) { + flushTable(tableKey); + } + } + + /** + * Flush accumulated records for a specific table + * + * @param tableKey table key (database.tableName) + */ + public void flushTable(String tableKey) throws Exception { + AtomicInteger recordCount = accumulatedRecordCount.get(tableKey); + if (recordCount == null || recordCount.get() <= 0) { + return; // Nothing to flush + } + + StreamTableWrite writer = streamWriterCache.get(tableKey); + StreamTableCommit commit = streamCommitCache.get(tableKey); + + if (writer == null || commit == null) { + return; // Writer or commit not initialized + } + + // Use lock to ensure thread safety + Object lock = commitLocks.computeIfAbsent(tableKey, k -> new Object()); + synchronized (lock) { + int finalCount = recordCount.get(); + if (finalCount > 0) { + // Prepare and commit + long commitIdentifier = commitIdentifierGenerator.incrementAndGet(); + List messages = writer.prepareCommit(false, commitIdentifier); + commit.commit(commitIdentifier, messages); + commitCallback(tableKey); + // Reset counters + recordCount.set(0); + AtomicLong lastCommit = lastCommitTime.get(tableKey); + if (lastCommit != null) { + lastCommit.set(System.currentTimeMillis()); + } + } + } + } + + /** + * Convert timestamp to stream offset (snapshot IDs) for specified tables + * + * This method finds the snapshot ID that is earlier than or equal to the given timestamp + * for each table. The snapshot ID can be used to resume stream reading from that point. + * + * @param tables list of table names to get offset for + * @param timestamp timestamp in milliseconds + * @param log logger + * @return map of table name to snapshot ID + * @throws Exception if conversion fails + */ + public Object timestampToStreamOffset(List tables, Long timestamp, Log log) throws Exception { + log.info("Converting timestamp {} to stream offset for {} tables", timestamp, tables.size()); + + Map offsetMap = new HashMap<>(); + String database = config.getDatabase(); + + // For each table, find the snapshot ID at or before the given timestamp + for (String tableName : tables) { + try { + Identifier identifier = Identifier.create(database, tableName); + Table paimonTable = catalog.getTable(identifier); + + // Use reflection to access snapshotManager() method + // AbstractFileStoreTable is not public, so we need to use reflection + try { + java.lang.reflect.Method snapshotManagerMethod = paimonTable.getClass().getMethod("snapshotManager"); + SnapshotManager snapshotManager = (SnapshotManager) snapshotManagerMethod.invoke(paimonTable); + + // Find snapshot at or before the given timestamp + Snapshot snapshot = snapshotManager.earlierOrEqualTimeMills(timestamp); + + if (snapshot != null) { + long snapshotId = snapshot.id(); + offsetMap.put(tableName, snapshotId); + log.info("Table {} - found snapshot {} at timestamp {}", tableName, snapshotId, snapshot.timeMillis()); + } else { + // No snapshot found at or before the timestamp, use null (will start from beginning) + offsetMap.put(tableName, null); + log.warn("Table {} - no snapshot found at or before timestamp {}, will start from beginning", tableName, timestamp); + } + } catch (NoSuchMethodException e) { + log.warn("Table {} does not have snapshotManager() method, cannot find snapshot by timestamp", tableName); + offsetMap.put(tableName, null); + } + } catch (Catalog.TableNotExistException e) { + log.warn("Table {} does not exist, skipping", tableName); + } catch (Exception e) { + log.error("Error finding snapshot for table {}: {}", tableName, e.getMessage(), e); + // Put null to start from beginning for this table + offsetMap.put(tableName, null); + } + } + + log.info("Timestamp to offset conversion result: {}", offsetMap); + return offsetMap; + } + + /** + * Stream read records from Paimon table (CDC mode) + * + * @param tables list of tables to read from + * @param offsetState offset state for resuming read + * @param eventBatchSize batch size for events + * @param eventsOffsetConsumer consumer for events and offset + * @param connectorContext connector context + * @throws Exception if read fails + */ + public void streamRead(List tables, Object offsetState, int eventBatchSize, + java.util.function.BiConsumer, Object> eventsOffsetConsumer, + TapConnectorContext connectorContext, Supplier running) throws Exception { + Log log = connectorContext.getLog(); + log.info("Starting stream read from tables: {}", tables); + + String database = config.getDatabase(); + + // Parse offset state - each table has its own snapshot ID + Map tableSnapshots = new HashMap<>(); + if (offsetState instanceof Map) { + Map offsetMap = (Map) offsetState; + for (Map.Entry entry : offsetMap.entrySet()) { + if (entry.getValue() != null) { + tableSnapshots.put(entry.getKey(), Long.parseLong(entry.getValue().toString()) + 1); + } + } + log.info("Resuming stream read from snapshots: {}", tableSnapshots); + } else if (offsetState == null) { + // First time stream read - start from AFTER latest snapshot to avoid reading historical data + log.info("No offset state found, initializing stream read from after latest snapshots"); + + // For each table, get the latest snapshot ID + for (String tableName : tables) { + try { + Identifier identifier = Identifier.create(database, tableName); + Table paimonTable = catalog.getTable(identifier); + + // Use reflection to access snapshotManager() method + try { + java.lang.reflect.Method snapshotManagerMethod = paimonTable.getClass().getMethod("snapshotManager"); + SnapshotManager snapshotManager = (SnapshotManager) snapshotManagerMethod.invoke(paimonTable); + + // Get the latest snapshot + Snapshot latestSnapshot = snapshotManager.latestSnapshot(); + + if (latestSnapshot != null) { + long snapshotId = latestSnapshot.id(); + // IMPORTANT: restore(snapshotId) will INCLUDE that snapshot's data + // To start from AFTER the latest snapshot, we need to use snapshotId + 1 + // This way, only NEW data after current snapshot will be read + long nextSnapshotId = snapshotId + 1; + tableSnapshots.put(tableName, nextSnapshotId); + log.info("Table {} - initialized to start AFTER latest snapshot {} (will start from snapshot {})", + tableName, snapshotId, nextSnapshotId); + } else { + // No snapshot exists yet, stream read will start from the first snapshot when it's created + log.info("Table {} - no snapshots exist yet, will start from first snapshot", tableName); + // Don't put anything in tableSnapshots, let it start naturally + } + } catch (NoSuchMethodException e) { + log.warn("Table {} does not have snapshotManager() method", tableName); + } + } catch (Catalog.TableNotExistException e) { + log.warn("Table {} does not exist, skipping", tableName); + } catch (Exception e) { + log.error("Error getting latest snapshot for table {}: {}", tableName, e.getMessage(), e); + } + } + + log.info("Initialized stream read to start after latest snapshots: {}", tableSnapshots); + } + + // Initialize stream scans for all tables + Map streamScans = new HashMap<>(); + Map tableReads = new HashMap<>(); + Map> paimonFieldsMap = new HashMap<>(); + Map> tapFieldsMap = new HashMap<>(); + + for (String tableName : tables) { + Identifier identifier = Identifier.create(database, tableName); + + // Get Paimon table + Table paimonTable; + try { + paimonTable = catalog.getTable(identifier); + } catch (Catalog.TableNotExistException e) { + log.warn("Table {} does not exist, skipping stream read", tableName); + continue; + } + + // Get TapTable definition + TapTable tapTable = connectorContext.getTableMap().get(tableName); + if (tapTable == null) { + log.warn("TapTable definition not found for table: {}, skipping", tableName); + continue; + } + + // Create read builder + ReadBuilder readBuilder = paimonTable.newReadBuilder(); + + // Create stream scan + StreamTableScan streamScan = readBuilder.newStreamScan(); + + // Restore from offset if available + Long startSnapshotId = tableSnapshots.get(tableName); + if (startSnapshotId != null) { + streamScan.restore(startSnapshotId); + log.info("Restored table {} from snapshot: {}", tableName, startSnapshotId); + } + + // Get field names and types for conversion + List paimonFields = paimonTable.rowType().getFields(); + Map tapFields = tapTable.getNameFieldMap(); + + // Create table read + TableRead tableRead = readBuilder.newRead(); + + // Store in maps + streamScans.put(tableName, streamScan); + tableReads.put(tableName, tableRead); + paimonFieldsMap.put(tableName, paimonFields); + tapFieldsMap.put(tableName, tapFields); + + log.info("Initialized stream scan for table: {}", tableName); + } + + if (streamScans.isEmpty()) { + log.warn("No valid tables to stream read"); + return; + } + + log.info("Starting continuous stream read for {} tables with multi-threading", streamScans.size()); + + // Create thread pool for table scanning - one thread per table + int threadCount = Math.min(streamScans.size(), Runtime.getRuntime().availableProcessors()); + ExecutorService executorService = new ThreadPoolExecutor( + threadCount, + threadCount, + 60L, + TimeUnit.SECONDS, + new LinkedBlockingQueue<>(), + r -> { + Thread t = new Thread(r); + t.setName("Paimon-StreamRead-" + t.getId()); + t.setDaemon(true); + return t; + } + ); + + AtomicReference threadException = new AtomicReference<>(); + BlockingQueue eventQueue = new LinkedBlockingQueue<>(eventBatchSize * 10); + Map currentOffsets = new ConcurrentHashMap<>(); + + // Initialize offsets + for (String tableName : streamScans.keySet()) { + Long snapshot = tableSnapshots.get(tableName); + if (snapshot != null) { + currentOffsets.put(tableName, snapshot); + } + } + + // Start consumer thread to collect events and send to downstream + Thread consumerThread = new Thread(() -> { + List batch = new ArrayList<>(); + try { + while (running.get() || !eventQueue.isEmpty()) { + io.tapdata.entity.event.TapEvent event = eventQueue.poll(100, TimeUnit.MILLISECONDS); + if (event != null) { + batch.add(event); + + // Send batch when size reached + if (batch.size() >= eventBatchSize) { + Map offsets = new HashMap<>(currentOffsets); + eventsOffsetConsumer.accept(batch, offsets); + batch = new ArrayList<>(); + } + } + + // When stopping, send remaining batch even if not full + // This ensures no data loss when running becomes false + if (!running.get() && !batch.isEmpty()) { + Map offsets = new HashMap<>(currentOffsets); + eventsOffsetConsumer.accept(batch, offsets); + batch = new ArrayList<>(); + } + } + + // Send any remaining events (final safety check) + if (!batch.isEmpty()) { + Map offsets = new HashMap<>(currentOffsets); + eventsOffsetConsumer.accept(batch, offsets); + } + } catch (Exception e) { + log.error("Error in consumer thread: {}", e.getMessage(), e); + threadException.set(e); } - } - } + }); + consumerThread.setName("Paimon-StreamRead-Consumer"); + consumerThread.setDaemon(true); + consumerThread.start(); + + // Submit scanning tasks for each table + for (String tableName : streamScans.keySet()) { + StreamTableScan streamScan = streamScans.get(tableName); + TableRead tableRead = tableReads.get(tableName); + List paimonFields = paimonFieldsMap.get(tableName); + Map tapFields = tapFieldsMap.get(tableName); + + executorService.submit(() -> { + log.info("Started stream read thread for table: {}", tableName); + try { + while (running.get()) { + // Check for exceptions in other threads + if (threadException.get() != null) { + break; + } + + // Plan next batch of splits + Plan plan = streamScan.plan(); + List splits = plan.splits(); + + if (splits.isEmpty()) { + // No new data, update checkpoint and wait + Long currentSnapshot = streamScan.checkpoint(); + currentOffsets.put(tableName, currentSnapshot); + Thread.sleep(1000); + continue; + } + + log.debug("Table {} has {} new splits to read", tableName, splits.size()); + + // Read data from each split + long totalRecords = 0; + for (Split split : splits) { + if (!running.get()) { + break; + } + + // Create record reader for this split + RecordReader reader = tableRead.createReader(split); + + try { + // Read records from this split + RecordReader.RecordIterator iterator = reader.readBatch(); + + while (iterator != null && running.get()) { + InternalRow row; + while ((row = iterator.next()) != null) { + // Convert InternalRow to Map + Map data = convertInternalRowToMap(row, paimonFields, tapFields); + + // Determine event type based on RowKind + io.tapdata.entity.event.TapEvent event = createEventFromRowKind(row, data, tableName); + + if (event != null) { + // Add to queue, block if queue is full + eventQueue.put(event); + totalRecords++; + } + } + + // Release current batch + iterator.releaseBatch(); + + // Read next batch + iterator = reader.readBatch(); + } + + } finally { + // Close reader + try { + reader.close(); + } catch (Exception e) { + log.warn("Error closing reader for table {}: {}", tableName, e.getMessage()); + } + } + } + + // Save checkpoint for this table + Long currentSnapshot = streamScan.checkpoint(); + currentOffsets.put(tableName, currentSnapshot); + + log.debug("Stream read batch completed for table: {}, records: {}", tableName, totalRecords); + } + } catch (InterruptedException e) { + log.warn("Stream read thread interrupted for table: {}", tableName); + Thread.currentThread().interrupt(); + } catch (Exception e) { + log.error("Error in stream read thread for table {}: {}", tableName, e.getMessage(), e); + threadException.set(e); + return; + } + log.info("Stream read thread stopped for table: {}", tableName); + }); + } + + // Wait for threads to complete or exception to occur + try { + while (running.get()) { + if (threadException.get() != null) { + throw new RuntimeException("Stream read failed", threadException.get()); + } + Thread.sleep(1000); + } + } finally { + executorService.shutdown(); + try { + if (!executorService.awaitTermination(30, TimeUnit.SECONDS)) { + executorService.shutdownNow(); + } + } catch (InterruptedException e) { + executorService.shutdownNow(); + Thread.currentThread().interrupt(); + } + + // Wait for consumer thread + try { + consumerThread.join(5000); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + + log.info("Stream read completed for all tables"); + } + + /** + * Create TapEvent from InternalRow based on RowKind + * + * @param row internal row + * @param data converted data map + * @param tableName table name + * @return TapEvent (Insert, Update, or Delete) + */ + private io.tapdata.entity.event.TapEvent createEventFromRowKind(InternalRow row, Map data, String tableName) { + RowKind rowKind = row.getRowKind(); + + switch (rowKind) { + case INSERT: + case UPDATE_AFTER: + // For INSERT and UPDATE_AFTER, create insert event + io.tapdata.entity.event.dml.TapInsertRecordEvent insertEvent = + new io.tapdata.entity.event.dml.TapInsertRecordEvent().init(); + insertEvent.setTableId(tableName); + insertEvent.setAfter(data); + insertEvent.setReferenceTime(System.currentTimeMillis()); + return insertEvent; + + case DELETE: + case UPDATE_BEFORE: + // For DELETE and UPDATE_BEFORE, create delete event + io.tapdata.entity.event.dml.TapDeleteRecordEvent deleteEvent = + new io.tapdata.entity.event.dml.TapDeleteRecordEvent().init(); + deleteEvent.setTableId(tableName); + deleteEvent.setBefore(data); + deleteEvent.setReferenceTime(System.currentTimeMillis()); + return deleteEvent; + + default: + // Unknown row kind, skip + return null; + } + } + + /** + * Batch read records from Paimon table + * + * @param table table definition + * @param offsetState offset state for resuming read (not used for now) + * @param eventBatchSize batch size for events + * @param eventsOffsetConsumer consumer for events and offset + * @param connectorContext connector context + * @throws Exception if read fails + */ + public void batchRead(TapTable table, Object offsetState, int eventBatchSize, + java.util.function.BiConsumer, Object> eventsOffsetConsumer, + TapConnectorContext connectorContext) throws Exception { + String database = config.getDatabase(); + String tableName = table.getName(); + Identifier identifier = Identifier.create(database, tableName); + + Log log = connectorContext.getLog(); + log.info("Starting batch read from table: {}", tableName); + + // Get Paimon table + Table paimonTable; + try { + paimonTable = catalog.getTable(identifier); + } catch (Catalog.TableNotExistException e) { + log.warn("Table {} does not exist, skipping batch read", tableName); + return; + } + + // Create read builder + ReadBuilder readBuilder = paimonTable.newReadBuilder(); + + // Create table scan to get splits + TableScan tableScan = readBuilder.newScan(); + TableScan.Plan plan = tableScan.plan(); + List splits = plan.splits(); + + log.info("Table {} has {} splits to read", tableName, splits.size()); + + // Get field names and types for conversion + List paimonFields = paimonTable.rowType().getFields(); + Map tapFields = table.getNameFieldMap(); + + // Read data from each split + long totalRecords = 0; + for (Split split : splits) { + // Create record reader for this split + RecordReader reader = readBuilder.newRead().createReader(split); + + List events = new ArrayList<>(); + + try { + // Read records from this split using RecordReader.RecordIterator + RecordReader.RecordIterator iterator = reader.readBatch(); + + while (iterator != null) { + InternalRow row; + while ((row = iterator.next()) != null) { + // Check RowKind to filter out intermediate states + // Only read final state data (INSERT and UPDATE_AFTER) + RowKind rowKind = row.getRowKind(); + if (rowKind == RowKind.UPDATE_BEFORE || rowKind == RowKind.DELETE) { + // Skip intermediate states (UPDATE_BEFORE and DELETE) + // These are not final state data + continue; + } + + // Convert InternalRow to Map + Map data = convertInternalRowToMap(row, paimonFields, tapFields); + + // Create TapInsertRecordEvent + io.tapdata.entity.event.dml.TapInsertRecordEvent event = + new io.tapdata.entity.event.dml.TapInsertRecordEvent().init(); + event.setTableId(tableName); + event.setAfter(data); + + events.add(event); + totalRecords++; + + // Send batch when size reached + if (events.size() >= eventBatchSize) { + eventsOffsetConsumer.accept(events, null); + events = new ArrayList<>(); + } + } + + // Release current batch + iterator.releaseBatch(); + + // Read next batch + iterator = reader.readBatch(); + } + + // Send remaining events + if (!events.isEmpty()) { + eventsOffsetConsumer.accept(events, null); + } + + } finally { + // Close reader + try { + reader.close(); + } catch (Exception e) { + log.warn("Error closing reader: {}", e.getMessage()); + } + } + } + + log.info("Batch read completed for table: {}, total records: {}", tableName, totalRecords); + } + + /** + * Convert Paimon InternalRow to Map + * + * @param row Paimon internal row + * @param paimonFields Paimon field definitions + * @param tapFields TapData field definitions + * @return data map + */ + private Map convertInternalRowToMap(InternalRow row, List paimonFields, + Map tapFields) { + Map data = new LinkedHashMap<>(); + + for (int i = 0; i < paimonFields.size(); i++) { + DataField paimonField = paimonFields.get(i); + String fieldName = paimonField.name(); + DataType dataType = paimonField.type(); + + // Check if field is null + if (row.isNullAt(i)) { + data.put(fieldName, null); + continue; + } + + // Convert value based on data type + Object value = convertPaimonValueToJava(row, i, dataType); + data.put(fieldName, value); + } + + return data; + } + + /** + * Convert Paimon value to Java object + * + * @param row internal row + * @param pos field position + * @param dataType Paimon data type + * @return Java object + */ + private Object convertPaimonValueToJava(InternalRow row, int pos, DataType dataType) { + String typeRoot = dataType.getTypeRoot().name(); + switch (typeRoot) { + case "BOOLEAN": + return row.getBoolean(pos); + case "TINYINT": + return row.getByte(pos); + case "SMALLINT": + return row.getShort(pos); + case "INTEGER": + return row.getInt(pos); + case "BIGINT": + return row.getLong(pos); + case "FLOAT": + return row.getFloat(pos); + case "DOUBLE": + return row.getDouble(pos); + case "DECIMAL": + Decimal decimal; + if (dataType instanceof DecimalType) { + decimal = row.getDecimal(pos, ((DecimalType) dataType).getPrecision(), ((DecimalType) dataType).getScale()); + } else { + Pair fieldPrecisionAndScale = getFieldPrecisionAndScale(dataType.asSQLString()); + decimal = row.getDecimal(pos, fieldPrecisionAndScale.getLeft(), fieldPrecisionAndScale.getRight()); + } + return decimal != null ? decimal.toBigDecimal() : null; + case "DATE": + int days = row.getInt(pos); + return new java.sql.Date(days * 86400000L); + case "TIMESTAMP_WITHOUT_TIME_ZONE": { + Timestamp timestamp; + if (dataType instanceof TimestampType) { + timestamp = row.getTimestamp(pos, ((TimestampType) dataType).getPrecision()); + } else { + Integer fraction = getFieldFraction(dataType.asSQLString()); + timestamp = row.getTimestamp(pos, fraction); + } + if (timestamp != null) { + return timestamp.toLocalDateTime(); + } + return null; + } + case "TIMESTAMP_WITH_LOCAL_TIME_ZONE": { + Timestamp timestamp; + if (dataType instanceof TimestampType) { + timestamp = row.getTimestamp(pos, ((TimestampType) dataType).getPrecision()); + } else { + Integer fraction = getFieldFraction(dataType.asSQLString()); + timestamp = row.getTimestamp(pos, fraction); + } + if (timestamp != null) { + return timestamp.toLocalDateTime().atZone(ZoneOffset.UTC); + } + return null; + } + case "TIME_WITHOUT_TIME_ZONE": + return LocalTime.ofNanoOfDay(row.getInt(pos) * 1000_000L).atDate(LocalDate.ofYearDay(1970, 1)); + case "BINARY": + case "VARBINARY": + case "BYTES": + return row.getBinary(pos); + default: + BinaryString binaryString = row.getString(pos); + return binaryString != null ? binaryString.toString() : null; + } + } + + /** + * Count records in Paimon table + * + * @param table table definition + * @param log logger + * @return record count + * @throws Exception if count fails + */ + public long batchCount(TapTable table, Log log) throws Exception { + String database = config.getDatabase(); + String tableName = table.getName(); + Identifier identifier = Identifier.create(database, tableName); + + log.info("Counting records in table: {}", tableName); + + // Get Paimon table + Table paimonTable; + try { + paimonTable = catalog.getTable(identifier); + } catch (Catalog.TableNotExistException e) { + log.warn("Table {} does not exist, returning count 0", tableName); + return 0; + } + + // Create read builder + ReadBuilder readBuilder = paimonTable.newReadBuilder(); + + // Create table scan to get splits + TableScan tableScan = readBuilder.newScan(); + TableScan.Plan plan = tableScan.plan(); + List splits = plan.splits(); + + log.debug("Table {} has {} splits to count", tableName, splits.size()); + + // Count records from all splits + long totalCount = 0; + for (Split split : splits) { + // Create record reader for this split + RecordReader reader = readBuilder.newRead().createReader(split); + + try { + // Read records from this split + RecordReader.RecordIterator iterator = reader.readBatch(); + + while (iterator != null) { + InternalRow row; + while ((row = iterator.next()) != null) { + // Check RowKind to filter out intermediate states + // Only count final state data (INSERT and UPDATE_AFTER) + RowKind rowKind = row.getRowKind(); + if (rowKind == RowKind.INSERT || rowKind == RowKind.UPDATE_AFTER) { + totalCount++; + } + } + + // Release current batch + iterator.releaseBatch(); + + // Read next batch + iterator = reader.readBatch(); + } + + } finally { + // Close reader + try { + reader.close(); + } catch (Exception e) { + log.warn("Error closing reader: {}", e.getMessage()); + } + } + } + + log.info("Table {} has {} records", tableName, totalCount); + return totalCount; + } + + /** + * Query records by advance filter + * + * @param table table definition + * @param filter advance filter with conditions + * @param consumer consumer for filter results + * @param log logger + * @throws Exception if query fails + */ + public void queryByAdvanceFilter(TapTable table, io.tapdata.pdk.apis.entity.TapAdvanceFilter filter, + java.util.function.Consumer consumer, + Log log) throws Exception { + String database = config.getDatabase(); + String tableName = table.getName(); + Identifier identifier = Identifier.create(database, tableName); + + log.info("Querying table {} with advance filter", tableName); + + // Get Paimon table + Table paimonTable; + try { + paimonTable = catalog.getTable(identifier); + } catch (Catalog.TableNotExistException e) { + log.warn("Table {} does not exist, skipping query", tableName); + return; + } + + // Get field names and types for conversion + List paimonFields = paimonTable.rowType().getFields(); + Map tapFields = table.getNameFieldMap(); + + // Create read builder + ReadBuilder readBuilder = paimonTable.newReadBuilder(); + + // Create batch scan + TableScan tableScan = readBuilder.newScan(); + TableRead tableRead = readBuilder.newRead(); + + // Plan all splits + Plan plan = tableScan.plan(); + List splits = plan.splits(); + + log.debug("Table {} has {} splits to query", tableName, splits.size()); + + // Determine batch size + int batchSize = filter != null && filter.getBatchSize() != null && filter.getBatchSize() > 0 + ? filter.getBatchSize() : 1000; + + // Determine limit and skip + int limit = filter != null && filter.getLimit() != null ? filter.getLimit() : Integer.MAX_VALUE; + int skip = filter != null && filter.getSkip() != null ? filter.getSkip() : 0; + + io.tapdata.pdk.apis.entity.FilterResults filterResults = new io.tapdata.pdk.apis.entity.FilterResults(); + int skippedCount = 0; + int returnedCount = 0; + + // Read records from all splits + outerLoop: + for (Split split : splits) { + // Create record reader for this split + RecordReader reader = tableRead.createReader(split); + + try { + // Read records from this split + RecordReader.RecordIterator iterator = reader.readBatch(); + + while (iterator != null) { + InternalRow row; + while ((row = iterator.next()) != null) { + // Check if we've returned enough records + if (returnedCount >= limit) { + break outerLoop; + } + + // Check RowKind to filter out intermediate states + // Only read final state data (INSERT and UPDATE_AFTER) + RowKind rowKind = row.getRowKind(); + if (rowKind == RowKind.UPDATE_BEFORE || rowKind == RowKind.DELETE) { + // Skip intermediate states + continue; + } + + // Convert InternalRow to Map + Map data = convertInternalRowToMap(row, paimonFields, tapFields); + + // Apply filter conditions + if (matchesFilter(data, filter)) { + // Handle skip + if (skippedCount < skip) { + skippedCount++; + continue; + } + + // Add to results + filterResults.add(data); + returnedCount++; + + // Send batch when size reached + if (filterResults.resultSize() >= batchSize) { + consumer.accept(filterResults); + filterResults = new io.tapdata.pdk.apis.entity.FilterResults(); + } + } + } + + // Release current batch + iterator.releaseBatch(); + + // Read next batch + iterator = reader.readBatch(); + } + + } finally { + // Close reader + try { + reader.close(); + } catch (Exception e) { + log.warn("Error closing reader: {}", e.getMessage()); + } + } + } + + // Send remaining results + if (filterResults.resultSize() > 0) { + consumer.accept(filterResults); + } + + log.info("Query completed for table: {}, returned {} records", tableName, returnedCount); + } + + /** + * Check if data matches filter conditions + * For now, we only support basic filtering (skip/limit) + * Advanced filtering (where conditions) can be added later + * + * @param data data map + * @param filter advance filter + * @return true if matches + */ + private boolean matchesFilter(Map data, io.tapdata.pdk.apis.entity.TapAdvanceFilter filter) { + // For now, we don't support where conditions in Paimon + // All records match (filtering is done by skip/limit) + return true; + } + + @Override + public void close() { + // Flush all accumulated data before closing + try { + flushAll(); + } catch (Exception e) { + // Log error but continue with cleanup + System.err.println("Error flushing accumulated data: " + e.getMessage()); + } + + cleanupAllResources(); + } + + public void setFlushOffsetCallback(Consumer flushOffsetCallback) { + this.flushOffsetCallback = flushOffsetCallback; + } + + public Map getFirstOffsetByTable() { + return firstOffsetByTable; + } + + private void commitCallback(String tableName) { + if (flushOffsetCallback != null) { + TapCallbackOffset offsetToSave = null; + synchronized (firstOffsetByTable) { + Map.Entry firstEntry = firstOffsetByTable.entrySet() + .stream() + .findFirst() + .orElse(null); + + if (firstEntry != null) { + String firstTableName = firstEntry.getKey(); + + // 如果当前刷新的表是第一个表 + offsetToSave = firstEntry.getValue(); + if (tableName.equals(firstTableName)) { + firstOffsetByTable.remove(firstTableName); + } + } + } + if (offsetToSave != null && offsetToSave.hasValidOffset()) { + try { + flushOffsetCallback.accept(offsetToSave); + } catch (Exception e) { + log.warn("Failed to flush offset callback: {}", e.getMessage()); + } + } + } + } } diff --git a/connectors/paimon-connector/src/main/overwrite/org/apache/paimon/utils/ThreadUtils.java b/connectors/paimon-connector/src/main/overwrite/org/apache/paimon/utils/ThreadUtils.java new file mode 100644 index 000000000..ded3993d5 --- /dev/null +++ b/connectors/paimon-connector/src/main/overwrite/org/apache/paimon/utils/ThreadUtils.java @@ -0,0 +1,73 @@ +// +// Source code recreated from a .class file by IntelliJ IDEA +// (powered by FernFlower decompiler) +// + +package org.apache.paimon.utils; + +import java.lang.management.ManagementFactory; +import java.lang.management.ThreadInfo; +import java.util.Arrays; +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.stream.Collectors; +import org.slf4j.Logger; + +public class ThreadUtils { + public static String currentStackString() { + StackTraceElement[] trace = (StackTraceElement[])Thread.getAllStackTraces().get(Thread.currentThread()); + StringBuilder builder = new StringBuilder(); + + for(StackTraceElement traceElement : trace) { + builder.append("\nat ").append(traceElement); + } + + return builder.toString(); + } + + public static void errorLogThreadDump(Logger logger) { + ThreadInfo[] perThreadInfo = ManagementFactory.getThreadMXBean().dumpAllThreads(true, true); + logger.error("Thread dump: \n{}", Arrays.stream(perThreadInfo).map(Object::toString).collect(Collectors.joining())); + } + + public static boolean stackContains(String name) { + StackTraceElement[] ss = (new RuntimeException()).getStackTrace(); + + for(StackTraceElement s : ss) { + if (s.toString().contains(name)) { + return true; + } + } + + return false; + } + + public static ThreadFactory newDaemonThreadFactory(String prefix) { + final ThreadFactory namedFactory = getNamedThreadFactory(prefix); + return new ThreadFactory() { + public Thread newThread(Runnable r) { + Thread t = namedFactory.newThread(r); + if (!t.isDaemon()) { + t.setDaemon(true); + } + + if (t.getPriority() != 5) { + t.setPriority(5); + } + + return t; + } + }; + } + + private static ThreadFactory getNamedThreadFactory(final String prefix) { + return new ThreadFactory() { + private final AtomicInteger threadNumber = new AtomicInteger(1); + + public Thread newThread(Runnable r) { + String name = prefix + "-t" + this.threadNumber.getAndIncrement(); + return new Thread(null, r, name); + } + }; + } +} diff --git a/connectors/paimon-connector/src/main/resources/spec.json b/connectors/paimon-connector/src/main/resources/spec.json index 6c05742bb..b29c4c427 100644 --- a/connectors/paimon-connector/src/main/resources/spec.json +++ b/connectors/paimon-connector/src/main/resources/spec.json @@ -1,6 +1,7 @@ { "properties": { "name": "Paimon", + "realName": "Apache Paimon", "icon": "icons/paimon.png", "doc": "${doc}", "tags": ["Database", "schema-free"], @@ -10,11 +11,14 @@ "capabilities": [ { "id": "dml_insert_policy", - "alternatives": ["update_on_exists", "ignore_on_exists", "just_insert"] + "alternatives": ["update_on_exists"] }, { "id": "dml_update_policy", - "alternatives": ["ignore_on_nonexists", "insert_on_nonexists", "log_on_nonexists"] + "alternatives": ["ignore_on_nonexists"] + }, + { + "id": "flush_offset_callback" } ], "connection": { @@ -59,7 +63,7 @@ "x-index": 20, "x-reactions": [ { - "target": "*(s3Endpoint,s3AccessKey,s3SecretKey,s3Region)", + "target": "*(s3Endpoint,s3AccessKey,s3SecretKey,s3Region,s3Properties)", "fulfill": { "state": { "visible": "{{$self.value==='s3'}}" @@ -118,6 +122,61 @@ }, "x-index": 60 }, + "s3Properties": { + "type": "array", + "title": "${s3Properties}", + "x-decorator": "FormItem", + "default": [ + {"propKey": "fs.s3a.connection.maximum", "propValue": "1000"}, + {"propKey": "fs.s3a.connection.establish.timeout", "propValue": "5000"} + ], + "x-component": "ArrayItems", + "x-index": 65, + "items": { + "type": "object", + "properties": { + "space": { + "type": "void", + "x-component": "Space", + "properties": { + "propKey": { + "type": "string", + "x-decorator": "FormItem", + "x-component": "Input", + "x-component-props": { + "placeholder": "${propKey}" + }, + "x-index": 1 + }, + "propValue": { + "type": "number", + "x-decorator": "FormItem", + "x-component": "Input", + "x-component-props": { + "placeholder": "${propValue}" + }, + "x-index": 2 + }, + "remove": { + "type": "void", + "x-decorator": "FormItem", + "x-component": "ArrayItems.Remove" + } + } + } + } + }, + "properties": { + "add": { + "type": "void", + "title": "${prompt}", + "x-component": "ArrayItems.Addition", + "x-component-props": { + "defaultValue": { "propKey": "", "propValue": "" } + } + } + } + }, "hdfsHost": { "type": "string", "title": "${hdfsHost}", @@ -183,7 +242,66 @@ } }, "node": { + "type": "object", + "x-tableConfigScope": ["target"], + "x-reactions": { + "target": "nodeConfig.partitionKey", + "fulfill": { + "state": { + "display": "{{$settings.syncType===\"migrate\"?\"hidden\":\"visible\"}}" + } + } + }, "properties": { + "hashKey": { + "type": "boolean", + "title": "${hashKey}", + "x-decorator": "FormItem", + "x-component": "Switch", + "x-decorator-props": { + "tooltip": "${hashKeyTip}" + }, + "default": false, + "x-index": 5, + "x-perTable": true, + "x-reactions": [ + { + "dependencies": ["$inputs"], + "fulfill": { + "state": { + "display": "{{$deps[0].length > 0 ? \"visible\":\"hidden\"}}" + } + } + } + ] + }, + "partitionKey": { + "title": "${partitionKey}", + "type": "array", + "x-index": 10, + "x-perTable": true, + "x-decorator": "FormItem", + "x-decorator-props": { + "tooltip": "${partitionKeyTip}" + }, + "x-component": "FieldSelect", + "x-component-props": { + "allowCreate": false, + "multiple": true, + "filterable": true + }, + "x-reactions": [ + "{{useAsyncDataSourceByConfig({service: loadNodeTableFields, withoutField: true}, $values.id, $settings.syncType===\"migrate\"?$self.path.pop().segments.slice(-1)[0]:$values.tableName)}}", + { + "dependencies":["$inputs"], + "fulfill":{ + "state":{ + "display": "{{$deps[0].length > 0 ? \"visible\":\"hidden\"}}" + } + } + } + ] + }, "bucketMode": { "type": "string", "title": "${bucketMode}", @@ -202,6 +320,7 @@ "default": "dynamic", "required": true, "x-index": 20, + "x-perTable": true, "x-reactions": [ { "target": "bucketCount", @@ -219,11 +338,429 @@ "x-decorator": "FormItem", "x-component": "InputNumber", "x-component-props": { - "min": 1, + "min": -2, "placeholder": "${bucketCount_placeholder}" }, "default": 4, - "x-index": 30 + "x-index": 30, + "x-perTable": true + }, + "fileFormat": { + "type": "string", + "title": "${fileFormat}", + "default": "", + "x-decorator": "FormItem", + "x-component": "Select", + "x-index": 40, + "x-perTable": true, + "enum": [ + { + "label": "", + "value": "" + }, + { + "label": "ORC", + "value": "orc" + }, + { + "label": "PARQUET", + "value": "parquet" + }, + { + "label": "AVRO", + "value": "avro" + }, + { + "label": "CSV", + "value": "csv" + }, + { + "label": "JSON", + "value": "json" + }, + { + "label": "LANCE", + "value": "lance" + }, + { + "label": "BLOB", + "value": "blob" + } + ], + "x-reactions": [ + { + "dependencies": ["$inputs"], + "fulfill": { + "state": { + "display": "{{$deps[0].length > 0 ? \"visible\":\"hidden\"}}" + } + } + } + ] + }, + "compression": { + "type": "string", + "title": "${compression}", + "default": "", + "x-decorator": "FormItem", + "x-component": "Select", + "x-index": 50, + "x-perTable": true, + "enum": [ + { + "label": "", + "value": "" + }, + { + "label": "NONE", + "value": "none" + }, + { + "label": "SNAPPY", + "value": "snappy" + }, + { + "label": "LZ4", + "value": "lz4" + }, + { + "label": "ZSTD", + "value": "zstd" + }, + { + "label": "GZIP", + "value": "gzip" + }, + { + "label": "BZIP2", + "value": "bzip2" + } + ], + "x-reactions": [ + { + "dependencies": ["$inputs"], + "fulfill": { + "state": { + "display": "{{$deps[0].length > 0 ? \"visible\":\"hidden\"}}" + } + } + } + ] + }, + "tableProperties": { + "type": "array", + "title": "${tableProperties}", + "x-decorator": "FormItem", + "x-decorator-props": { + "tooltip": "${tablePropertiesTip}" + }, + "x-component": "ArrayItems", + "x-index": 55, + "x-perTable": true, + "default": [], + "items": { + "type": "object", + "properties": { + "space": { + "type": "void", + "x-component": "Space", + "properties": { + "propKey": { + "type": "string", + "x-decorator": "FormItem", + "x-component": "Input", + "x-component-props": { + "placeholder": "${propKey}" + }, + "x-index": 1 + }, + "propValue": { + "type": "number", + "x-decorator": "FormItem", + "x-component": "Input", + "x-component-props": { + "placeholder": "${propValue}" + }, + "x-index": 2 + }, + "remove": { + "type": "void", + "x-decorator": "FormItem", + "x-component": "ArrayItems.Remove" + } + } + } + } + }, + "properties": { + "add": { + "type": "void", + "title": "${prompt}", + "x-component": "ArrayItems.Addition", + "x-component-props": { + "defaultValue": { "propKey": "", "propValue": "" } + } + } + } + }, + "writeBufferSize": { + "type": "number", + "title": "${writeBufferSize}", + "x-decorator": "FormItem", + "x-component": "InputNumber", + "x-component-props": { + "min": 64, + "max": 2048, + "placeholder": "${writeBufferSize_placeholder}" + }, + "default": 256, + "x-index": 60, + "x-reactions": [ + { + "dependencies": ["$inputs"], + "fulfill": { + "state": { + "display": "{{$deps[0].length > 0 ? \"visible\":\"hidden\"}}" + } + } + } + ] + }, + "diskOverflowWrite": { + "type": "boolean", + "title": "${diskOverflowWrite}", + "x-decorator": "FormItem", + "x-component": "Switch", + "default": false, + "x-index": 62, + "x-reactions": [ + { + "dependencies": ["$inputs"], + "fulfill": { + "state": { + "display": "{{$deps[0].length > 0 ? \"visible\":\"hidden\"}}" + } + } + } + ] + }, + "diskMaxSize": { + "type": "number", + "title": "${diskMaxSize}", + "x-decorator": "FormItem", + "x-component": "InputNumber", + "x-component-props": { + "min": 1, + "max": 10 + }, + "default": 1, + "x-index": 65, + "x-reactions": [ + { + "dependencies": ["$inputs", ".diskOverflowWrite"], + "fulfill": { + "state": { + "display": "{{$deps[0].length > 0 && $deps[1] ? \"visible\":\"hidden\"}}" + } + } + } + ] + }, + "diskTmpDir": { + "type": "string", + "title": "${diskTmpDir}", + "default": "/tmp", + "x-decorator": "FormItem", + "x-component": "Input", + "x-index": 68, + "x-perTable": true, + "x-reactions": [ + { + "dependencies": ["$inputs", ".diskOverflowWrite"], + "fulfill": { + "state": { + "display": "{{$deps[0].length > 0 && $deps[1] ? \"visible\":\"hidden\"}}" + } + } + } + ] + }, + "batchAccumulationSize": { + "type": "number", + "title": "${batchAccumulationSize}", + "x-decorator": "FormItem", + "x-component": "InputNumber", + "x-component-props": { + "min": 0, + "max": 1000000, + "placeholder": "${batchAccumulationSize_placeholder}" + }, + "default": 100000, + "x-index": 70, + "x-reactions": [ + { + "dependencies": ["$inputs"], + "fulfill": { + "state": { + "display": "{{$deps[0].length > 0 ? \"visible\":\"hidden\"}}" + } + } + } + ] + }, + "commitIntervalMs": { + "type": "number", + "title": "${commitIntervalMs}", + "x-decorator": "FormItem", + "x-component": "InputNumber", + "x-component-props": { + "min": 0, + "max": 300000, + "placeholder": "${commitIntervalMs_placeholder}" + }, + "default": 30000, + "x-index": 80, + "x-reactions": [ + { + "dependencies": ["$inputs"], + "fulfill": { + "state": { + "display": "{{$deps[0].length > 0 ? \"visible\":\"hidden\"}}" + } + } + } + ] + }, + "enableAsyncCommit": { + "type": "boolean", + "title": "${enableAsyncCommit}", + "x-decorator": "FormItem", + "x-component": "Switch", + "default": true, + "x-index": 90, + "x-reactions": [ + { + "dependencies": ["$inputs"], + "fulfill": { + "state": { + "display": "{{$deps[0].length > 0 ? \"visible\":\"hidden\"}}" + } + } + } + ] + }, + "writeThreads": { + "type": "number", + "title": "${writeThreads}", + "x-decorator": "FormItem", + "x-component": "InputNumber", + "x-component-props": { + "min": 1, + "max": 32, + "placeholder": "${writeThreads_placeholder}" + }, + "default": 4, + "x-index": 100, + "x-reactions": [ + { + "dependencies": ["$inputs"], + "fulfill": { + "state": { + "display": "{{$deps[0].length > 0 ? \"visible\":\"hidden\"}}" + } + } + } + ] + }, + "enableAutoCompaction": { + "type": "boolean", + "title": "${enableAutoCompaction}", + "x-decorator": "FormItem", + "x-component": "Switch", + "default": true, + "x-index": 110, + "x-perTable": true, + "x-reactions": [ + { + "dependencies": ["$inputs"], + "fulfill": { + "state": { + "display": "{{$deps[0].length > 0 ? \"visible\":\"hidden\"}}" + } + } + } + ] + }, + "compactionIntervalMinutes": { + "type": "number", + "title": "${compactionIntervalMinutes}", + "x-decorator": "FormItem", + "x-component": "InputNumber", + "x-component-props": { + "min": 1, + "max": 1440, + "placeholder": "${compactionIntervalMinutes_placeholder}" + }, + "default": 30, + "x-index": 120, + "x-perTable": true, + "x-reactions": [ + { + "dependencies": ["$inputs"], + "fulfill": { + "state": { + "display": "{{$deps[0].length > 0 ? \"visible\":\"hidden\"}}" + } + } + } + ] + }, + "targetFileSize": { + "type": "number", + "title": "${targetFileSize}", + "x-decorator": "FormItem", + "x-component": "InputNumber", + "x-component-props": { + "min": 32, + "max": 1024, + "placeholder": "${targetFileSize_placeholder}" + }, + "default": 128, + "x-index": 130, + "x-perTable": true, + "x-reactions": [ + { + "dependencies": ["$inputs"], + "fulfill": { + "state": { + "display": "{{$deps[0].length > 0 ? \"visible\":\"hidden\"}}" + } + } + } + ] + }, + "enablePrimaryKeyUpdate": { + "type": "boolean", + "title": "${enablePrimaryKeyUpdate}", + "x-decorator": "FormItem", + "x-component": "Switch", + "default": false, + "x-index": 140, + "x-perTable": true, + "x-decorator-props": { + "layout": "horizontal", + "tooltip": "${enablePrimaryKeyUpdate_description}" + }, + "x-reactions": [ + { + "dependencies": ["$inputs"], + "fulfill": { + "state": { + "display": "{{$deps[0].length > 0 ? \"visible\":\"hidden\"}}" + } + } + } + ] } } } @@ -241,6 +778,10 @@ "s3SecretKey": "S3 Secret Key", "s3Region": "S3 Region", "s3Region_placeholder": "e.g., us-east-1", + "s3Properties": "S3 Properties", + "prompt": "Add", + "propKey": "Property Name", + "propValue": "Property Value", "hdfsHost": "HDFS Host", "hdfsHost_placeholder": "e.g., namenode.example.com", "hdfsPort": "HDFS Port", @@ -251,11 +792,38 @@ "ossSecretKey": "OSS Secret Key", "database": "Database Name", "database_placeholder": "Paimon database name", + "hashKey": "Hash Key", + "hashKeyTip": "When enabled, if the primary key or update condition field is greater than 5, the hash field will be automatically added in the paimon table to improve write performance", + "partitionKey": "Partition Key", + "partitionKeyTip": "The partition key of the table, used to partition the data. If left empty, it means not to use the partitioning function.", "bucketMode": "Bucket Mode", "bucketMode_dynamic": "Dynamic (Recommended for general use)", "bucketMode_fixed": "Fixed (Better performance)", "bucketCount": "Bucket Count", - "bucketCount_placeholder": "Number of buckets for fixed mode (must be > 0)" + "bucketCount_placeholder": "Number of buckets for fixed mode (must be > 0)", + "fileFormat": "File Format", + "compression": "Compression Format", + "tableProperties": "Table Properties", + "tablePropertiesTip": "Table properties, e.g., {key1: value1, key2: value2}", + "writeBufferSize": "Write Buffer Size (MB)", + "writeBufferSize_placeholder": "Memory buffer for writes (64-2048 MB, default: 256)", + "diskOverflowWrite": "Data disk overflow write", + "diskMaxSize": "Disk overflow capacity (GB)", + "diskTmpDir": "Disk temporary directory", + "batchAccumulationSize": "Batch Accumulation Size", + "batchAccumulationSize_placeholder": "Records to accumulate before commit (0=immediate, default: 10000)", + "commitIntervalMs": "Commit Interval (ms)", + "commitIntervalMs_placeholder": "Time interval for commits in milliseconds (default: 30000)", + "enableAsyncCommit": "Enable Async Commit", + "writeThreads": "Write Threads", + "writeThreads_placeholder": "Number of parallel write threads (1-32, default: 4)", + "enableAutoCompaction": "Enable Auto Compaction", + "compactionIntervalMinutes": "Compaction Interval (minutes)", + "compactionIntervalMinutes_placeholder": "Interval for auto compaction (1-1440, default: 30)", + "targetFileSize": "Target File Size (MB)", + "targetFileSize_placeholder": "Target size for data files (32-1024 MB, default: 128)", + "enablePrimaryKeyUpdate": "Enable Primary Key Update Detection", + "enablePrimaryKeyUpdate_description": "When enabled, automatically detects primary key changes and converts update operations to delete+insert. Requires source database to provide before-update data. If unavailable, enabling this feature will cause errors. It is recommended to keep this feature disabled by default and only enable it when primary key values are expected to change. Enabling this feature will reduce update performance." }, "zh_CN": { "doc": "docs/paimon_zh_CN.md", @@ -268,6 +836,10 @@ "s3SecretKey": "S3 密钥", "s3Region": "S3 区域", "s3Region_placeholder": "例如:us-east-1", + "s3Properties": "S3 配置", + "prompt": "添加", + "propKey": "属性名", + "propValue": "属性值", "hdfsHost": "HDFS 主机", "hdfsHost_placeholder": "例如:namenode.example.com", "hdfsPort": "HDFS 端口", @@ -278,11 +850,38 @@ "ossSecretKey": "OSS 密钥", "database": "数据库名称", "database_placeholder": "Paimon 数据库名称", + "hashKey": "Hash键", + "hashKeyTip": "开关打开时,如果主键或更新条件字段大于5个时,会自动在paimon表中添加hash字段,用于提升写入性能", + "partitionKey": "分区键", + "partitionKeyTip": "表的分区键,用于对数据进行分区。如果置空,则表示不使用分区功能。", "bucketMode": "分桶模式", "bucketMode_dynamic": "动态模式(推荐通用场景)", "bucketMode_fixed": "固定模式(更好的性能)", "bucketCount": "分桶数量", - "bucketCount_placeholder": "固定模式下的桶数量(必须大于 0)" + "bucketCount_placeholder": "固定模式下的桶数量(必须大于 0)", + "fileFormat": "文件格式", + "compression": "压缩格式", + "tableProperties": "表属性", + "tablePropertiesTip": "表属性,例如:{key1: value1, key2: value2}", + "writeBufferSize": "写入缓冲区大小(MB)", + "writeBufferSize_placeholder": "写入内存缓冲区大小(64-2048 MB,默认:256)", + "diskOverflowWrite": "数据磁盘溢写", + "diskMaxSize": "磁盘溢写容量(GB)", + "diskTmpDir": "磁盘临时目录", + "batchAccumulationSize": "批量累积大小", + "batchAccumulationSize_placeholder": "提交前累积的记录数(0=立即提交,默认:10000)", + "commitIntervalMs": "提交间隔(毫秒)", + "commitIntervalMs_placeholder": "提交的时间间隔(毫秒,默认:30000)", + "enableAsyncCommit": "启用异步提交", + "writeThreads": "写入线程数", + "writeThreads_placeholder": "并行写入线程数(1-32,默认:4)", + "enableAutoCompaction": "启用自动压缩", + "compactionIntervalMinutes": "压缩间隔(分钟)", + "compactionIntervalMinutes_placeholder": "自动压缩的时间间隔(1-1440,默认:30)", + "targetFileSize": "目标文件大小(MB)", + "targetFileSize_placeholder": "数据文件的目标大小(32-1024 MB,默认:128)", + "enablePrimaryKeyUpdate": "是否更新主键", + "enablePrimaryKeyUpdate_description": "开启后,会自动检查主键变更,将更新操作改为删除+写入,要求源库可以提供更新前数据,如果无法提供,开启此功能会报错退出。建议默认关闭该功能,如果确认主键值会变更再开启,开启后,更新性能会下降。" }, "zh_TW": { "doc": "docs/paimon_zh_TW.md", @@ -295,6 +894,10 @@ "s3SecretKey": "S3 密鑰", "s3Region": "S3 區域", "s3Region_placeholder": "例如:us-east-1", + "s3Properties": "S3 配置", + "prompt": "添加", + "propKey": "屬性名", + "propValue": "屬性值", "hdfsHost": "HDFS 主機", "hdfsHost_placeholder": "例如:namenode.example.com", "hdfsPort": "HDFS 端口", @@ -305,11 +908,38 @@ "ossSecretKey": "OSS 密鑰", "database": "數據庫名稱", "database_placeholder": "Paimon 數據庫名稱", + "hashKey": "Hash 鍵", + "hashKeyTip": "開啟後,如果主鍵或更新條件字段大於5個時,會自動在paimon表中添加hash字段,用於提升寫入性能", + "partitionKey": "分區鍵", + "partitionKeyTip": "表的分區鍵,用於對數據進行分區。如果置空,則表示不使用分區功能。", "bucketMode": "分桶模式", "bucketMode_dynamic": "動態模式(推薦通用場景)", "bucketMode_fixed": "固定模式(更好的性能)", "bucketCount": "分桶數量", - "bucketCount_placeholder": "固定模式下的桶數量(必須大於 0)" + "bucketCount_placeholder": "固定模式下的桶數量(必須大於 0)", + "fileFormat": "文件格式", + "compression": "壓縮格式", + "tableProperties": "表屬性", + "tablePropertiesTip": "表屬性,例如:{key1: value1, key2: value2}", + "writeBufferSize": "寫入緩衝區大小(MB)", + "writeBufferSize_placeholder": "寫入內存緩衝區大小(64-2048 MB,默認:256)", + "diskOverflowWrite": "數據磁盤溢寫", + "diskMaxSize": "磁盤溢寫容量(GB)", + "diskTmpDir": "磁盤臨時目錄", + "batchAccumulationSize": "批量累積大小", + "batchAccumulationSize_placeholder": "提交前累積的記錄數(0=立即提交,默認:10000)", + "commitIntervalMs": "提交間隔(毫秒)", + "commitIntervalMs_placeholder": "提交的時間間隔(毫秒,默認:30000)", + "enableAsyncCommit": "啟用異步提交", + "writeThreads": "寫入線程數", + "writeThreads_placeholder": "並行寫入線程數(1-32,默認:4)", + "enableAutoCompaction": "啟用自動壓縮", + "compactionIntervalMinutes": "壓縮間隔(分鐘)", + "compactionIntervalMinutes_placeholder": "自動壓縮的時間間隔(1-1440,默認:30)", + "targetFileSize": "目標文件大小(MB)", + "targetFileSize_placeholder": "數據文件的目標大小(32-1024 MB,默認:128)", + "enablePrimaryKeyUpdate": "是否更新主鍵", + "enablePrimaryKeyUpdate_description": "開啟後,會自動檢查主鍵變更,將更新操作改為刪除+寫入,要求源庫可以提供更新前數據,如果無法提供,開啟此功能會報錯退出。建議默認關閉該功能,如果確認主鍵值會變更再開啟,開啟後,更新性能會下降。" } }, "dataTypes": { @@ -334,6 +964,12 @@ "precision": 10, "value": [-2147483648, 2147483647] }, + "INTEGER": { + "to": "TapNumber", + "bit": 32, + "precision": 10, + "value": [-2147483648, 2147483647] + }, "BIGINT": { "to": "TapNumber", "bit": 64, @@ -377,9 +1013,14 @@ }, "BINARY[($byte)]": { "to": "TapBinary", - "byte": "2147483647" + "byte": "255", + "fixed": true }, "VARBINARY[($byte)]": { + "to": "TapBinary", + "byte": "65535" + }, + "BYTES": { "to": "TapBinary", "byte": "2147483647" }, @@ -388,17 +1029,30 @@ "range": ["0001-01-01", "9999-12-31"], "pattern": "yyyy-MM-dd" }, + "TIME[($fraction)]": { + "to": "TapTime", + "fraction": [ + 0, + 6 + ], + "defaultFraction": 6, + "range": [ + "00:00:00", + "23:59:59" + ], + "pattern": "HH:mm:ss" + }, "TIMESTAMP[($fraction)]": { "to": "TapDateTime", - "range": ["1970-01-01 00:00:00.000000", "2038-01-19 03:14:07.999999"], - "pattern": "yyyy-MM-dd HH:mm:ss.SSSSSS", + "range": ["1970-01-01 00:00:00", "2038-01-19 03:14:07"], + "pattern": "yyyy-MM-dd HH:mm:ss", "fraction": [0, 6], "defaultFraction": 6 }, - "TIMESTAMP_LTZ[($fraction)]": { + "TIMESTAMP[($fraction)] WITH LOCAL TIME ZONE": { "to": "TapDateTime", - "range": ["1970-01-01 00:00:00.000000", "2038-01-19 03:14:07.999999"], - "pattern": "yyyy-MM-dd HH:mm:ss.SSSSSS", + "range": ["1970-01-01 00:00:01", "2038-01-19 03:14:07"], + "pattern": "yyyy-MM-dd HH:mm:ss", "fraction": [0, 6], "defaultFraction": 6, "withTimeZone": true diff --git a/connectors/paimon-connector/src/test/java/io/tapdata/connector/paimon/PaimonConfigTest.java b/connectors/paimon-connector/src/test/java/io/tapdata/connector/paimon/PaimonConfigTest.java index 3b36c4819..3b8ad4aeb 100644 --- a/connectors/paimon-connector/src/test/java/io/tapdata/connector/paimon/PaimonConfigTest.java +++ b/connectors/paimon-connector/src/test/java/io/tapdata/connector/paimon/PaimonConfigTest.java @@ -27,7 +27,7 @@ public void testS3StorageWarehousePath() { config.setStorageType("s3"); String fullPath = config.getFullWarehousePath(); - assertEquals("s3://bucket/warehouse", fullPath); + assertEquals("s3a://bucket/warehouse", fullPath); } @Test diff --git a/connectors/paimon-connector/src/test/java/io/tapdata/connector/paimon/PaimonPerformanceTest.java b/connectors/paimon-connector/src/test/java/io/tapdata/connector/paimon/PaimonPerformanceTest.java new file mode 100644 index 000000000..a09f3e184 --- /dev/null +++ b/connectors/paimon-connector/src/test/java/io/tapdata/connector/paimon/PaimonPerformanceTest.java @@ -0,0 +1,593 @@ +package io.tapdata.connector.paimon; + +import org.apache.hadoop.conf.Configuration; +import org.apache.paimon.catalog.Catalog; +import org.apache.paimon.catalog.CatalogContext; +import org.apache.paimon.catalog.CatalogFactory; +import org.apache.paimon.catalog.Identifier; +import org.apache.paimon.data.GenericRow; +import org.apache.paimon.data.Timestamp; +import org.apache.paimon.options.Options; +import org.apache.paimon.schema.Schema; +import org.apache.paimon.table.Table; +import org.apache.paimon.table.sink.CommitMessage; +import org.apache.paimon.table.sink.StreamTableCommit; +import org.apache.paimon.table.sink.StreamTableWrite; +import org.apache.paimon.table.sink.StreamWriteBuilder; +import org.apache.paimon.types.DataTypes; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.text.DecimalFormat; +import java.util.ArrayList; +import java.util.List; +import java.util.Random; +import java.util.concurrent.*; +import java.util.concurrent.atomic.AtomicLong; + +/** + * 高性能写入测试 - 使用最原生的 Paimon API + 8线程并发向 S3 写入 1000 万条数据 + *

+ * 特点: + * - 直接使用 Paimon 原生 API,无任何中间层 + * - 8 线程并发写入,最高性能 + * - 每个线程独立的 Writer,共享 Commit + * - 10 个字段(id, name, age, email, phone, address, city, country, created_at, updated_at) + * - 1000 万条数据 + * - 批量提交优化 + *

+ * 使用前请配置 S3 连接信息 + */ +public class PaimonPerformanceTest { + + // ===== S3 配置 - 请根据实际情况修改 ===== + private static final String S3_ENDPOINT = "http://127.0.0.1:9000"; + private static final String S3_ACCESS_KEY = "19CAkIyPfUcHRTd8"; + private static final String S3_SECRET_KEY = "xa8HcHPei6cA0LvZF6kh3yokDjZiU5Vc"; + private static final String S3_REGION = ""; + private static final String S3_BUCKET = "jarad"; + + // ===== 测试配置 ===== + private static final int TOTAL_RECORDS = 10_000_000; // 1000 万条数据 + private static final int WRITE_THREADS = 8; // 8 个写入线程 + private static final int COMMIT_BATCH_SIZE = 100000; // 每 100000 条提交一次 + private static final String DATABASE_NAME = "default"; + private static final String TABLE_NAME = "user_data"; + + // Paimon 原生对象 + private Catalog catalog; + private List writers; // 每个线程一个 Writer + private StreamTableCommit commit; + private AtomicLong commitIdentifier = new AtomicLong(0); + + // 线程池 + private ExecutorService executorService; + + @BeforeEach + public void setUp() throws Exception { + System.out.println("========================================"); + System.out.println("Paimon 原生 API + 8线程并发写入测试"); + System.out.println("========================================"); + System.out.println("目标: 写入 " + formatNumber(TOTAL_RECORDS) + " 条数据到 S3"); + System.out.println("写入线程: " + WRITE_THREADS + " 个"); + System.out.println("提交批次: " + COMMIT_BATCH_SIZE + " 条/提交"); + System.out.println("========================================\n"); + + // 初始化 Paimon Catalog + System.out.println("初始化 Paimon Catalog..."); + initCatalog(); + + // 创建表 + System.out.println("创建表: " + DATABASE_NAME + "." + TABLE_NAME); + createTable(); + System.out.println("表创建成功\n"); + + // 初始化 Writers 和 Commit + System.out.println("初始化 " + WRITE_THREADS + " 个 Writers 和 Commit..."); + initWritersAndCommit(); + System.out.println("初始化完成\n"); + + // 创建线程池 + executorService = Executors.newFixedThreadPool(WRITE_THREADS); + } + + @AfterEach + public void tearDown() throws Exception { + System.out.println("\n正在关闭资源..."); + + // 关闭线程池 + if (executorService != null) { + executorService.shutdown(); + try { + if (!executorService.awaitTermination(60, TimeUnit.SECONDS)) { + executorService.shutdownNow(); + } + } catch (InterruptedException e) { + executorService.shutdownNow(); + } + } + + // 关闭所有 Writers + if (writers != null) { + for (StreamTableWrite writer : writers) { + try { + writer.close(); + } catch (Exception e) { + // Ignore + } + } + } + + if (commit != null) { + try { + commit.close(); + } catch (Exception e) { + // Ignore + } + } + + if (catalog != null) { + try { + catalog.close(); + } catch (Exception e) { + // Ignore + } + } + + System.out.println("资源已关闭"); + } + + /** + * 初始化 Paimon Catalog + */ + private void initCatalog() throws Exception { + // 创建 Catalog Options + Options options = new Options(); + options.set("warehouse", "s3a://" + S3_BUCKET + "/paimon-warehouse"); + + // 创建 Hadoop Configuration(S3 配置) + Configuration hadoopConf = new Configuration(); + + // S3A 基本配置 + hadoopConf.set("fs.s3a.endpoint", S3_ENDPOINT); + hadoopConf.set("fs.s3a.access.key", S3_ACCESS_KEY); + hadoopConf.set("fs.s3a.secret.key", S3_SECRET_KEY); + hadoopConf.set("fs.s3a.endpoint.region", S3_REGION); + hadoopConf.set("fs.s3a.path.style.access", "false"); + + // ===== S3A 性能优化配置 ===== + // 连接池优化 + hadoopConf.set("fs.s3a.connection.maximum", "100"); + hadoopConf.set("fs.s3a.threads.max", "50"); + hadoopConf.set("fs.s3a.connection.establish.timeout", "10000"); + hadoopConf.set("fs.s3a.connection.timeout", "200000"); + + // 快速上传配置 + hadoopConf.set("fs.s3a.fast.upload", "true"); + hadoopConf.set("fs.s3a.fast.upload.buffer", "disk"); + hadoopConf.set("fs.s3a.multipart.size", "268435456"); // 256 MB + hadoopConf.set("fs.s3a.multipart.threshold", "134217728"); // 128 MB + + // 减少 HeadObject 调用 + hadoopConf.set("fs.s3a.directory.marker.retention", "keep"); + hadoopConf.set("fs.s3a.change.detection.mode", "none"); + + // 重试策略 + hadoopConf.set("fs.s3a.attempts.maximum", "10"); + hadoopConf.set("fs.s3a.retry.limit", "5"); + + // 创建 Catalog Context + CatalogContext context = CatalogContext.create(options, hadoopConf); + + // 创建 Catalog + catalog = CatalogFactory.createCatalog(context); + + // 确保数据库存在 + try { + catalog.getDatabase(DATABASE_NAME); + } catch (Catalog.DatabaseNotExistException e) { + catalog.createDatabase(DATABASE_NAME, true); + } + } + + /** + * 创建表 + */ + private void createTable() throws Exception { + Identifier identifier = Identifier.create(DATABASE_NAME, TABLE_NAME); + + // 检查表是否已存在 + try { + catalog.getTable(identifier); + System.out.println("表已存在,将使用现有表"); + return; + } catch (Catalog.TableNotExistException e) { + // 表不存在,创建新表 + } + + // 创建 Schema Builder + Schema.Builder schemaBuilder = Schema.newBuilder(); + + // 添加字段 + schemaBuilder.column("id", DataTypes.BIGINT(), "主键ID"); + schemaBuilder.column("name", DataTypes.STRING(), "用户名"); + schemaBuilder.column("age", DataTypes.INT(), "年龄"); + schemaBuilder.column("email", DataTypes.STRING(), "邮箱"); + schemaBuilder.column("phone", DataTypes.STRING(), "电话"); + schemaBuilder.column("address", DataTypes.STRING(), "地址"); + schemaBuilder.column("city", DataTypes.STRING(), "城市"); + schemaBuilder.column("country", DataTypes.STRING(), "国家"); + schemaBuilder.column("created_at", DataTypes.TIMESTAMP(3), "创建时间"); + schemaBuilder.column("updated_at", DataTypes.TIMESTAMP(3), "更新时间"); + + // 设置主键 + schemaBuilder.primaryKey("id"); + + // ===== 性能优化选项 ===== + // 写入缓冲区:1GB + schemaBuilder.option("write-buffer-size", "1024mb"); + + // 目标文件大小:512MB + schemaBuilder.option("target-file-size", "512mb"); + + // 文件格式:Parquet + schemaBuilder.option("file.format", "parquet"); + + // 压缩格式:ZSTD + schemaBuilder.option("file.compression", "zstd"); + + // 分桶模式:动态分桶(-1 表示动态) + schemaBuilder.option("bucket", "8"); + + // 快照保留策略 + schemaBuilder.option("snapshot.num-retained.min", "10"); + schemaBuilder.option("snapshot.num-retained.max", "100"); + schemaBuilder.option("snapshot.time-retained", "1h"); + + // 禁用自动压缩(写入阶段) + schemaBuilder.option("compaction.min.file-num", "999999"); + schemaBuilder.option("compaction.max.file-num", "999999"); + + // 并行度 + schemaBuilder.option("sink.parallelism", "16"); + schemaBuilder.option("write-parallelism", "8"); + schemaBuilder.option("write-buffer-spillable", "true"); + schemaBuilder.option("write-buffer-for-append", "true"); + schemaBuilder.option("write-buffer-spill-threshold", "64mb"); + schemaBuilder.option("commit.async", "true"); + schemaBuilder.option("commit.interval", "5s"); + schemaBuilder.option("commit.timeout", "2m"); + schemaBuilder.option("s3.upload.max-concurrency", "50"); + schemaBuilder.option("s3.upload.part-size", "16mb"); + schemaBuilder.option("s3.fast-upload", "true"); + +// +// +// // 小文件合并 +// " 'auto-merge' = 'true',\n" + +// " 'merge-engine' = 'deduplicate',\n" + +// " 'merge.max-file-size' = '256mb',\n" + +// " 'merge.min-file-size' = '16mb',\n" + +// " 'merge.trigger.interval' = '10m',\n" + +// +// +// +// // S3优化 +// " 's3.upload.max-concurrency' = '50',\n" + +// " 's3.upload.part-size' = '16mb',\n" + +// " 's3.fast-upload' = 'true'\n" + + + // 创建表 + catalog.createTable(identifier, schemaBuilder.build(), false); + } + + /** + * 初始化多个 Writers 和 Commit + */ + private void initWritersAndCommit() throws Exception { + Identifier identifier = Identifier.create(DATABASE_NAME, TABLE_NAME); + Table table = catalog.getTable(identifier); + + // 创建 StreamWriteBuilder + StreamWriteBuilder writeBuilder = table.newStreamWriteBuilder(); + + // 为每个线程创建一个 Writer + writers = new ArrayList<>(WRITE_THREADS); + for (int i = 0; i < WRITE_THREADS; i++) { + writers.add(writeBuilder.newWrite()); + } + + // 创建共享的 Commit + commit = writeBuilder.newCommit(); + } + + + /** + * 主测试方法:使用 8 线程并发写入 1000 万条数据 + */ + public void testWrite10MillionRecords() throws Exception { + System.out.println("开始 8 线程并发写入测试...\n"); + + long startTime = System.currentTimeMillis(); + + // 全局统计 + AtomicLong totalWritten = new AtomicLong(0); + AtomicLong lastReportTime = new AtomicLong(startTime); + AtomicLong lastReportCount = new AtomicLong(0); + + // 每个线程处理的记录数 + int recordsPerThread = TOTAL_RECORDS / WRITE_THREADS; + + // 用于收集所有线程的 CommitMessage + ConcurrentLinkedQueue allMessages = new ConcurrentLinkedQueue<>(); + + // 用于同步提交 + Object commitLock = new Object(); + AtomicLong accumulatedRecords = new AtomicLong(0); + + // 创建写入任务 + List> futures = new ArrayList<>(); + + for (int threadId = 0; threadId < WRITE_THREADS; threadId++) { + final int tid = threadId; + final long startRecordId = (long) tid * recordsPerThread; + final long endRecordId = (tid == WRITE_THREADS - 1) ? TOTAL_RECORDS : (tid + 1) * recordsPerThread; + final StreamTableWrite threadWriter = writers.get(tid); + + Future future = executorService.submit(() -> { + try { + Random random = new Random(tid); // 每个线程独立的随机数生成器 + List localMessages = new ArrayList<>(); + + for (long recordId = startRecordId; recordId < endRecordId; recordId++) { + // 创建 GenericRow + GenericRow row = createRow(recordId, random); + + // 写入数据(使用线程 ID 作为 bucket) + threadWriter.write(row, tid); + + long written = totalWritten.incrementAndGet(); + long accumulated = accumulatedRecords.incrementAndGet(); + + // 每 COMMIT_BATCH_SIZE 条记录提交一次 + if (accumulated >= COMMIT_BATCH_SIZE) { + synchronized (commitLock) { + // 再次检查,避免重复提交 + if (accumulatedRecords.get() >= COMMIT_BATCH_SIZE) { + commitAllWriters(); + accumulatedRecords.set(0); + } + } + } + + // 每 10 秒或每 100000 条记录报告一次进度(只由线程 0 报告) + if (tid == 0) { + long currentTime = System.currentTimeMillis(); + if (written % 100000 == 0 || (currentTime - lastReportTime.get()) >= 10000) { + long timeDiff = currentTime - lastReportTime.get(); + long countDiff = written - lastReportCount.get(); + + if (timeDiff > 0) { + double progress = (double) written / TOTAL_RECORDS * 100; + double currentThroughput = (countDiff * 1000.0 / timeDiff); + double overallThroughput = (written * 1000.0 / (currentTime - startTime)); + + System.out.printf("进度: %.2f%% | 已写入: %s 条 | 当前吞吐: %s 条/秒 | 平均吞吐: %s 条/秒\n", + progress, + formatNumber(written), + formatNumber((long) currentThroughput), + formatNumber((long) overallThroughput)); + + lastReportTime.set(currentTime); + lastReportCount.set(written); + } + } + } + } + + } catch (Exception e) { + System.err.println("线程 " + tid + " 写入失败: " + e.getMessage()); + e.printStackTrace(); + throw new RuntimeException(e); + } + }); + + futures.add(future); + } + + // 等待所有线程完成 + System.out.println("等待所有写入线程完成..."); + for (Future future : futures) { + try { + future.get(); + } catch (Exception e) { + System.err.println("线程执行失败: " + e.getMessage()); + e.printStackTrace(); + } + } + + // 提交剩余数据 + if (accumulatedRecords.get() > 0) { + System.out.println("\n正在提交剩余数据..."); + commitAllWriters(); + } + + long endTime = System.currentTimeMillis(); + long totalTime = endTime - startTime; + + // 输出最终统计 + printFinalStatistics(totalWritten.get(), totalTime); + } + + /** + * 创建一行数据 + */ + private GenericRow createRow(long id, Random random) { + GenericRow row = new GenericRow(10); + + // 字段 0: id + row.setField(0, id); + + // 字段 1: name + row.setField(1, org.apache.paimon.data.BinaryString.fromString("User_" + id)); + + // 字段 2: age + row.setField(2, 18 + random.nextInt(60)); + + // 字段 3: email + row.setField(3, org.apache.paimon.data.BinaryString.fromString("user" + id + "@example.com")); + + // 字段 4: phone + row.setField(4, org.apache.paimon.data.BinaryString.fromString(generatePhone(random))); + + // 字段 5: address + row.setField(5, org.apache.paimon.data.BinaryString.fromString(generateAddress(random))); + + // 字段 6: city + row.setField(6, org.apache.paimon.data.BinaryString.fromString(generateCity(random))); + + // 字段 7: country + row.setField(7, org.apache.paimon.data.BinaryString.fromString(generateCountry(random))); + + // 字段 8: created_at + row.setField(8, Timestamp.fromEpochMillis(System.currentTimeMillis())); + + // 字段 9: updated_at + row.setField(9, Timestamp.fromEpochMillis(System.currentTimeMillis())); + + return row; + } + + /** + * 提交所有 Writers 的数据 + */ + private void commitAllWriters() throws Exception { + long commitId = commitIdentifier.incrementAndGet(); + + // 收集所有 Writers 的 CommitMessage + List allMessages = new ArrayList<>(); + for (StreamTableWrite writer : writers) { + List messages = writer.prepareCommit(false, commitId); + allMessages.addAll(messages); + } + + // 统一提交 + commit.commit(commitId, allMessages); + } + + /** + * 生成随机电话号码 + */ + private String generatePhone(Random random) { + return String.format("+1-%03d-%03d-%04d", + random.nextInt(1000), + random.nextInt(1000), + random.nextInt(10000)); + } + + /** + * 生成随机地址 + */ + private String generateAddress(Random random) { + String[] streets = {"Main St", "Oak Ave", "Maple Dr", "Pine Rd", "Cedar Ln"}; + return random.nextInt(9999) + " " + streets[random.nextInt(streets.length)]; + } + + /** + * 生成随机城市 + */ + private String generateCity(Random random) { + String[] cities = {"New York", "Los Angeles", "Chicago", "Houston", "Phoenix", + "Philadelphia", "San Antonio", "San Diego", "Dallas", "San Jose"}; + return cities[random.nextInt(cities.length)]; + } + + /** + * 生成随机国家 + */ + private String generateCountry(Random random) { + String[] countries = {"USA", "Canada", "UK", "Germany", "France", "Japan", "China", "Australia"}; + return countries[random.nextInt(countries.length)]; + } + + /** + * 打印最终统计信息 + */ + private void printFinalStatistics(long totalRecords, long totalTimeMs) { + System.out.println("\n========================================"); + System.out.println("写入测试完成!"); + System.out.println("========================================"); + System.out.println("总记录数: " + formatNumber(totalRecords)); + System.out.println("写入线程数: " + WRITE_THREADS); + System.out.println("总耗时: " + formatTime(totalTimeMs)); + + double throughput = totalTimeMs > 0 ? (totalRecords * 1000.0 / totalTimeMs) : 0; + System.out.println("平均吞吐量: " + formatNumber((long) throughput) + " 条/秒"); + + double perThreadThroughput = throughput / WRITE_THREADS; + System.out.println("单线程吞吐量: " + formatNumber((long) perThreadThroughput) + " 条/秒"); + + long totalCommits = (totalRecords + COMMIT_BATCH_SIZE - 1) / COMMIT_BATCH_SIZE; + System.out.println("总提交次数: " + formatNumber(totalCommits)); + + double avgCommitTime = totalCommits > 0 ? (totalTimeMs / (double) totalCommits) : 0; + System.out.println("平均提交耗时: " + String.format("%.2f", avgCommitTime) + " 毫秒"); + + // 估算数据大小(每条记录约 200 字节) + long estimatedDataSize = totalRecords * 200; + System.out.println("估算数据大小: " + formatBytes(estimatedDataSize)); + + double dataThroughput = totalTimeMs > 0 ? (estimatedDataSize * 1000.0 / totalTimeMs) : 0; + System.out.println("数据吞吐量: " + formatBytes((long) dataThroughput) + "/秒"); + + System.out.println("\n性能提示:"); + System.out.println("- 使用原生 Paimon API,无中间层开销"); + System.out.println("- " + WRITE_THREADS + " 线程并发写入,充分利用多核 CPU"); + System.out.println("- 批量提交优化(每 " + formatNumber(COMMIT_BATCH_SIZE) + " 条提交一次)"); + System.out.println("- S3A 性能优化已启用"); + System.out.println("- 建议在写入完成后手动执行压缩以优化查询性能"); + + System.out.println("========================================\n"); + } + + /** + * 格式化数字(添加千分位分隔符) + */ + private String formatNumber(long number) { + DecimalFormat formatter = new DecimalFormat("#,###"); + return formatter.format(number); + } + + /** + * 格式化时间 + */ + private String formatTime(long milliseconds) { + long seconds = milliseconds / 1000; + long minutes = seconds / 60; + long hours = minutes / 60; + + if (hours > 0) { + return String.format("%d 小时 %d 分钟 %d 秒", hours, minutes % 60, seconds % 60); + } else if (minutes > 0) { + return String.format("%d 分钟 %d 秒", minutes, seconds % 60); + } else { + return String.format("%d 秒", seconds); + } + } + + /** + * 格式化字节大小 + */ + private String formatBytes(long bytes) { + if (bytes < 1024) { + return bytes + " B"; + } else if (bytes < 1024 * 1024) { + return String.format("%.2f KB", bytes / 1024.0); + } else if (bytes < 1024 * 1024 * 1024) { + return String.format("%.2f MB", bytes / (1024.0 * 1024)); + } else { + return String.format("%.2f GB", bytes / (1024.0 * 1024 * 1024)); + } + } +} diff --git a/connectors/paimon-connector/src/test/java/io/tapdata/connector/paimon/TransactionPerformanceTest.java b/connectors/paimon-connector/src/test/java/io/tapdata/connector/paimon/TransactionPerformanceTest.java new file mode 100644 index 000000000..e210afe81 --- /dev/null +++ b/connectors/paimon-connector/src/test/java/io/tapdata/connector/paimon/TransactionPerformanceTest.java @@ -0,0 +1,713 @@ +//package io.tapdata.connector.paimon; +// +//import org.apache.hadoop.conf.Configuration; +//import org.apache.paimon.catalog.Catalog; +//import org.apache.paimon.catalog.CatalogContext; +//import org.apache.paimon.catalog.CatalogFactory; +//import org.apache.paimon.catalog.Identifier; +//import org.apache.paimon.data.BinaryString; +//import org.apache.paimon.data.Decimal; +//import org.apache.paimon.data.GenericRow; +//import org.apache.paimon.data.Timestamp; +//import org.apache.paimon.options.Options; +//import org.apache.paimon.schema.Schema; +//import org.apache.paimon.table.Table; +//import org.apache.paimon.table.sink.CommitMessage; +//import org.apache.paimon.table.sink.StreamTableCommit; +//import org.apache.paimon.table.sink.StreamTableWrite; +//import org.apache.paimon.table.sink.StreamWriteBuilder; +//import org.apache.paimon.types.DataTypes; +//import org.junit.jupiter.api.AfterEach; +//import org.junit.jupiter.api.BeforeEach; +//import org.junit.jupiter.api.Test; +// +//import java.math.BigDecimal; +//import java.text.DecimalFormat; +//import java.util.ArrayList; +//import java.util.List; +//import java.util.Random; +//import java.util.UUID; +//import java.util.concurrent.*; +//import java.util.concurrent.atomic.AtomicLong; +// +///** +// * Paimon 1.3.1 性能测试 - 交易明细表(29 字段,单条 1KB) +// * +// * 特点: +// * - 基于真实业务表结构(TransactionDetails) +// * - 29 个字段,单条记录约 1KB +// * - 主键表(Id 为主键) +// * - 支持分区测试(天/月/年) +// * - 8 线程并发写入 +// * - 本地文件系统存储 +// * +// * @author Tapdata Connectors Team +// * @version 2.0 (Paimon 1.3.1) +// */ +//public class TransactionPerformanceTest { +// +// // ===== 仓库路径配置 ===== +// private static final String WAREHOUSE_PATH = "/Users/your-username/workspace/tapdata-connectors/test-output/paimon-warehouse"; +// +// // ===== 测试配置 ===== +// private static final int BENCHMARK_RECORDS = 100_000; // 基准测试:10 万条 +// private static final int PRESSURE_RECORDS = 1_000_000; // 压力测试:100 万条 +// private static final int WRITE_THREADS = 8; // 8 个写入线程 +// private static final int COMMIT_BATCH_SIZE = 10000; // 每 1 万条提交一次 +// private static final String DATABASE_NAME = "ods_ewallet"; +// +// // Paimon 原生对象 +// private Catalog catalog; +// private List writers; +// private StreamTableCommit commit; +// private AtomicLong commitIdentifier = new AtomicLong(0); +// +// // 线程池 +// private ExecutorService executorService; +// +// // 随机数生成器 +// private final Random random = new Random(); +// +// // 数据常量 +// private static final String[] TRANSACTION_TYPES = {"PAYMENT", "REFUND", "TRANSFER", "WITHDRAWAL", "DEPOSIT"}; +// private static final String[] CHANNELS = {"MOBILE_APP", "WEB", "POS", "ATM", "COUNTER"}; +// private static final String[] CURRENCIES = {"SGD", "USD", "EUR", "MYR", "THB"}; +// private static final String[] OUTLETS = {"Marina Bay Sands", "Sentosa Resort", "Changi Airport", "Orchard Road", "Bugis Junction"}; +// private static final String[] PROPERTIES = {"CASINO", "HOTEL", "RESTAURANT", "RETAIL", "ENTERTAINMENT"}; +// +// @BeforeEach +// public void setUp() throws Exception { +// System.out.println("========================================"); +// System.out.println("Paimon 1.3.1 交易明细表性能测试"); +// System.out.println("========================================"); +// System.out.println("仓库路径:" + WAREHOUSE_PATH); +// System.out.println("数据库:" + DATABASE_NAME); +// System.out.println("========================================\n"); +// +// // 初始化 Paimon Catalog +// System.out.println("初始化 Paimon Catalog..."); +// initCatalog(); +// +// // 创建线程池 +// executorService = Executors.newFixedThreadPool(WRITE_THREADS); +// } +// +// @AfterEach +// public void tearDown() throws Exception { +// System.out.println("\n正在关闭资源..."); +// +// // 关闭线程池 +// if (executorService != null) { +// executorService.shutdown(); +// try { +// if (!executorService.awaitTermination(60, TimeUnit.SECONDS)) { +// executorService.shutdownNow(); +// } +// } catch (InterruptedException e) { +// executorService.shutdownNow(); +// } +// } +// +// // 关闭所有 Writers +// if (writers != null) { +// for (StreamTableWrite writer : writers) { +// try { +// writer.close(); +// } catch (Exception e) { +// // Ignore +// } +// } +// } +// +// if (commit != null) { +// try { +// commit.close(); +// } catch (Exception e) { +// // Ignore +// } +// } +// +// if (catalog != null) { +// try { +// catalog.close(); +// } catch (Exception e) { +// // Ignore +// } +// } +// +// System.out.println("资源已关闭"); +// } +// +// /** +// * 初始化 Paimon Catalog +// */ +// private void initCatalog() throws Exception { +// Options options = new Options(); +// options.set("warehouse", "file://" + WAREHOUSE_PATH); +// +// Configuration hadoopConf = new Configuration(); +// CatalogContext context = CatalogContext.create(options, hadoopConf); +// catalog = CatalogFactory.createCatalog(context); +// +// // 确保数据库存在 +// try { +// catalog.getDatabase(DATABASE_NAME); +// } catch (Catalog.DatabaseNotExistException e) { +// catalog.createDatabase(DATABASE_NAME, true); +// } +// } +// +// /** +// * 创建按天分区表 +// */ +// protected void createDailyPartitionTable(String tableName) throws Exception { +// Identifier identifier = Identifier.create(DATABASE_NAME, tableName); +// +// // 检查表是否已存在 +// try { +// catalog.getTable(identifier); +// System.out.println("表已存在:" + tableName); +// return; +// } catch (Catalog.TableNotExistException e) { +// // 表不存在,创建新表 +// } +// +// Schema.Builder schemaBuilder = createSchemaBuilder(); +// +// // 按天分区配置 +// schemaBuilder.partitionKeys("pt_created_date"); +// +// // 性能优化参数 +// schemaBuilder.option("bucket", "-1") // 动态分桶 +// .option("target-file-size", "256mb") // 目标文件大小 +// .option("write-buffer-size", "512mb") // 写入缓冲区 +// .option("sink.parallelism", "8") // 并发度 +// .option("compaction.async.enabled", "true") // 异步 Compaction +// .option("compaction.optimization-interval", "10min") +// .option("num-sorted-run.compaction-trigger", "4") +// .option("num-sorted-run.stop-trigger", "8") +// .option("changelog-producer", "input") +// .option("changelog-producer.lookup-wait", "false") +// .option("snapshot.num-retained.min", "5") +// .option("snapshot.num-retained.max", "500") +// .option("snapshot.time-retained", "7d") +// .option("commit.force-compact", "false") +// .option("file.format", "parquet") +// .option("file.compression", "zstd"); +// +// catalog.createTable(identifier, schemaBuilder.build(), false); +// System.out.println("创建按天分区表:" + tableName); +// } +// +// /** +// * 创建按月分区表 +// */ +// protected void createMonthlyPartitionTable(String tableName) throws Exception { +// Identifier identifier = Identifier.create(DATABASE_NAME, tableName); +// +// try { +// catalog.getTable(identifier); +// System.out.println("表已存在:" + tableName); +// return; +// } catch (Catalog.TableNotExistException e) { +// // 表不存在 +// } +// +// Schema.Builder schemaBuilder = createSchemaBuilder(); +// schemaBuilder.partitionKeys("pt_created_date"); +// +// // 按月分区配置 +// schemaBuilder.option("bucket", "-1") +// .option("target-file-size", "256mb") +// .option("write-buffer-size", "512mb") +// .option("sink.parallelism", "8") +// .option("compaction.async.enabled", "true") +// .option("compaction.optimization-interval", "10min") +// .option("num-sorted-run.compaction-trigger", "4") +// .option("num-sorted-run.stop-trigger", "8") +// .option("changelog-producer", "input") +// .option("changelog-producer.lookup-wait", "false") +// .option("snapshot.num-retained.min", "5") +// .option("snapshot.num-retained.max", "500") +// .option("snapshot.time-retained", "7d") +// .option("commit.force-compact", "false") +// .option("file.format", "parquet") +// .option("file.compression", "zstd"); +// +// catalog.createTable(identifier, schemaBuilder.build(), false); +// System.out.println("创建按月分区表:" + tableName); +// } +// +// /** +// * 创建按年分区表 +// */ +// protected void createYearlyPartitionTable(String tableName) throws Exception { +// Identifier identifier = Identifier.create(DATABASE_NAME, tableName); +// +// try { +// catalog.getTable(identifier); +// System.out.println("表已存在:" + tableName); +// return; +// } catch (Catalog.TableNotExistException e) { +// // 表不存在 +// } +// +// Schema.Builder schemaBuilder = createSchemaBuilder(); +// schemaBuilder.partitionKeys("pt_created_date"); +// +// // 按年分区配置 +// schemaBuilder.option("bucket", "-1") +// .option("target-file-size", "256mb") +// .option("write-buffer-size", "512mb") +// .option("sink.parallelism", "8") +// .option("compaction.async.enabled", "true") +// .option("compaction.optimization-interval", "10min") +// .option("num-sorted-run.compaction-trigger", "4") +// .option("num-sorted-run.stop-trigger", "8") +// .option("changelog-producer", "input") +// .option("changelog-producer.lookup-wait", "false") +// .option("snapshot.num-retained.min", "5") +// .option("snapshot.num-retained.max", "500") +// .option("snapshot.time-retained", "7d") +// .option("commit.force-compact", "false") +// .option("file.format", "parquet") +// .option("file.compression", "zstd"); +// +// catalog.createTable(identifier, schemaBuilder.build(), false); +// System.out.println("创建按年分区表:" + tableName); +// } +// +// /** +// * 创建 Schema Builder(29 个字段) +// */ +// protected Schema.Builder createSchemaBuilder() { +// Schema.Builder schemaBuilder = Schema.newBuilder(); +// +// // 添加 29 个字段 +// schemaBuilder.column("Id", DataTypes.VARCHAR(900), "主键 ID"); +// schemaBuilder.column("BalanceDetailId", DataTypes.VARCHAR(8000), "余额明细 ID"); +// schemaBuilder.column("BeforeDetailBalance", DataTypes.DECIMAL(18, 0), "明细前余额"); +// schemaBuilder.column("Amount", DataTypes.DECIMAL(18, 0), "交易金额"); +// schemaBuilder.column("ExpiryDate", DataTypes.TIMESTAMP(6), "过期时间"); +// schemaBuilder.column("ComporId", DataTypes.VARCHAR(10), "组件 ID"); +// schemaBuilder.column("TransactionType", DataTypes.VARCHAR(50), "交易类型"); +// schemaBuilder.column("Channel", DataTypes.VARCHAR(200), "渠道"); +// schemaBuilder.column("POSReference", DataTypes.VARCHAR(500), "POS 参考号"); +// schemaBuilder.column("Outlet", DataTypes.VARCHAR(500), "门店名称"); +// schemaBuilder.column("Remark", DataTypes.VARCHAR(500), "备注"); +// schemaBuilder.column("CreatedTime", DataTypes.TIMESTAMP(6), "创建时间"); +// schemaBuilder.column("PaymentDetailId", DataTypes.VARCHAR(8000), "支付明细 ID"); +// schemaBuilder.column("PaymentId", DataTypes.BIGINT(), "支付 ID"); +// schemaBuilder.column("CreatedBy", DataTypes.VARCHAR(100), "创建人"); +// schemaBuilder.column("op", DataTypes.VARCHAR(1024), "CDC 操作类型"); +// schemaBuilder.column("AfterDetailBalance", DataTypes.DECIMAL(18, 0), "明细后余额"); +// schemaBuilder.column("SourceSystem", DataTypes.VARCHAR(200), "源系统"); +// schemaBuilder.column("DollarTypeId", DataTypes.VARCHAR(50), "币种 ID"); +// schemaBuilder.column("ExceptionBalance", DataTypes.DECIMAL(18, 0), "异常余额"); +// schemaBuilder.column("PatronId", DataTypes.VARCHAR(80), "客户 ID"); +// schemaBuilder.column("SourceKey", DataTypes.VARCHAR(200), "源键值"); +// schemaBuilder.column("DeviceId", DataTypes.VARCHAR(50), "设备 ID"); +// schemaBuilder.column("AfterBalance", DataTypes.DECIMAL(18, 0), "交易后余额"); +// schemaBuilder.column("BeforeBalance", DataTypes.DECIMAL(18, 0), "交易前余额"); +// schemaBuilder.column("OutletCode", DataTypes.VARCHAR(15), "门店代码"); +// schemaBuilder.column("ods_updated_at", DataTypes.TIMESTAMP(3), "ODS 更新时间"); +// schemaBuilder.column("Property", DataTypes.VARCHAR(200), "物业"); +// schemaBuilder.column("DisplaySourceKey", DataTypes.VARCHAR(200), "显示源键"); +// schemaBuilder.column("SourceSystemExtraReference", DataTypes.VARCHAR(200), "源系统扩展参考"); +// schemaBuilder.column("SegmentCode", DataTypes.VARCHAR(40), "分段代码"); +// schemaBuilder.column("pt_created_date", DataTypes.INT(), "分区字段"); +// +// // 设置主键 +// schemaBuilder.primaryKey("Id"); +// +// return schemaBuilder; +// } +// +// /** +// * 初始化 Writers 和 Commit +// */ +// protected void initWritersAndCommit(String tableName) throws Exception { +// Identifier identifier = Identifier.create(DATABASE_NAME, tableName); +// Table table = catalog.getTable(identifier); +// +// StreamWriteBuilder writeBuilder = table.newStreamWriteBuilder(); +// +// writers = new ArrayList<>(WRITE_THREADS); +// for (int i = 0; i < WRITE_THREADS; i++) { +// writers.add(writeBuilder.newWrite()); +// } +// +// commit = writeBuilder.newCommit(); +// } +// +// /** +// * 测试按天分区写入性能 +// */ +// @Test +// public void testDailyPartitionWrite() throws Exception { +// String tableName = "TransactionDetails_day"; +// +// System.out.println("\n========================================"); +// System.out.println("测试:按天分区写入性能"); +// System.out.println("表名:" + tableName); +// System.out.println("数据量:" + PRESSURE_RECORDS + " 条"); +// System.out.println("========================================\n"); +// +// // 创建表 +// createDailyPartitionTable(tableName); +// +// // 初始化 Writers +// initWritersAndCommit(tableName); +// +// // 执行写入 +// writeRecords(tableName, PRESSURE_RECORDS); +// } +// +// /** +// * 测试按月分区写入性能 +// */ +// @Test +// public void testMonthlyPartitionWrite() throws Exception { +// String tableName = "TransactionDetails_month"; +// +// System.out.println("\n========================================"); +// System.out.println("测试:按月分区写入性能"); +// System.out.println("表名:" + tableName); +// System.out.println("数据量:" + PRESSURE_RECORDS + " 条"); +// System.out.println("========================================\n"); +// +// createMonthlyPartitionTable(tableName); +// initWritersAndCommit(tableName); +// writeRecords(tableName, PRESSURE_RECORDS); +// } +// +// /** +// * 测试按年分区写入性能 +// */ +// @Test +// public void testYearlyPartitionWrite() throws Exception { +// String tableName = "TransactionDetails_year"; +// +// System.out.println("\n========================================"); +// System.out.println("测试:按年分区写入性能"); +// System.out.println("表名:" + tableName); +// System.out.println("数据量:" + PRESSURE_RECORDS + " 条"); +// System.out.println("========================================\n"); +// +// createYearlyPartitionTable(tableName); +// initWritersAndCommit(tableName); +// writeRecords(tableName, PRESSURE_RECORDS); +// } +// +// /** +// * 执行数据写入 +// */ +// protected void writeRecords(String tableName, int totalRecords) throws Exception { +// long startTime = System.currentTimeMillis(); +// +// AtomicLong totalWritten = new AtomicLong(0); +// AtomicLong lastReportTime = new AtomicLong(startTime); +// AtomicLong lastReportCount = new AtomicLong(0); +// +// int recordsPerThread = totalRecords / WRITE_THREADS; +// ConcurrentLinkedQueue allMessages = new ConcurrentLinkedQueue<>(); +// Object commitLock = new Object(); +// AtomicLong accumulatedRecords = new AtomicLong(0); +// +// List> futures = new ArrayList<>(); +// +// for (int threadId = 0; threadId < WRITE_THREADS; threadId++) { +// final int tid = threadId; +// final long startRecordId = (long) tid * recordsPerThread; +// final long endRecordId = (tid == WRITE_THREADS - 1) ? totalRecords : (tid + 1) * recordsPerThread; +// final StreamTableWrite threadWriter = writers.get(tid); +// +// Future future = executorService.submit(() -> { +// try { +// Random threadRandom = new Random(tid); +// +// for (long recordId = startRecordId; recordId < endRecordId; recordId++) { +// // 创建交易数据行 +// GenericRow row = createTransactionRow(recordId, threadRandom); +// +// // 计算分区(按天) +// int partitionDate = calculatePartitionDate(recordId); +// row.setField(29, partitionDate); +// +// // 写入数据 +// threadWriter.write(row, tid); +// +// long written = totalWritten.incrementAndGet(); +// long accumulated = accumulatedRecords.incrementAndGet(); +// +// // 批量提交 +// if (accumulated >= COMMIT_BATCH_SIZE) { +// synchronized (commitLock) { +// if (accumulatedRecords.get() >= COMMIT_BATCH_SIZE) { +// commitAllWriters(); +// accumulatedRecords.set(0); +// } +// } +// } +// +// // 报告进度(仅线程 0) +// if (tid == 0 && written % 10000 == 0) { +// long currentTime = System.currentTimeMillis(); +// long timeDiff = currentTime - lastReportTime.get(); +// long countDiff = written - lastReportCount.get(); +// +// if (timeDiff > 0) { +// double progress = (double) written / totalRecords * 100; +// double currentThroughput = (countDiff * 1000.0 / timeDiff); +// double overallThroughput = (written * 1000.0 / (currentTime - startTime)); +// +// System.out.printf("进度:%.1f%% | 已写入:%s 条 | 当前吞吐:%s 条/秒 | 平均吞吐:%s 条/秒\n", +// progress, +// formatNumber(written), +// formatNumber((long) currentThroughput), +// formatNumber((long) overallThroughput)); +// +// lastReportTime.set(currentTime); +// lastReportCount.set(written); +// } +// } +// } +// +// } catch (Exception e) { +// System.err.println("线程 " + tid + " 写入失败:" + e.getMessage()); +// e.printStackTrace(); +// throw new RuntimeException(e); +// } +// }); +// +// futures.add(future); +// } +// +// // 等待所有线程完成 +// System.out.println("等待所有写入线程完成..."); +// for (Future future : futures) { +// try { +// future.get(); +// } catch (Exception e) { +// System.err.println("线程执行失败:" + e.getMessage()); +// e.printStackTrace(); +// } +// } +// +// // 提交剩余数据 +// if (accumulatedRecords.get() > 0) { +// System.out.println("\n正在提交剩余数据..."); +// commitAllWriters(); +// } +// +// long endTime = System.currentTimeMillis(); +// long totalTime = endTime - startTime; +// +// // 输出统计 +// printFinalStatistics(totalWritten.get(), totalTime, tableName); +// } +// +// /** +// * 创建交易数据行(29 个字段,单条约 1KB) +// */ +// protected GenericRow createTransactionRow(long id, Random random) { +// GenericRow row = new GenericRow(30); // 29 个字段 + 1 个分区字段 +// +// long timestamp = System.currentTimeMillis() - random.nextInt(86400000); // 随机时间(1 天内) +// Timestamp ts = Timestamp.fromEpochMillis(timestamp); +// +// // 字段 0: Id (主键) +// row.setField(0, BinaryString.fromString("TXN_" + System.currentTimeMillis() + "_" + id)); +// +// // 字段 1: BalanceDetailId +// row.setField(1, BinaryString.fromString("BAL_" + System.currentTimeMillis() + "_" + id)); +// +// // 字段 2: BeforeDetailBalance (DECIMAL) +// row.setField(2, Decimal.fromBigDecimal(new BigDecimal(random.nextLong(1000000, 10000000L)), 18, 0)); +// +// // 字段 3: Amount (DECIMAL) +// row.setField(3, Decimal.fromBigDecimal(new BigDecimal(random.nextLong(1000, 500000)), 18, 0)); +// +// // 字段 4: ExpiryDate +// row.setField(4, Timestamp.fromEpochMillis(timestamp + 86400000L * 30)); // 30 天后过期 +// +// // 字段 5: ComporId +// row.setField(5, BinaryString.fromString("COMP_" + String.format("%03d", random.nextInt(1000)))); +// +// // 字段 6: TransactionType +// row.setField(6, BinaryString.fromString(TRANSACTION_TYPES[random.nextInt(TRANSACTION_TYPES.length)])); +// +// // 字段 7: Channel +// row.setField(7, BinaryString.fromString(CHANNELS[random.nextInt(CHANNELS.length)])); +// +// // 字段 8: POSReference +// row.setField(8, BinaryString.fromString("POS_" + UUID.randomUUID().toString().replace("-", "").substring(0, 16))); +// +// // 字段 9: Outlet +// row.setField(9, BinaryString.fromString(OUTLETS[random.nextInt(OUTLETS.length)])); +// +// // 字段 10: Remark +// row.setField(10, BinaryString.fromString("Transaction processed successfully at " + ts.toString())); +// +// // 字段 11: CreatedTime +// row.setField(11, ts); +// +// // 字段 12: PaymentDetailId +// row.setField(12, BinaryString.fromString("PAY_DTL_" + UUID.randomUUID().toString().replace("-", "").substring(0, 12))); +// +// // 字段 13: PaymentId +// row.setField(13, random.nextLong(1000000000L, 9999999999L)); +// +// // 字段 14: CreatedBy +// row.setField(14, BinaryString.fromString("system")); +// +// // 字段 15: op (CDC 操作类型) +// row.setField(15, BinaryString.fromString(random.nextBoolean() ? "INSERT" : "UPDATE")); +// +// // 字段 16: AfterDetailBalance +// row.setField(16, Decimal.fromBigDecimal(new BigDecimal(random.nextLong(1000000, 10000000L)), 18, 0)); +// +// // 字段 17: SourceSystem +// row.setField(17, BinaryString.fromString("WALLET_CORE")); +// +// // 字段 18: DollarTypeId +// row.setField(18, BinaryString.fromString(CURRENCIES[random.nextInt(CURRENCIES.length)])); +// +// // 字段 19: ExceptionBalance +// row.setField(19, Decimal.fromBigDecimal(BigDecimal.ZERO, 18, 0)); +// +// // 字段 20: PatronId +// row.setField(20, BinaryString.fromString("CUST_" + String.format("%05d", random.nextInt(100000)))); +// +// // 字段 21: SourceKey +// row.setField(21, BinaryString.fromString("SRC_" + UUID.randomUUID().toString().replace("-", "").substring(0, 12))); +// +// // 字段 22: DeviceId +// row.setField(22, BinaryString.fromString("DEV_" + String.format("%03d", random.nextInt(1000)))); +// +// // 字段 23: AfterBalance +// row.setField(23, Decimal.fromBigDecimal(new BigDecimal(random.nextLong(1000000, 20000000L)), 18, 0)); +// +// // 字段 24: BeforeBalance +// row.setField(24, Decimal.fromBigDecimal(new BigDecimal(random.nextLong(1000000, 20000000L)), 18, 0)); +// +// // 字段 25: OutletCode +// row.setField(25, BinaryString.fromString("OUT_" + String.format("%03d", random.nextInt(1000)))); +// +// // 字段 26: ods_updated_at +// row.setField(26, Timestamp.fromEpochMillis(timestamp)); +// +// // 字段 27: Property +// row.setField(27, BinaryString.fromString(PROPERTIES[random.nextInt(PROPERTIES.length)])); +// +// // 字段 28: DisplaySourceKey +// row.setField(28, BinaryString.fromString("DISP_" + String.format("%03d", random.nextInt(1000)))); +// +// // 字段 29: SourceSystemExtraReference +// row.setField(29, BinaryString.fromString("EXT_" + UUID.randomUUID().toString().replace("-", "").substring(0, 12))); +// +// // 字段 30: SegmentCode (实际是第 29 个字段,索引 28) +// // 字段 31: pt_created_date (分区字段,索引 29) +// +// return row; +// } +// +// /** +// * 计算分区日期 +// */ +// protected int calculatePartitionDate(long recordId) { +// // 模拟 30 天内的数据 +// long daysAgo = recordId % 30; +// long timestamp = System.currentTimeMillis() - daysAgo * 86400000L; +// return Integer.parseInt(new java.text.SimpleDateFormat("yyyyMMdd").format(new java.util.Date(timestamp))); +// } +// +// /** +// * 提交所有 Writers 的数据 +// */ +// protected void commitAllWriters() throws Exception { +// long commitId = commitIdentifier.incrementAndGet(); +// +// List allMessages = new ArrayList<>(); +// for (StreamTableWrite writer : writers) { +// List messages = writer.prepareCommit(false, commitId); +// allMessages.addAll(messages); +// } +// +// commit.commit(commitId, allMessages); +// } +// +// /** +// * 输出最终统计信息 +// */ +// protected void printFinalStatistics(long totalRecords, long totalTimeMs, String tableName) { +// System.out.println("\n========================================"); +// System.out.println("写入测试完成!"); +// System.out.println("========================================"); +// System.out.println("表名:" + tableName); +// System.out.println("总记录数:" + formatNumber(totalRecords)); +// System.out.println("写入线程数:" + WRITE_THREADS); +// System.out.println("总耗时:" + formatTime(totalTimeMs)); +// +// double throughput = totalTimeMs > 0 ? (totalRecords * 1000.0 / totalTimeMs) : 0; +// System.out.println("平均吞吐量:" + formatNumber((long) throughput) + " 条/秒"); +// +// double perThreadThroughput = throughput / WRITE_THREADS; +// System.out.println("单线程吞吐量:" + formatNumber((long) perThreadThroughput) + " 条/秒"); +// +// long totalCommits = (totalRecords + COMMIT_BATCH_SIZE - 1) / COMMIT_BATCH_SIZE; +// System.out.println("总提交次数:" + formatNumber(totalCommits)); +// +// double avgCommitTime = totalCommits > 0 ? (totalTimeMs / (double) totalCommits) : 0; +// System.out.println("平均提交耗时:" + String.format("%.2f", avgCommitTime) + " 毫秒"); +// +// // 估算数据大小(每条记录约 1KB) +// long estimatedDataSize = totalRecords * 1024; +// System.out.println("估算数据大小:" + formatBytes(estimatedDataSize)); +// +// double dataThroughput = totalTimeMs > 0 ? (estimatedDataSize * 1000.0 / totalTimeMs) : 0; +// System.out.println("数据吞吐量:" + formatBytes((long) dataThroughput) + "/秒"); +// +// System.out.println("========================================\n"); +// } +// +// /** +// * 格式化数字 +// */ +// protected String formatNumber(long number) { +// DecimalFormat formatter = new DecimalFormat("#,###"); +// return formatter.format(number); +// } +// +// /** +// * 格式化时间 +// */ +// protected String formatTime(long milliseconds) { +// long seconds = milliseconds / 1000; +// long minutes = seconds / 60; +// long hours = minutes / 60; +// +// if (hours > 0) { +// return String.format("%d 小时 %d 分钟 %d 秒", hours, minutes % 60, seconds % 60); +// } else if (minutes > 0) { +// return String.format("%d 分钟 %d 秒", minutes, seconds % 60); +// } else { +// return String.format("%d 秒", seconds); +// } +// } +// +// /** +// * 格式化字节大小 +// */ +// protected String formatBytes(long bytes) { +// if (bytes < 1024) { +// return bytes + " B"; +// } else if (bytes < 1024 * 1024) { +// return String.format("%.2f KB", bytes / 1024.0); +// } else if (bytes < 1024 * 1024 * 1024) { +// return String.format("%.2f MB", bytes / (1024.0 * 1024)); +// } else { +// return String.format("%.2f GB", bytes / (1024.0 * 1024 * 1024)); +// } +// } +//} diff --git a/connectors/paimon-connector/src/test/java/io/tapdata/connector/paimon/service/ManagedIOStreamTableWriteTest.java b/connectors/paimon-connector/src/test/java/io/tapdata/connector/paimon/service/ManagedIOStreamTableWriteTest.java new file mode 100644 index 000000000..089dc896f --- /dev/null +++ b/connectors/paimon-connector/src/test/java/io/tapdata/connector/paimon/service/ManagedIOStreamTableWriteTest.java @@ -0,0 +1,209 @@ +package io.tapdata.connector.paimon.service; + +import org.apache.paimon.data.BinaryRow; +import org.apache.paimon.data.InternalRow; +import org.apache.paimon.disk.BufferFileReader; +import org.apache.paimon.disk.BufferFileWriter; +import org.apache.paimon.disk.FileIOChannel; +import org.apache.paimon.disk.IOManager; +import org.apache.paimon.io.BundleRecords; +import org.apache.paimon.memory.MemoryPoolFactory; +import org.apache.paimon.metrics.MetricRegistry; +import org.apache.paimon.table.sink.CommitMessage; +import org.apache.paimon.table.sink.StreamTableWrite; +import org.apache.paimon.table.sink.TableWrite; +import org.apache.paimon.types.RowType; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class ManagedIOStreamTableWriteTest { + + @Test + void closeShouldCloseWriteThenIoManager() throws Exception { + List order = new ArrayList<>(); + StreamTableWrite delegate = new FakeStreamTableWrite(order, null); + IOManager ioManager = new FakeIOManager(order, null); + ManagedIOStreamTableWrite writer = new ManagedIOStreamTableWrite(delegate, ioManager); + + writer.close(); + + assertEquals(2, order.size()); + assertEquals("write.close", order.get(0)); + assertEquals("io.close", order.get(1)); + } + + @Test + void closeShouldStillCloseIoManagerWhenWriteCloseFails() { + List order = new ArrayList<>(); + Exception writeError = new Exception("write close failed"); + StreamTableWrite delegate = new FakeStreamTableWrite(order, writeError); + IOManager ioManager = new FakeIOManager(order, null); + + ManagedIOStreamTableWrite writer = new ManagedIOStreamTableWrite(delegate, ioManager); + + Exception thrown = assertThrows(Exception.class, writer::close); + assertSame(writeError, thrown); + assertEquals(2, order.size()); + assertEquals("write.close", order.get(0)); + assertEquals("io.close", order.get(1)); + } + + @Test + void closeShouldAttachIoManagerErrorAsSuppressedWhenBothFail() { + List order = new ArrayList<>(); + Exception writeError = new Exception("write close failed"); + Exception ioError = new Exception("io close failed"); + StreamTableWrite delegate = new FakeStreamTableWrite(order, writeError); + IOManager ioManager = new FakeIOManager(order, ioError); + + ManagedIOStreamTableWrite writer = new ManagedIOStreamTableWrite(delegate, ioManager); + + Exception thrown = assertThrows(Exception.class, writer::close); + assertSame(writeError, thrown); + assertArrayEquals(new Throwable[] {ioError}, thrown.getSuppressed()); + assertEquals(2, order.size()); + assertEquals("write.close", order.get(0)); + assertEquals("io.close", order.get(1)); + } + + @Test + void closeShouldWorkWithoutIoManager() throws Exception { + List order = new ArrayList<>(); + StreamTableWrite delegate = new FakeStreamTableWrite(order, null); + ManagedIOStreamTableWrite writer = new ManagedIOStreamTableWrite(delegate, null); + + writer.close(); + + assertEquals(Collections.singletonList("write.close"), order); + } + + private static class FakeStreamTableWrite implements StreamTableWrite { + + private final List order; + private final Exception closeError; + + private FakeStreamTableWrite(List order, Exception closeError) { + this.order = order; + this.closeError = closeError; + } + + @Override + public List prepareCommit(boolean waitCompaction, long commitIdentifier) { + return Collections.emptyList(); + } + + @Override + public TableWrite withIOManager(IOManager ioManager) { + return this; + } + + @Override + public TableWrite withWriteType(RowType writeType) { + return this; + } + + @Override + public TableWrite withMemoryPoolFactory(MemoryPoolFactory memoryPoolFactory) { + return this; + } + + @Override + public BinaryRow getPartition(InternalRow row) { + return null; + } + + @Override + public int getBucket(InternalRow row) { + return 0; + } + + @Override + public void write(InternalRow row) { + } + + @Override + public void write(InternalRow row, int bucket) { + } + + @Override + public void writeBundle(BinaryRow partition, int bucket, BundleRecords bundle) { + } + + @Override + public void compact(BinaryRow partition, int bucket, boolean fullCompaction) { + } + + @Override + public TableWrite withMetricRegistry(MetricRegistry registry) { + return this; + } + + @Override + public void close() throws Exception { + order.add("write.close"); + if (closeError != null) { + throw closeError; + } + } + } + + private static class FakeIOManager implements IOManager { + + private final List order; + private final Exception closeError; + + private FakeIOManager(List order, Exception closeError) { + this.order = order; + this.closeError = closeError; + } + + @Override + public FileIOChannel.ID createChannel() { + return null; + } + + @Override + public FileIOChannel.ID createChannel(String prefix) { + return null; + } + + @Override + public String[] tempDirs() { + return new String[0]; + } + + @Override + public FileIOChannel.Enumerator createChannelEnumerator() { + return null; + } + + @Override + public BufferFileWriter createBufferFileWriter(FileIOChannel.ID channelID) { + return null; + } + + @Override + public BufferFileReader createBufferFileReader(FileIOChannel.ID channelID) { + return null; + } + + @Override + public void close() throws Exception { + order.add("io.close"); + if (closeError != null) { + throw closeError; + } + } + } +} + + + diff --git a/connectors/polar-db-mysql-connector/pom.xml b/connectors/polar-db-mysql-connector/pom.xml index ba49d077f..f9fcadd35 100644 --- a/connectors/polar-db-mysql-connector/pom.xml +++ b/connectors/polar-db-mysql-connector/pom.xml @@ -23,7 +23,7 @@ 8 - 2.0.0-SNAPSHOT + 2.0.5-SNAPSHOT diff --git a/connectors/polar-db-mysql-connector/src/main/resources/polar-db-mysql-spec.json b/connectors/polar-db-mysql-connector/src/main/resources/polar-db-mysql-spec.json index 2e9642e7b..c691b5ef7 100644 --- a/connectors/polar-db-mysql-connector/src/main/resources/polar-db-mysql-spec.json +++ b/connectors/polar-db-mysql-connector/src/main/resources/polar-db-mysql-spec.json @@ -1,6 +1,7 @@ { "properties": { "name": "PolarDB MySQL", + "realName": "ApsaraDB PolarDB for MySQL", "icon": "icons/polar-db-mysql.png", "id": "polar-db-mysql", "doc": "${doc}", diff --git a/connectors/polar-db-postgres-connector/src/main/resources/polar-db-postgres-spec.json b/connectors/polar-db-postgres-connector/src/main/resources/polar-db-postgres-spec.json index 744cdf28b..7c54d1517 100644 --- a/connectors/polar-db-postgres-connector/src/main/resources/polar-db-postgres-spec.json +++ b/connectors/polar-db-postgres-connector/src/main/resources/polar-db-postgres-spec.json @@ -1,6 +1,7 @@ { "properties": { "name": "PolarDB PostgreSQL", + "realName": "ApsaraDB PolarDB for PostgreSQL", "icon": "icons/polar_db_postgres.png", "doc" : "${doc}", "id": "polar-db-postgres", diff --git a/connectors/pom.xml b/connectors/pom.xml index 1d81e8bd4..c93f8e4aa 100644 --- a/connectors/pom.xml +++ b/connectors/pom.xml @@ -71,12 +71,13 @@ highgo-connector file-stream-connector paimon-connector + snowflake-connector ${project.artifactId}-v${project.version} 8 - 2.0-SNAPSHOT - 2.0.0-SNAPSHOT + 2.5-SNAPSHOT + 2.0.5-SNAPSHOT 1.0-SNAPSHOT 5.8.1 1.8.1 diff --git a/connectors/postgres-connector/pom.xml b/connectors/postgres-connector/pom.xml index c37f0041a..67c745a7b 100644 --- a/connectors/postgres-connector/pom.xml +++ b/connectors/postgres-connector/pom.xml @@ -24,6 +24,12 @@ io.tapdata postgres-core 1.0-SNAPSHOT + + + org.slf4j + slf4j-log4j12 + + org.postgresql diff --git a/connectors/postgres-connector/src/main/java/io/tapdata/connector/postgres/PostgresConnector.java b/connectors/postgres-connector/src/main/java/io/tapdata/connector/postgres/PostgresConnector.java index a32657efe..0042a2b9f 100644 --- a/connectors/postgres-connector/src/main/java/io/tapdata/connector/postgres/PostgresConnector.java +++ b/connectors/postgres-connector/src/main/java/io/tapdata/connector/postgres/PostgresConnector.java @@ -37,6 +37,7 @@ import io.tapdata.entity.utils.cache.Iterator; import io.tapdata.entity.utils.cache.KVReadOnlyMap; import io.tapdata.exception.TapCodeException; +import io.tapdata.exception.TapPdkRetryableEx; import io.tapdata.kit.*; import io.tapdata.pdk.apis.annotations.TapConnectorClass; import io.tapdata.pdk.apis.consumer.StreamReadConsumer; @@ -63,10 +64,7 @@ import java.sql.*; import java.sql.Date; import java.text.SimpleDateFormat; -import java.time.Instant; -import java.time.LocalDateTime; -import java.time.ZoneId; -import java.time.ZoneOffset; +import java.time.*; import java.util.*; import java.util.concurrent.CopyOnWriteArraySet; import java.util.concurrent.CountDownLatch; @@ -293,17 +291,24 @@ private void clearSlot() throws Throwable { } private void dropPublication() throws Throwable { - postgresJdbcContext.execute("DROP PUBLICATION " + slotName); + if (EmptyKit.isNotNull(slotName)) { + postgresJdbcContext.execute("DROP PUBLICATION " + slotName); + } } private void buildSlot(TapConnectorContext connectorContext, Boolean needCheck) throws Throwable { if (EmptyKit.isNull(slotName)) { slotName = "tapdata_cdc_" + UUID.randomUUID().toString().replaceAll("-", "_"); String sql = "SELECT pg_create_logical_replication_slot('" + slotName + "','" + postgresConfig.getLogPluginName() + "')"; + long begin = System.currentTimeMillis(); try { - postgresJdbcContext.execute(sql); + postgresJdbcContext.execute(sql, 20); } catch (SQLException e) { - throw new TapCodeException(PostgresErrorCode.SELECT_PUBLICATION_FAILED, "Select publication failed. Error message: " + e.getMessage()).dynamicDescriptionParameters(sql); + if (System.currentTimeMillis() - begin > 18000) { + throw new TapCodeException(PostgresErrorCode.CREATE_SLOT_TIMEOUT, "Create slot failed, sql: {}, Error message: " + e.getMessage()).dynamicDescriptionParameters(sql); + } else { + throw new TapCodeException(PostgresErrorCode.CREATE_SLOT_FAILED, "Create slot failed, sql: {}, Error message: " + e.getMessage()).dynamicDescriptionParameters(sql); + } } tapLogger.info("new logical replication slot created, slotName:{}", slotName); connectorContext.getStateMap().put("tapdata_pg_slot", slotName); @@ -367,13 +372,93 @@ private void testReplicateIdentity(KVReadOnlyMap tableMap) { } } - private Object getStreamOffsetFromString(TapConnectorContext connectorContext, String offset) { + private Object getStreamOffsetFromString(TapConnectorContext connectorContext, String offsetString) { + if (EmptyKit.isBlank(offsetString)) { + throw new IllegalArgumentException("Offset string cannot be null or empty"); + } + try { PostgresOffset postgresOffset = new PostgresOffset(); - postgresOffset.setSourceOffset(offset); + + // 尝试解析为 JSON 格式的完整 offset + if (offsetString.trim().startsWith("{")) { + // 直接使用 JSON 字符串作为 sourceOffset + postgresOffset.setSourceOffset(offsetString); + tapLogger.info("Using JSON format offset: {}", offsetString); + return postgresOffset; + } + + // 解析 LSN 值 + Long lsnValue = parseLsn(offsetString); + + // 构建最小化的 offset JSON + // 只包含必要的 lsn 字段,让 Debezium 从这个 LSN 开始读取 + Map offsetMap = new HashMap<>(); + offsetMap.put("lsn", lsnValue); + + // 将 Map 转换为 JSON 字符串 + ObjectMapper objectMapper = new ObjectMapper(); + String sourceOffset = objectMapper.writeValueAsString(offsetMap); + postgresOffset.setSourceOffset(sourceOffset); + + tapLogger.info("Created offset from LSN string '{}', parsed LSN value: {}, offset: {}", + offsetString, lsnValue, sourceOffset); + return postgresOffset; + } catch (JsonProcessingException e) { + throw new RuntimeException("Failed to create offset from string: " + offsetString + + ". Error: " + e.getMessage(), e); } catch (Exception e) { - throw new RuntimeException("Oracle use scn as offset, invalid scn: " + offset, e); + throw new RuntimeException("Invalid LSN offset string: " + offsetString + + ". Expected format: '0/1234567' or '19088743' or JSON format. Error: " + e.getMessage(), e); + } + } + + /** + * 解析 LSN 字符串为 Long 值 + * + * @param lsnString LSN 字符串,支持两种格式: + * 1. PostgreSQL 标准格式:0/1234567 (segment/offset) + * 2. 十进制数值:19088743 + * @return LSN 的 Long 值 + */ + private Long parseLsn(String lsnString) { + lsnString = lsnString.trim(); + + // 格式 1: PostgreSQL 标准格式 "segment/offset" (如 "0/1234567") + if (lsnString.contains("/")) { + String[] parts = lsnString.split("/"); + if (parts.length != 2) { + throw new IllegalArgumentException("Invalid LSN format: " + lsnString + + ". Expected format: 'segment/offset' (e.g., '0/1234567')"); + } + + try { + // 将十六进制的 segment 和 offset 转换为 Long + long segment = Long.parseLong(parts[0], 16); + long offset = Long.parseLong(parts[1], 16); + + // LSN = (segment << 32) | offset + long lsn = (segment << 32) | offset; + + tapLogger.debug("Parsed LSN from '{}': segment={}, offset={}, lsn={}", + lsnString, segment, offset, lsn); + + return lsn; + } catch (NumberFormatException e) { + throw new IllegalArgumentException("Invalid LSN format: " + lsnString + + ". Segment and offset must be hexadecimal numbers. Error: " + e.getMessage(), e); + } + } + + // 格式 2: 直接的十进制数值 + try { + long lsn = Long.parseLong(lsnString); + tapLogger.debug("Parsed LSN from decimal string '{}': {}", lsnString, lsn); + return lsn; + } catch (NumberFormatException e) { + throw new IllegalArgumentException("Invalid LSN format: " + lsnString + + ". Expected either 'segment/offset' format or a decimal number. Error: " + e.getMessage(), e); } } @@ -391,11 +476,7 @@ public void onStop(TapConnectionContext connectionContext) { //initialize jdbc context, slot name, version private void initConnection(TapConnectionContext connectionContext) { postgresConfig = (PostgresConfig) new PostgresConfig().load(connectionContext.getConnectionConfig()); - postgresTest = new PostgresTest(postgresConfig, testItem -> { - }, null).initContext(); - postgresJdbcContext = new PostgresJdbcContext(postgresConfig); - commonDbConfig = postgresConfig; - jdbcContext = postgresJdbcContext; + postgresConfig.load(connectionContext.getNodeConfig()); isConnectorStarted(connectionContext, tapConnectorContext -> { firstConnectorId = (String) tapConnectorContext.getStateMap().get("firstConnectorId"); if (EmptyKit.isNull(firstConnectorId)) { @@ -403,11 +484,20 @@ private void initConnection(TapConnectionContext connectionContext) { tapConnectorContext.getStateMap().put("firstConnectorId", firstConnectorId); } slotName = tapConnectorContext.getStateMap().get("tapdata_pg_slot"); - postgresConfig.load(tapConnectorContext.getNodeConfig()); if (EmptyKit.isNull(slotName) && StringUtils.isNotBlank(postgresConfig.getCustomSlotName())) { slotName = postgresConfig.getCustomSlotName(); } }); + tapLogger = connectionContext.getLog(); + if (postgresConfig.getFileLog()) { + tapLogger.info("Starting Jdbc Logging, connectorId: {}", firstConnectorId); + postgresConfig.startJdbcLog(firstConnectorId); + } + postgresTest = new PostgresTest(postgresConfig, testItem -> { + }, null).initContext(); + postgresJdbcContext = new PostgresJdbcContext(postgresConfig); + commonDbConfig = postgresConfig; + jdbcContext = postgresJdbcContext; postgresVersion = postgresJdbcContext.queryVersion(); commonSqlMaker = new PostgresSqlMaker() .dbVersion(postgresVersion) @@ -423,7 +513,6 @@ private void initConnection(TapConnectionContext connectionContext) { postgresJdbcContext.withPostgresVersion(postgresVersion); postgresTest.withPostgresVersion(postgresVersion); ddlSqlGenerator = new PostgresDDLSqlGenerator(); - tapLogger = connectionContext.getLog(); fieldDDLHandlers = new BiClassHandlers<>(); fieldDDLHandlers.register(TapNewFieldEvent.class, this::newField); fieldDDLHandlers.register(TapAlterFieldAttributesEvent.class, this::alterFieldAttr); @@ -743,7 +832,7 @@ private Object timestampToStreamOffset(TapConnectorContext connectorContext, Lon if ("pgoutput".equals(postgresConfig.getLogPluginName()) && Integer.parseInt(postgresVersion) > 100000) { if (!postgresConfig.getPartPublication()) { createAllPublicationIfNotExist(); - } else if(EmptyKit.isBlank(postgresConfig.getCustomPublicationName())) { + } else if (EmptyKit.isBlank(postgresConfig.getCustomPublicationName())) { List tableList = new ArrayList<>(); Iterator> iterator = connectorContext.getTableMap().iterator(); while (iterator.hasNext()) { @@ -865,7 +954,7 @@ private void createAllPublicationIfNotExist() throws SQLException { } private void createCustomPublicationIfNotExist(List tableList) { - String sql = String.format("CREATE PUBLICATION %s FOR TABLE %s", slotName, tableList.stream().map(this::getSchemaAndTable).collect(Collectors.joining(", "))); + String sql = String.format("CREATE PUBLICATION %s FOR TABLE %s %s", slotName, tableList.stream().map(this::getSchemaAndTable).collect(Collectors.joining(", ")), postgresConfig.getPartitionRoot() ? "WITH (publish_via_partition_root = true)" : ""); try { tapLogger.info("Create publication sql: {}", sql); postgresJdbcContext.execute(sql); @@ -984,13 +1073,40 @@ private Map filterTimeForPG(ResultSet resultSet, Map 8 UTF-8 - 2.0.0-SNAPSHOT + 2.0.6-SNAPSHOT diff --git a/connectors/redis-connector/src/main/java/io/tapdata/connector/redis/RedisConfig.java b/connectors/redis-connector/src/main/java/io/tapdata/connector/redis/RedisConfig.java index a29f9d832..ed36060d1 100644 --- a/connectors/redis-connector/src/main/java/io/tapdata/connector/redis/RedisConfig.java +++ b/connectors/redis-connector/src/main/java/io/tapdata/connector/redis/RedisConfig.java @@ -2,6 +2,7 @@ import io.tapdata.connector.redis.constant.DeployModeEnum; import io.tapdata.entity.utils.BeanUtils; +import io.tapdata.entity.utils.DataMap; import io.tapdata.entity.utils.InstanceFactory; import io.tapdata.kit.EmptyKit; import org.apache.commons.lang3.StringUtils; @@ -40,6 +41,7 @@ public class RedisConfig { private Boolean oneKey = false; private String schemaKey = "-schema-key-"; private long rateLimit = 5000L; + private Map tableConfig; private final static String DATA_BASE ="database"; @@ -168,6 +170,10 @@ public String getValueType() { return valueType; } + public String getValueType(String key) { + return getTableConfigValue(key, "valueType", valueType); + } + public void setValueType(String valueType) { this.valueType = valueType; } @@ -192,6 +198,10 @@ public String getValueData() { return valueData; } + public String getValueData(String key) { + return getTableConfigValue(key, "valueData", valueData); + } + public void setValueData(String valueData) { this.valueData = valueData; } @@ -200,6 +210,10 @@ public String getValueJoinString() { return valueJoinString; } + public String getValueJoinString(String key) { + return getTableConfigValue(key, "valueJoinString", valueJoinString); + } + public void setValueJoinString(String valueJoinString) { this.valueJoinString = valueJoinString; } @@ -216,6 +230,10 @@ public Boolean getCsvFormat() { return csvFormat; } + public Boolean getCsvFormat(String key) { + return getTableConfigValue(key, "csvFormat", csvFormat); + } + public void setCsvFormat(Boolean csvFormat) { this.csvFormat = csvFormat; } @@ -224,6 +242,10 @@ public Boolean getListHead() { return listHead; } + public Boolean getListHead(String key) { + return getTableConfigValue(key, "listHead", listHead); + } + public void setListHead(Boolean listHead) { this.listHead = listHead; } @@ -232,6 +254,10 @@ public String getKeyExpression() { return keyExpression; } + public String getKeyExpression(String key) { + return getTableConfigValue(key, "keyExpression", keyExpression); + } + public void setKeyExpression(String keyExpression) { this.keyExpression = keyExpression; } @@ -264,6 +290,10 @@ public Boolean getOneKey() { return oneKey; } + public Boolean getOneKey(String key) { + return getTableConfigValue(key, "oneKey", oneKey); + } + public void setOneKey(Boolean oneKey) { this.oneKey = oneKey; } @@ -283,4 +313,19 @@ public long getRateLimit() { public void setRateLimit(long rateLimit) { this.rateLimit = rateLimit; } + + public Map getTableConfig() { + return tableConfig; + } + + public void setTableConfig(Map tableConfig) { + this.tableConfig = tableConfig; + } + + private T getTableConfigValue(String key, String propertyName, T defaultValue) { + if (tableConfig != null && tableConfig.containsKey(key)) { + return tableConfig.get(key).getValue(propertyName, defaultValue); + } + return defaultValue; + } } diff --git a/connectors/redis-connector/src/main/java/io/tapdata/connector/redis/RedisConnector.java b/connectors/redis-connector/src/main/java/io/tapdata/connector/redis/RedisConnector.java index da30f74ff..84e4ff95c 100644 --- a/connectors/redis-connector/src/main/java/io/tapdata/connector/redis/RedisConnector.java +++ b/connectors/redis-connector/src/main/java/io/tapdata/connector/redis/RedisConnector.java @@ -89,6 +89,7 @@ public void initConnection(TapConnectionContext connectionContext) throws Throwa connectorContext.getStateMap().put("firstConnectorId", firstConnectorId); } redisConfig.load(connectionContext.getNodeConfig()); + redisConfig.setTableConfig(connectionContext.getTableNodeConfig()); }); this.redisContext = new RedisContext(redisConfig); this.redisExceptionCollector = new RedisExceptionCollector(); @@ -140,7 +141,7 @@ public ConnectionOptions connectionTest(TapConnectionContext connectionContext, private void writeRecord(TapConnectorContext connectorContext, List tapRecordEvents, TapTable tapTable, Consumer> writeListResultConsumer) throws Throwable { AbstractRedisRecordWriter recordWriter; - switch (ValueTypeEnum.fromString(redisConfig.getValueType())) { + switch (ValueTypeEnum.fromString(redisConfig.getValueType(tapTable.getId()))) { case LIST: recordWriter = new ListRedisRecordWriter(redisContext, tapTable); break; @@ -162,13 +163,13 @@ private void writeRecord(TapConnectorContext connectorContext, List fieldList = createTableEvent.getTable().getNameFieldMap().entrySet().stream().sorted(Comparator.comparing(v -> EmptyKit.isNull(v.getValue().getPos()) ? 99999 : v.getValue().getPos())).map(Map.Entry::getKey).collect(Collectors.toList()); - if (redisConfig.getOneKey()) { - String keyName = createTableEvent.getTableId(); - if (ValueTypeEnum.fromString(redisConfig.getValueType()) == ValueTypeEnum.LIST) { + if (redisConfig.getOneKey(keyName)) { + if (ValueTypeEnum.fromString(redisConfig.getValueType(keyName)) == ValueTypeEnum.LIST) { jedis.del(keyName); - jedis.rpush(keyName, String.join(EmptyKit.isEmpty(redisConfig.getValueJoinString()) ? "," : redisConfig.getValueJoinString(), fieldList)); + jedis.rpush(keyName, String.join(EmptyKit.isEmpty(redisConfig.getValueJoinString(keyName)) ? "," : redisConfig.getValueJoinString(keyName), fieldList)); } else { jedis.hset(redisConfig.getSchemaKey(), keyName, fieldList.stream().map(v -> csvFormat(v, ",")).collect(Collectors.joining(","))); } diff --git a/connectors/redis-connector/src/main/java/io/tapdata/connector/redis/writer/AbstractRedisRecordWriter.java b/connectors/redis-connector/src/main/java/io/tapdata/connector/redis/writer/AbstractRedisRecordWriter.java index c3d7e02a4..57529776d 100644 --- a/connectors/redis-connector/src/main/java/io/tapdata/connector/redis/writer/AbstractRedisRecordWriter.java +++ b/connectors/redis-connector/src/main/java/io/tapdata/connector/redis/writer/AbstractRedisRecordWriter.java @@ -27,6 +27,11 @@ public abstract class AbstractRedisRecordWriter { protected final CommonJedis jedis; protected final RedisConfig redisConfig; protected final TapTable tapTable; + protected final String valueData; + protected final String valueJoinString; + protected final Boolean csvFormat; + protected final String keyExpression; + protected final Boolean oneKey; protected final List fieldList; protected final List keyFieldList; protected static final JsonParser jsonParser = InstanceFactory.instance(JsonParser.class); //json util @@ -35,6 +40,11 @@ public AbstractRedisRecordWriter(RedisContext redisContext, TapTable tapTable) { this.redisConfig = redisContext.getRedisConfig(); this.jedis = redisContext.getJedis(); this.tapTable = tapTable; + this.valueData = redisConfig.getValueData(tapTable.getId()); + this.valueJoinString = redisConfig.getValueJoinString(tapTable.getId()); + this.csvFormat = redisConfig.getCsvFormat(tapTable.getId()); + this.keyExpression = redisConfig.getKeyExpression(tapTable.getId()); + this.oneKey = redisConfig.getOneKey(tapTable.getId()); this.fieldList = tapTable.getNameFieldMap().entrySet().stream().sorted(Comparator.comparing(v -> EmptyKit.isNull(v.getValue().getPos()) ? 99999 : v.getValue().getPos())).map(Map.Entry::getKey).collect(Collectors.toList()); this.keyFieldList = getKeyFieldList(); @@ -92,8 +102,8 @@ public void write(List tapRecordEvents, Consumer value) { - if (EmptyKit.isNotBlank(redisConfig.getKeyExpression())) { - String key = redisConfig.getKeyExpression(); + if (EmptyKit.isNotBlank(keyExpression)) { + String key = keyExpression; for (String field : fieldList) { Object obj = value.get(field); key = key.replaceAll("\\$\\{" + field + "}", EmptyKit.isNull(obj) ? "null" : String.valueOf(obj)); @@ -117,7 +127,7 @@ protected String getRedisKey(Map value) { protected List getKeyFieldList() { List keyFieldList = new ArrayList<>(); - String expression = redisConfig.getKeyExpression(); + String expression = keyExpression; if (EmptyKit.isBlank(expression)) { return keyFieldList; } @@ -137,12 +147,12 @@ protected String getTextValue(Map value) { return fieldList.stream().map(v -> { Object obj = value.get(v); String str = EmptyKit.isNull(obj) ? "null" : String.valueOf(obj); - if (redisConfig.getCsvFormat()) { - return csvFormat(str, redisConfig.getValueJoinString()); + if (csvFormat) { + return csvFormat(str, valueJoinString); } else { return str; } - }).collect(Collectors.joining(redisConfig.getValueJoinString())); + }).collect(Collectors.joining(valueJoinString)); } private String csvFormat(String str, String delimiter) { diff --git a/connectors/redis-connector/src/main/java/io/tapdata/connector/redis/writer/HashRedisRecordWriter.java b/connectors/redis-connector/src/main/java/io/tapdata/connector/redis/writer/HashRedisRecordWriter.java index b55f9d76e..f207cd4b5 100644 --- a/connectors/redis-connector/src/main/java/io/tapdata/connector/redis/writer/HashRedisRecordWriter.java +++ b/connectors/redis-connector/src/main/java/io/tapdata/connector/redis/writer/HashRedisRecordWriter.java @@ -25,8 +25,8 @@ public HashRedisRecordWriter(RedisContext redisContext, TapTable tapTable) { protected void handleInsertEvent(TapInsertRecordEvent event, RedisPipeline pipelined) { Map value = event.getAfter(); String fieldName = getRedisKey(value); - if (redisConfig.getOneKey()) { - String strValue = ValueDataEnum.JSON.getType().equals(redisConfig.getValueData()) ? getJsonValue(value) : getTextValue(value); + if (oneKey) { + String strValue = ValueDataEnum.JSON.getType().equals(valueData) ? getJsonValue(value) : getTextValue(value); pipelined.hset(keyName, fieldName, strValue); } else { pipelined.hmset(fieldName, toStringMap(value)); @@ -43,9 +43,9 @@ protected void handleUpdateEvent(TapUpdateRecordEvent event, RedisPipeline pipel } else { keyFieldList.forEach(v -> lastBefore.put(v, afterValue.get(v))); } - if (redisConfig.getOneKey()) { + if (oneKey) { pipelined.hdel(keyName, getRedisKey(lastBefore)); - String strValue = ValueDataEnum.JSON.getType().equals(redisConfig.getValueData()) ? getJsonValue(afterValue) : getTextValue(afterValue); + String strValue = ValueDataEnum.JSON.getType().equals(valueData) ? getJsonValue(afterValue) : getTextValue(afterValue); pipelined.hset(keyName, getRedisKey(afterValue), strValue); } else { pipelined.del(getRedisKey(lastBefore)); @@ -57,8 +57,8 @@ protected void handleUpdateEvent(TapUpdateRecordEvent event, RedisPipeline pipel protected void handleDeleteEvent(TapDeleteRecordEvent event, RedisPipeline pipelined) { Map value = event.getBefore(); String fieldName = getRedisKey(value); - if (redisConfig.getOneKey()) { - String strValue = ValueDataEnum.JSON.getType().equals(redisConfig.getValueData()) ? getJsonValue(value) : getTextValue(value); + if (oneKey) { + String strValue = ValueDataEnum.JSON.getType().equals(valueData) ? getJsonValue(value) : getTextValue(value); pipelined.hdel(keyName, fieldName, strValue); } else { pipelined.del(fieldName); diff --git a/connectors/redis-connector/src/main/java/io/tapdata/connector/redis/writer/ListRedisRecordWriter.java b/connectors/redis-connector/src/main/java/io/tapdata/connector/redis/writer/ListRedisRecordWriter.java index 0b48415db..7b3485610 100644 --- a/connectors/redis-connector/src/main/java/io/tapdata/connector/redis/writer/ListRedisRecordWriter.java +++ b/connectors/redis-connector/src/main/java/io/tapdata/connector/redis/writer/ListRedisRecordWriter.java @@ -24,8 +24,8 @@ public ListRedisRecordWriter(RedisContext redisContext, TapTable tapTable) { @Override protected void handleInsertEvent(TapInsertRecordEvent event, RedisPipeline pipelined) { Map value = event.getAfter(); - String strValue = ValueDataEnum.JSON.getType().equals(redisConfig.getValueData()) ? getJsonValue(value) : getTextValue(value); - if (!redisConfig.getOneKey()) { + String strValue = ValueDataEnum.JSON.getType().equals(valueData) ? getJsonValue(value) : getTextValue(value); + if (!oneKey) { keyName = getRedisKey(value); } pipelined.rpush(keyName, strValue); @@ -34,7 +34,7 @@ protected void handleInsertEvent(TapInsertRecordEvent event, RedisPipeline pipel @Override protected void handleUpdateEvent(TapUpdateRecordEvent event, RedisPipeline pipelined) throws Exception { Map afterValue = event.getAfter(); - String newValue = ValueDataEnum.JSON.getType().equals(redisConfig.getValueData()) ? getJsonValue(afterValue) : getTextValue(afterValue); + String newValue = ValueDataEnum.JSON.getType().equals(valueData) ? getJsonValue(afterValue) : getTextValue(afterValue); if (null == event.getBefore()) { throw new Exception("Redis update failed reason before data is null"); } @@ -45,8 +45,8 @@ protected void handleUpdateEvent(TapUpdateRecordEvent event, RedisPipeline pipel } else { keyFieldList.forEach(v -> lastBefore.put(v, afterValue.get(v))); } - String oldValue = ValueDataEnum.JSON.getType().equals(redisConfig.getValueData()) ? getJsonValue(lastBefore) : getTextValue(lastBefore); - if (!redisConfig.getOneKey()) { + String oldValue = ValueDataEnum.JSON.getType().equals(valueData) ? getJsonValue(lastBefore) : getTextValue(lastBefore); + if (!oneKey) { String newKeyName = getRedisKey(afterValue); String oldKeyName = getRedisKey(lastBefore); if (newKeyName.equals(oldKeyName)) { @@ -63,8 +63,8 @@ protected void handleUpdateEvent(TapUpdateRecordEvent event, RedisPipeline pipel @Override protected void handleDeleteEvent(TapDeleteRecordEvent event, RedisPipeline pipelined) { Map value = event.getBefore(); - String oldValue = ValueDataEnum.JSON.getType().equals(redisConfig.getValueData()) ? getJsonValue(value) : getTextValue(value); - if (redisConfig.getOneKey()) { + String oldValue = ValueDataEnum.JSON.getType().equals(valueData) ? getJsonValue(value) : getTextValue(value); + if (oneKey) { pipelined.lrem(keyName, 1, oldValue); } else { pipelined.lrem(getRedisKey(value), 1, oldValue); diff --git a/connectors/redis-connector/src/main/java/io/tapdata/connector/redis/writer/StringRedisRecordWriter.java b/connectors/redis-connector/src/main/java/io/tapdata/connector/redis/writer/StringRedisRecordWriter.java index e2aff088e..6d60d6473 100644 --- a/connectors/redis-connector/src/main/java/io/tapdata/connector/redis/writer/StringRedisRecordWriter.java +++ b/connectors/redis-connector/src/main/java/io/tapdata/connector/redis/writer/StringRedisRecordWriter.java @@ -21,7 +21,7 @@ public StringRedisRecordWriter(RedisContext redisContext, TapTable tapTable) { @Override protected void handleInsertEvent(TapInsertRecordEvent event, RedisPipeline pipelined) { Map value = event.getAfter(); - String strValue = ValueDataEnum.JSON.getType().equals(redisConfig.getValueData()) ? getJsonValue(value) : getTextValue(value); + String strValue = ValueDataEnum.JSON.getType().equals(valueData) ? getJsonValue(value) : getTextValue(value); pipelined.set(getRedisKey(value), strValue); } @@ -36,7 +36,7 @@ protected void handleUpdateEvent(TapUpdateRecordEvent event, RedisPipeline pipel keyFieldList.forEach(v -> lastBefore.put(v, afterValue.get(v))); } pipelined.del(getRedisKey(lastBefore)); - String strValue = ValueDataEnum.JSON.getType().equals(redisConfig.getValueData()) ? getJsonValue(afterValue) : getTextValue(afterValue); + String strValue = ValueDataEnum.JSON.getType().equals(valueData) ? getJsonValue(afterValue) : getTextValue(afterValue); pipelined.set(getRedisKey(afterValue), strValue); } diff --git a/connectors/redis-connector/src/main/resources/spec_redis.json b/connectors/redis-connector/src/main/resources/spec_redis.json index ae63f4cde..0cef19cf1 100644 --- a/connectors/redis-connector/src/main/resources/spec_redis.json +++ b/connectors/redis-connector/src/main/resources/spec_redis.json @@ -1,6 +1,7 @@ { "properties": { "name": "Redis", + "realName": "Redis", "icon": "icons/redis.png", "doc": "${doc}", "id": "redis", @@ -187,6 +188,16 @@ } }, "node":{ + "type": "object", + "x-tableConfigScope": ["target"], + "x-reactions": { + "target": "nodeConfig.keyExpression", + "fulfill": { + "state": { + "display": "{{$settings.syncType===\"migrate\"?\"hidden\":\"visible\"}}" + } + } + }, "properties":{ "valueType": { "type": "string", @@ -198,6 +209,7 @@ "tooltip": "${valueTypeTooltip}" }, "x-index": 1, + "x-perTable": true, "enum": [ { "label": "String", @@ -233,6 +245,7 @@ "x-decorator": "FormItem", "x-component": "Switch", "x-index": 2, + "x-perTable": true, "x-decorator-props": { "tooltip": "${oneKeyTooltip}" }, @@ -254,6 +267,7 @@ "x-decorator": "FormItem", "x-component": "Switch", "x-index": 4, + "x-perTable": true, "x-decorator-props": { "tooltip": "${listHeadTooltip}" }, @@ -275,16 +289,17 @@ "x-decorator": "FormItem", "x-component": "Input", "x-index": 5, + "x-perTable": true, "x-decorator-props": { "tooltip": "${keyExpressionTooltip}" }, "required": true, "x-reactions": [ { - "dependencies": [".valueType",".oneKey","type","$inputs"], + "dependencies": [".valueType",".oneKey","$inputs"], "fulfill": { "state": { - "visible": "{{($deps[0] === 'Hash' || !$deps[1]) && $deps[0] !== 'Redis' && ($deps[2] !== 'database') && $deps[3].length > 0}}" + "visible": "{{($deps[0] === 'Hash' || !$deps[1]) && $deps[0] !== 'Redis' && $deps[2].length > 0}}" } } } @@ -358,6 +373,7 @@ "x-decorator": "FormItem", "x-component": "Select", "x-index": 9, + "x-perTable": true, "x-decorator-props": { "tooltip": "${valueDataTooltip}" }, @@ -390,6 +406,7 @@ "x-decorator": "FormItem", "x-component": "Input", "x-index": 10, + "x-perTable": true, "x-decorator-props": { "tooltip": "${valueJoinStringTooltip}" }, @@ -412,6 +429,7 @@ "x-decorator": "FormItem", "x-component": "Switch", "x-index": 11, + "x-perTable": true, "x-decorator-props": { "tooltip": "${csvFormatTooltip}" }, diff --git a/connectors/rocketmq-connector/src/main/resources/spec_rocketmq.json b/connectors/rocketmq-connector/src/main/resources/spec_rocketmq.json index 0bc9cb906..7b61e64b0 100644 --- a/connectors/rocketmq-connector/src/main/resources/spec_rocketmq.json +++ b/connectors/rocketmq-connector/src/main/resources/spec_rocketmq.json @@ -1,6 +1,7 @@ { "properties": { "name": "RocketMQ", + "realName": "Apache RocketMQ", "icon": "icons/rocketmq.png", "doc": "${doc}", "id": "rocketmq", diff --git a/connectors/selectdb-connector/pom.xml b/connectors/selectdb-connector/pom.xml index c6686cc23..b6dd525b0 100644 --- a/connectors/selectdb-connector/pom.xml +++ b/connectors/selectdb-connector/pom.xml @@ -15,7 +15,7 @@ 8 - 2.0.0-SNAPSHOT + 2.0.5-SNAPSHOT 1.0-SNAPSHOT diff --git a/connectors/selectdb-connector/src/main/resources/spec_selectdb.json b/connectors/selectdb-connector/src/main/resources/spec_selectdb.json index 4dd6221de..c0303fa27 100644 --- a/connectors/selectdb-connector/src/main/resources/spec_selectdb.json +++ b/connectors/selectdb-connector/src/main/resources/spec_selectdb.json @@ -1,6 +1,7 @@ { "properties": { "name": "SelectDB", + "realName": "SelectDB", "icon": "icons/selectdb.png", "doc": "${doc}", "id": "selectdb", diff --git a/connectors/snowflake-connector/pom.xml b/connectors/snowflake-connector/pom.xml new file mode 100644 index 000000000..978f087d8 --- /dev/null +++ b/connectors/snowflake-connector/pom.xml @@ -0,0 +1,166 @@ + + + + + connectors + io.tapdata + 1.0-SNAPSHOT + + 4.0.0 + + snowflake-connector + 1.0-SNAPSHOT + jar + + + 8 + 3.14.5 + 2.0.5-SNAPSHOT + + + + + io.tapdata + tapdata-pdk-api + ${tapdata.pdk.api.version} + provided + + + io.tapdata + tapdata-pdk-runner + test + + + io.tapdata + pdk-error-code + + + net.snowflake + snowflake-jdbc + ${snowflake.driver.version} + + + org.slf4j + slf4j-log4j12 + + + + + io.tapdata + sql-core + 1.0-SNAPSHOT + + + + + + + org.apache.maven.plugins + maven-assembly-plugin + 3.1.1 + + + ${connector.file.name} + + jar-with-dependencies + + + + ${project.artifactId} + ${project.version} + ${project.groupId} + + ${os.name} ${os.version} + Java ${java.version} + ${tapdata.pdk.runner.version} + ${tapdata.pdk.api.version} + ${tapdata.pdk.api.version} + ${project.version} + GA + ${pay.mode} + ${git.build.time} + ${git.branch} + ${git.commit.id} + ${git.build.user.name} + ${git.build.user.email} + + + false + + + + + make-assembly + package + + single + + + + + + + pl.project13.maven + git-commit-id-plugin + 2.2.3 + + + get-the-git-infos + + revision + + validate + + + + true + true + true + true + ${project.basedir}/../../.git + + + + maven-resources-plugin + 3.1.0 + + + copy-resource-one + package + + copy-resources + + + + ../dist + + + ${basedir}/target/ + + ${connector.file.name}.jar + + + + + + + + + org.apache.felix + maven-bundle-plugin + 4.2.1 + true + + + maven-compiler-plugin + 3.7.0 + + 8 + 8 + + + + + + diff --git a/connectors/snowflake-connector/src/main/java/io/tapdata/connector/snowflake/SnowflakeColumn.java b/connectors/snowflake-connector/src/main/java/io/tapdata/connector/snowflake/SnowflakeColumn.java new file mode 100644 index 000000000..730244db4 --- /dev/null +++ b/connectors/snowflake-connector/src/main/java/io/tapdata/connector/snowflake/SnowflakeColumn.java @@ -0,0 +1,65 @@ +package io.tapdata.connector.snowflake; + +import io.tapdata.common.CommonColumn; +import io.tapdata.entity.schema.TapField; +import io.tapdata.entity.utils.DataMap; +import io.tapdata.kit.StringKit; + +public class SnowflakeColumn extends CommonColumn { + + public SnowflakeColumn(DataMap dataMap) { + super(dataMap); + this.dataScale = this.dataScale == null ? dataMap.getInteger("dataFraction") : this.dataScale; + this.dataType = getDataType(dataMap); //'dataType' with precision and scale (oracle has no function) + } + + @Override + public TapField getTapField() { + return new TapField(this.columnName, this.dataType) + .pureDataType(StringKit.removeParentheses(this.pureDataType)) + .length(this.dataLength) + .precision(this.dataPrecision) + .scale(this.dataScale) + .nullable(this.isNullable()) + .defaultValue(columnDefaultValue) + .comment(this.remarks); + } + + @Override + protected Boolean isNullable() { + return "YES".equals(this.nullable); + } + + private String getDataType(DataMap dataMap) { + String dataType = dataMap.getString("dataType"); + String dataLength = dataMap.getString("dataLength"); + String dataPrecision = dataMap.getString("dataPrecision"); + if (dataType.contains("(")) { + return dataType; + } else { + switch (dataType) { + case "TEXT": + if (dataLength == null) { + return dataType; + } + return dataType + "(" + dataLength + ")"; + case "NUMBER": + if (dataScale == null) { + return "NUMBER"; + } else { + return "NUMBER(" + dataPrecision + "," + dataScale + ")"; + } + case "TIME": + case "TIMESTAMP_NTZ": + case "TIMESTAMP_TZ": + case "TIMESTAMP_LTZ": + if (dataScale == null) { + return dataType; + } + return dataType + "(" + dataScale + ")"; + default: + return dataType; + } + } + } +} diff --git a/connectors/snowflake-connector/src/main/java/io/tapdata/connector/snowflake/SnowflakeConnector.java b/connectors/snowflake-connector/src/main/java/io/tapdata/connector/snowflake/SnowflakeConnector.java new file mode 100644 index 000000000..e57bb6296 --- /dev/null +++ b/connectors/snowflake-connector/src/main/java/io/tapdata/connector/snowflake/SnowflakeConnector.java @@ -0,0 +1,230 @@ +package io.tapdata.connector.snowflake; + +import io.tapdata.common.CommonDbConnector; +import io.tapdata.common.SqlExecuteCommandFunction; +import io.tapdata.common.exception.AbstractExceptionCollector; +import io.tapdata.connector.snowflake.config.SnowflakeConfig; +import io.tapdata.connector.snowflake.dml.SnowflakeRecordWriter; +import io.tapdata.entity.codec.TapCodecsRegistry; +import io.tapdata.entity.error.CoreException; +import io.tapdata.entity.event.ddl.table.TapAlterFieldAttributesEvent; +import io.tapdata.entity.event.ddl.table.TapAlterFieldNameEvent; +import io.tapdata.entity.event.ddl.table.TapDropFieldEvent; +import io.tapdata.entity.event.ddl.table.TapNewFieldEvent; +import io.tapdata.entity.event.dml.TapRecordEvent; +import io.tapdata.entity.schema.TapField; +import io.tapdata.entity.schema.TapIndex; +import io.tapdata.entity.schema.TapIndexField; +import io.tapdata.entity.schema.TapTable; +import io.tapdata.entity.schema.value.*; +import io.tapdata.entity.simplify.TapSimplify; +import io.tapdata.entity.simplify.pretty.BiClassHandlers; +import io.tapdata.entity.utils.DataMap; +import io.tapdata.kit.EmptyKit; +import io.tapdata.pdk.apis.annotations.TapConnectorClass; +import io.tapdata.pdk.apis.context.TapConnectionContext; +import io.tapdata.pdk.apis.context.TapConnectorContext; +import io.tapdata.pdk.apis.entity.ConnectionOptions; +import io.tapdata.pdk.apis.entity.TestItem; +import io.tapdata.pdk.apis.entity.WriteListResult; +import io.tapdata.pdk.apis.functions.ConnectorFunctions; + +import java.sql.*; +import java.time.Instant; +import java.time.ZoneId; +import java.time.ZoneOffset; +import java.util.List; +import java.util.Map; +import java.util.TimeZone; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Consumer; +import java.util.stream.Collectors; + +/** + * Snowflake PDK Connector + * + * @author Jarad + * @date 2026/03/24 + */ +@TapConnectorClass("spec_snowflake.json") +public class SnowflakeConnector extends CommonDbConnector { + + protected SnowflakeJdbcContext snowflakeJdbcContext; + protected SnowflakeConfig snowflakeConfig; + + @Override + public void onStart(TapConnectionContext connectorContext) { + initConnection(connectorContext); + } + + @Override + protected void singleThreadDiscoverSchema(List subList, Consumer> consumer) throws SQLException { + List tapTableList = TapSimplify.list(); + List subTableNames = subList.stream().map(v -> v.getString("tableName")).collect(Collectors.toList()); + List columnList = snowflakeJdbcContext.queryAllColumns(subTableNames); + List pkList = snowflakeJdbcContext.queryAllPks(subTableNames); + List indexList = TapSimplify.list(); + subList.forEach(subTable -> { + //2、table name/comment + String table = subTable.getString("tableName"); + TapTable tapTable = table(table); + tapTable.setTableAttr(getSpecificAttr(subTable)); + tapTable.setComment(subTable.getString("tableComment")); + //3、primary key and table index + TapIndex pkIndex = new TapIndex(); + pkIndex.setPrimary(true); + pkIndex.setUnique(true); + List pkIndexFields = TapSimplify.list(); + List primaryKey = TapSimplify.list(); + pkList.stream().filter(pk -> table.equals(pk.getString("tableName"))).forEach(pk -> { + primaryKey.add(pk.getString("columnName")); + pkIndex.setName(pk.getString("constraintName")); + pkIndexFields.add(new TapIndexField().name(pk.getString("columnName")).fieldAsc(true)); + }); + pkIndex.setIndexFields(pkIndexFields); + List tapIndexList = TapSimplify.list(); + makePrimaryKeyAndIndex(indexList, table, primaryKey, tapIndexList); + //4、table columns info + AtomicInteger keyPos = new AtomicInteger(0); + columnList.stream().filter(col -> table.equals(col.getString("tableName"))) + .forEach(col -> { + try { + TapField tapField = new SnowflakeColumn(col).getTapField(); + if (null == tapField) return; + tapField.setPos(keyPos.incrementAndGet()); + tapField.setPrimaryKey(primaryKey.contains(tapField.getName())); + tapField.setPrimaryKeyPos(primaryKey.indexOf(tapField.getName()) + 1); + if (tapField.getPrimaryKey()) { + tapField.setNullable(false); + } + tapTable.add(tapField); + } catch (Exception e) { + throw new CoreException("Construct field failed, table: " + table + ", column: " + col + ", error: " + e.getMessage()); + } + }); + tapIndexList.add(pkIndex); + tapTable.setIndexList(tapIndexList); + tapTableList.add(tapTable); + }); + syncSchemaSubmit(discoverPartitionInfo(tapTableList), consumer); + } + + @Override + public ConnectionOptions connectionTest(TapConnectionContext connectionContext, Consumer consumer) { + snowflakeConfig = (SnowflakeConfig) new SnowflakeConfig().load(connectionContext.getConnectionConfig()); + ConnectionOptions connectionOptions = ConnectionOptions.create(); + connectionOptions.connectionString(snowflakeConfig.getConnectionString()); + try ( + SnowflakeTest snowflakeTest = new SnowflakeTest(snowflakeConfig, consumer) + ) { + snowflakeTest.testOneByOne(); + return connectionOptions; + } + } + + @Override + public void registerCapabilities(ConnectorFunctions connectorFunctions, TapCodecsRegistry codecRegistry) { + // Test + connectorFunctions.supportErrorHandleFunction(this::errorHandle); + + // Target + connectorFunctions.supportWriteRecord(this::writeRecord); + connectorFunctions.supportCreateTableV2(this::createTableV2); + connectorFunctions.supportClearTable(this::clearTable); + connectorFunctions.supportDropTable(this::dropTable); + + // Source + connectorFunctions.supportBatchCount(this::batchCount); + connectorFunctions.supportBatchRead(this::batchReadWithoutOffset); + + // Query + connectorFunctions.supportQueryByFilter(this::queryByFilter); + connectorFunctions.supportQueryByAdvanceFilter(this::queryByAdvanceFilterWithOffset); + + // DDL + connectorFunctions.supportNewFieldFunction(this::fieldDDLHandler); + connectorFunctions.supportAlterFieldNameFunction(this::fieldDDLHandler); + connectorFunctions.supportAlterFieldAttributesFunction(this::fieldDDLHandler); + connectorFunctions.supportDropFieldFunction(this::fieldDDLHandler); + connectorFunctions.supportGetTableNamesFunction(this::getTableNames); + connectorFunctions.supportExecuteCommandFunction((a, b, c) -> SqlExecuteCommandFunction.executeCommand(a, b, () -> snowflakeJdbcContext.getConnection(), this::isAlive, c)); + + codecRegistry.registerFromTapValue(TapRawValue.class, "TEXT", tapRawValue -> { + if (tapRawValue != null && tapRawValue.getValue() != null) return toJson(tapRawValue.getValue()); + return "null"; + }); + codecRegistry.registerFromTapValue(TapTimeValue.class, tapTimeValue -> tapTimeValue.getValue().toTimeStr()); + codecRegistry.registerFromTapValue(TapDateTimeValue.class, tapDateTimeValue -> { + if (EmptyKit.isNotNull(tapDateTimeValue.getValue().getTimeZone())) { + return tapDateTimeValue.getValue().toTimestamp(); + } else { + return formatTapDateTime(tapDateTimeValue.getValue(), "yyyy-MM-dd HH:mm:ss.SSSSSS"); + } + }); + codecRegistry.registerFromTapValue(TapDateValue.class, tapDateValue -> tapDateValue.getValue().toSqlDate()); + codecRegistry.registerFromTapValue(TapYearValue.class, "TEXT(4)", TapValue::getOriginValue); + } + + @Override + public void onStop(TapConnectionContext connectionContext) { + EmptyKit.closeQuietly(snowflakeJdbcContext); + } + + private void initConnection(TapConnectionContext connectionContext) { + snowflakeConfig = (SnowflakeConfig) new SnowflakeConfig().load(connectionContext.getConnectionConfig()); + snowflakeJdbcContext = new SnowflakeJdbcContext(snowflakeConfig); + + // Set common fields - only set fields that are compatible + commonDbConfig = snowflakeConfig; + jdbcContext = snowflakeJdbcContext; + // Note: postgresJdbcContext is not set because SnowflakeJdbcContext doesn't extend PostgresJdbcContext + + commonSqlMaker = new SnowflakeSqlMaker(); +// ddlSqlGenerator = new PostgresDDLSqlGenerator(); + tapLogger = connectionContext.getLog(); + + fieldDDLHandlers = new BiClassHandlers<>(); + fieldDDLHandlers.register(TapNewFieldEvent.class, this::newField); + fieldDDLHandlers.register(TapAlterFieldAttributesEvent.class, this::alterFieldAttr); + fieldDDLHandlers.register(TapAlterFieldNameEvent.class, this::alterFieldName); + fieldDDLHandlers.register(TapDropFieldEvent.class, this::dropField); + + exceptionCollector = new AbstractExceptionCollector() { + }; + } + + private void writeRecord(TapConnectorContext connectorContext, List tapRecordEvents, TapTable tapTable, Consumer> writeListResultConsumer) throws SQLException { + String insertDmlPolicy = connectorContext.getConnectorCapabilities().getCapabilityAlternative(ConnectionOptions.DML_INSERT_POLICY); + if (insertDmlPolicy == null) { + insertDmlPolicy = ConnectionOptions.DML_INSERT_POLICY_UPDATE_ON_EXISTS; + } + String updateDmlPolicy = connectorContext.getConnectorCapabilities().getCapabilityAlternative(ConnectionOptions.DML_UPDATE_POLICY); + if (updateDmlPolicy == null) { + updateDmlPolicy = ConnectionOptions.DML_UPDATE_POLICY_IGNORE_ON_NON_EXISTS; + } + new SnowflakeRecordWriter(snowflakeJdbcContext, tapTable) + .setInsertPolicy(insertDmlPolicy) + .setUpdatePolicy(updateDmlPolicy) + .setTapLogger(tapLogger) + .write(tapRecordEvents, writeListResultConsumer, this::isAlive); + } + + @Override + protected void processDataMap(DataMap dataMap, TapTable tapTable) { + for (Map.Entry entry : dataMap.entrySet()) { + Object value = entry.getValue(); + if (value instanceof Timestamp) { + if (!tapTable.getNameFieldMap().get(entry.getKey()).getDataType().startsWith("TIMESTAMP_TZ")) { + entry.setValue(((Timestamp) value).toLocalDateTime().minusHours(snowflakeConfig.getZoneOffsetHour())); + } else { + entry.setValue(((Timestamp) value).toLocalDateTime().minusHours(TimeZone.getDefault().getRawOffset() / 3600000).atZone(ZoneOffset.UTC)); + } + } else if (value instanceof Date) { + entry.setValue(Instant.ofEpochMilli(((Date) value).getTime()).atZone(ZoneId.systemDefault()).toLocalDateTime()); + } else if (value instanceof Time) { + entry.setValue(Instant.ofEpochMilli(((Time) value).getTime()).atZone(ZoneId.systemDefault()).toLocalDateTime().minusHours(snowflakeConfig.getZoneOffsetHour())); + } + } + } +} + diff --git a/connectors/snowflake-connector/src/main/java/io/tapdata/connector/snowflake/SnowflakeJdbcContext.java b/connectors/snowflake-connector/src/main/java/io/tapdata/connector/snowflake/SnowflakeJdbcContext.java new file mode 100644 index 000000000..da8994d74 --- /dev/null +++ b/connectors/snowflake-connector/src/main/java/io/tapdata/connector/snowflake/SnowflakeJdbcContext.java @@ -0,0 +1,228 @@ +package io.tapdata.connector.snowflake; + +import io.tapdata.common.JdbcContext; +import io.tapdata.connector.snowflake.config.SnowflakeConfig; +import io.tapdata.entity.logger.TapLogger; +import io.tapdata.entity.utils.DataMap; +import io.tapdata.kit.DbKit; +import io.tapdata.kit.EmptyKit; +import io.tapdata.kit.StringKit; + +import java.sql.*; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicReference; +import java.util.stream.Collectors; + +import static io.tapdata.entity.simplify.TapSimplify.list; + +/** + * Snowflake JDBC Context + *

+ * Snowflake uses INFORMATION_SCHEMA views which are different from PostgreSQL's system catalogs + * + * @author Jarad + * @date 2026/03/24 + */ +public class SnowflakeJdbcContext extends JdbcContext { + + private final static String TAG = SnowflakeJdbcContext.class.getSimpleName(); + + public SnowflakeJdbcContext(SnowflakeConfig config) { + super(config); + } + + @Override + public String queryVersion() { + AtomicReference version = new AtomicReference<>(""); + try { + queryWithNext("SELECT CURRENT_VERSION()", resultSet -> version.set(resultSet.getString(1))); + } catch (Throwable e) { + throw new RuntimeException(e); + } + return version.get(); + } + + @Override + public Long queryTimestamp() throws SQLException { + AtomicReference currentTime = new AtomicReference<>(); + queryWithNext("SELECT CURRENT_TIMESTAMP()", resultSet -> currentTime.set(resultSet.getTimestamp(1))); + return currentTime.get().getTime(); + } + + @Override + protected String queryAllTablesSql(String schema, List tableNames) { + String tableSql = EmptyKit.isNotEmpty(tableNames) ? + "AND TABLE_NAME IN ('" + tableNames.stream().map(v -> StringKit.escape(v, "'")).collect(Collectors.joining("','")) + "')" : ""; + + return String.format(SNOWFLAKE_ALL_TABLES, + StringKit.escape(getConfig().getDatabase(), "'"), + StringKit.escape(schema, "'"), + tableSql); + } + + @Override + protected String queryAllColumnsSql(String schema, List tableNames) { + String tableSql = EmptyKit.isNotEmpty(tableNames) ? + "AND TABLE_NAME IN ('" + tableNames.stream().map(v -> StringKit.escape(v, "'")).collect(Collectors.joining("','")) + "')" : ""; + + return String.format(SNOWFLAKE_ALL_COLUMNS, + StringKit.escape(getConfig().getDatabase(), "'"), + StringKit.escape(schema, "'"), + tableSql); + } + + public synchronized List queryAllPks(List tableNames) { + List pkList = list(); + if (EmptyKit.isEmpty(tableNames)) { + return pkList; + } + try ( + Connection connection = getConnection(); + Statement statement = connection.createStatement(); + ) { + statement.execute(SNOWFLAKE_ALL_PK1); + try ( + ResultSet resultSet = statement.executeQuery(String.format(SNOWFLAKE_ALL_PK2, StringKit.escape(getConfig().getSchema(), "'"))) + ) { + pkList.addAll(DbKit.getDataFromResultSet(resultSet)); + } + } catch (SQLException e) { + throw new RuntimeException(e); + } + return pkList; + } + + @Override + protected String queryAllIndexesSql(String schema, List tableNames) { + String tableSql = EmptyKit.isNotEmpty(tableNames) ? + "AND tc.TABLE_NAME IN ('" + tableNames.stream().map(v -> StringKit.escape(v, "'")).collect(Collectors.joining("','")) + "')" : ""; + + return String.format(SNOWFLAKE_ALL_INDEXES, + StringKit.escape(getConfig().getDatabase(), "'"), + StringKit.escape(schema, "'"), + tableSql); + } + + @Override + protected String queryAllForeignKeysSql(String schema, List tableNames) { + // Snowflake doesn't enforce foreign keys, but they can be defined for metadata + String tableSql = EmptyKit.isNotEmpty(tableNames) ? + "AND rc.TABLE_NAME IN ('" + tableNames.stream().map(v -> StringKit.escape(v, "'")).collect(Collectors.joining("','")) + "')" : ""; + + return String.format(SNOWFLAKE_ALL_FOREIGN_KEYS, + StringKit.escape(getConfig().getDatabase(), "'"), + StringKit.escape(schema, "'"), + tableSql); + } + + public DataMap getTableInfo(String tableName) { + DataMap dataMap = DataMap.create(); + List list = new ArrayList<>(); + list.add("row_count"); + list.add("bytes"); + try { + query(String.format(SNOWFLAKE_TABLE_INFO, + StringKit.escape(getConfig().getDatabase(), "'"), + StringKit.escape(getConfig().getSchema(), "'"), + StringKit.escape(tableName, "'")), resultSet -> { + while (resultSet.next()) { + dataMap.putAll(DbKit.getRowFromResultSet(resultSet, list)); + } + }); + } catch (Throwable e) { + TapLogger.error(TAG, "Execute getTableInfo failed, error: " + e.getMessage(), e); + } + return dataMap; + } + + // Snowflake SQL queries using INFORMATION_SCHEMA + + protected final static String SNOWFLAKE_ALL_TABLES = + "SELECT " + + " TABLE_NAME AS \"tableName\", " + + " COMMENT AS \"tableComment\" " + + "FROM INFORMATION_SCHEMA.TABLES " + + "WHERE TABLE_CATALOG = '%s' " + + " AND TABLE_SCHEMA = '%s' " + + " AND TABLE_TYPE = 'BASE TABLE' " + + " %s " + + "ORDER BY TABLE_NAME"; + + protected final static String SNOWFLAKE_ALL_COLUMNS = + "SELECT " + + " TABLE_NAME AS \"tableName\", " + + " COLUMN_NAME AS \"columnName\", " + + " DATA_TYPE AS \"dataType\", " + + " COLUMN_DEFAULT AS \"columnDefault\", " + + " IS_NULLABLE AS \"nullable\", " + + " IS_IDENTITY AS \"autoInc\", " + + " IDENTITY_START AS \"seedValue\", " + + " IDENTITY_INCREMENT AS \"incrementValue\", " + + " CHARACTER_MAXIMUM_LENGTH AS \"dataLength\", " + + " NUMERIC_PRECISION AS \"dataPrecision\", " + + " NUMERIC_SCALE AS \"dataScale\", " + + " DATETIME_PRECISION AS \"dataFraction\", " + + " COMMENT AS \"columnComment\", " + + " ORDINAL_POSITION AS \"ordinalPosition\" " + + "FROM INFORMATION_SCHEMA.COLUMNS " + + "WHERE TABLE_CATALOG = '%s' " + + " AND TABLE_SCHEMA = '%s' " + + " %s " + + "ORDER BY TABLE_NAME, ORDINAL_POSITION"; + + protected final static String SNOWFLAKE_ALL_PK1 = "SHOW PRIMARY KEYS IN DATABASE"; + + protected final static String SNOWFLAKE_ALL_PK2 = "SELECT\n" + + " \"table_name\" as \"tableName\",\n" + + " \"constraint_name\" as \"constraintName\",\n" + + " \"column_name\" as \"columnName\",\n" + + " \"key_sequence\" as \"KEYSEQ\"\n" + + "FROM TABLE(RESULT_SCAN(LAST_QUERY_ID())) WHERE \"schema_name\" = '%s' ORDER BY \"table_name\", \"key_sequence\""; + + protected final static String SNOWFLAKE_ALL_INDEXES = + "SELECT " + + " tc.TABLE_NAME AS \"tableName\", " + + " tc.CONSTRAINT_NAME AS \"indexName\", " + + " kcu.COLUMN_NAME AS \"columnName\", " + + " CASE WHEN tc.CONSTRAINT_TYPE = 'PRIMARY KEY' THEN '1' ELSE '0' END AS \"isPk\", " + + " CASE WHEN tc.CONSTRAINT_TYPE IN ('PRIMARY KEY', 'UNIQUE') THEN '1' ELSE '0' END AS \"isUnique\" " + + "FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS tc " + + "JOIN INFORMATION_SCHEMA.KEY_COLUMN_USAGE kcu " + + " ON tc.CONSTRAINT_NAME = kcu.CONSTRAINT_NAME " + + " AND tc.TABLE_SCHEMA = kcu.TABLE_SCHEMA " + + " AND tc.TABLE_NAME = kcu.TABLE_NAME " + + "WHERE tc.TABLE_CATALOG = '%s' " + + " AND tc.TABLE_SCHEMA = '%s' " + + " AND tc.CONSTRAINT_TYPE IN ('PRIMARY KEY', 'UNIQUE') " + + " %s " + + "ORDER BY tc.TABLE_NAME, tc.CONSTRAINT_NAME, kcu.ORDINAL_POSITION"; + + protected final static String SNOWFLAKE_ALL_FOREIGN_KEYS = + "SELECT " + + " rc.CONSTRAINT_NAME AS \"constraintName\", " + + " rc.TABLE_NAME AS \"tableName\", " + + " rc.REFERENCED_TABLE_NAME AS \"referencesTableName\", " + + " kcu.COLUMN_NAME AS \"fk\", " + + " kcu.REFERENCED_COLUMN_NAME AS \"rfk\", " + + " rc.UPDATE_RULE AS \"onUpdate\", " + + " rc.DELETE_RULE AS \"onDelete\" " + + "FROM INFORMATION_SCHEMA.REFERENTIAL_CONSTRAINTS rc " + + "JOIN INFORMATION_SCHEMA.KEY_COLUMN_USAGE kcu " + + " ON rc.CONSTRAINT_NAME = kcu.CONSTRAINT_NAME " + + " AND rc.TABLE_SCHEMA = kcu.TABLE_SCHEMA " + + "WHERE rc.CONSTRAINT_CATALOG = '%s' " + + " AND rc.CONSTRAINT_SCHEMA = '%s' " + + " %s " + + "ORDER BY rc.CONSTRAINT_NAME"; + + protected final static String SNOWFLAKE_TABLE_INFO = + "SELECT " + + " ROW_COUNT AS \"row_count\", " + + " BYTES AS \"bytes\" " + + "FROM INFORMATION_SCHEMA.TABLES " + + "WHERE TABLE_CATALOG = '%s' " + + " AND TABLE_SCHEMA = '%s' " + + " AND TABLE_NAME = '%s'"; +} + diff --git a/connectors/snowflake-connector/src/main/java/io/tapdata/connector/snowflake/SnowflakeSqlMaker.java b/connectors/snowflake-connector/src/main/java/io/tapdata/connector/snowflake/SnowflakeSqlMaker.java new file mode 100644 index 000000000..55983b74e --- /dev/null +++ b/connectors/snowflake-connector/src/main/java/io/tapdata/connector/snowflake/SnowflakeSqlMaker.java @@ -0,0 +1,18 @@ +package io.tapdata.connector.snowflake; + +import io.tapdata.common.CommonSqlMaker; + +/** + * Snowflake SQL Maker + * + * @author Jarad + * @date 2026/03/24 + */ +public class SnowflakeSqlMaker extends CommonSqlMaker { + + public SnowflakeSqlMaker() { + // Snowflake uses double quotes for identifiers + super('"'); + } +} + diff --git a/connectors/snowflake-connector/src/main/java/io/tapdata/connector/snowflake/SnowflakeTest.java b/connectors/snowflake-connector/src/main/java/io/tapdata/connector/snowflake/SnowflakeTest.java new file mode 100644 index 000000000..dd68610c9 --- /dev/null +++ b/connectors/snowflake-connector/src/main/java/io/tapdata/connector/snowflake/SnowflakeTest.java @@ -0,0 +1,65 @@ +package io.tapdata.connector.snowflake; + +import io.tapdata.common.CommonDbTest; +import io.tapdata.connector.snowflake.config.SnowflakeConfig; +import io.tapdata.constant.DbTestItem; +import io.tapdata.pdk.apis.entity.TestItem; +import io.tapdata.pdk.apis.exception.testItem.TapTestHostPortEx; +import io.tapdata.util.NetUtil; + +import java.io.IOException; +import java.util.function.Consumer; + +import static io.tapdata.base.ConnectorBase.testItem; + +/** + * Snowflake Connection Test + * + * @author Jarad + * @date 2026/03/24 + */ +public class SnowflakeTest extends CommonDbTest { + + public SnowflakeTest(SnowflakeConfig config, Consumer consumer) { + super(config, consumer); + jdbcContext = new SnowflakeJdbcContext(config); + } + + protected Boolean testHostPort() { + try { + NetUtil.validateHostPortWithSocket(((SnowflakeConfig) commonDbConfig).getAccount() + ".snowflakecomputing.com", 443); + consumer.accept(testItem(DbTestItem.HOST_PORT.getContent(), TestItem.RESULT_SUCCESSFULLY, + String.format(TEST_HOST_PORT_MESSAGE, ((SnowflakeConfig) commonDbConfig).getAccount() + ".snowflakecomputing.com", 443))); + return true; + } catch (IOException e) { + consumer.accept(new TestItem(DbTestItem.HOST_PORT.getContent(), new TapTestHostPortEx(e, ((SnowflakeConfig) commonDbConfig).getAccount() + ".snowflakecomputing.com", "443"), TestItem.RESULT_FAILED)); + return false; + } + } + + private void testWarehouse() { +// TestItem testItem = new TestItem("Test Warehouse", TestItem.RESULT_SUCCESSFULLY, null); +// if (connection == null) { +// testItem.setResult(TestItem.RESULT_FAILED); +// testItem.setInformation("Connection not established"); +// consumer.accept(testItem); +// return; +// } +// +// try (Statement stmt = connection.createStatement()) { +// String sql = "SHOW WAREHOUSES LIKE '" + config.getWarehouse() + "'"; +// ResultSet rs = stmt.executeQuery(sql); +// if (rs.next()) { +// testItem.setResult(TestItem.RESULT_SUCCESSFULLY); +// } else { +// testItem.setResult(TestItem.RESULT_FAILED); +// testItem.setInformation("Warehouse '" + config.getWarehouse() + "' not found"); +// } +// } catch (Exception e) { +// testItem.setResult(TestItem.RESULT_FAILED); +// testItem.setInformation("Failed to access warehouse: " + e.getMessage()); +// } +// consumer.accept(testItem); + } +} + diff --git a/connectors/snowflake-connector/src/main/java/io/tapdata/connector/snowflake/config/SnowflakeConfig.java b/connectors/snowflake-connector/src/main/java/io/tapdata/connector/snowflake/config/SnowflakeConfig.java new file mode 100644 index 000000000..b8e03f2e0 --- /dev/null +++ b/connectors/snowflake-connector/src/main/java/io/tapdata/connector/snowflake/config/SnowflakeConfig.java @@ -0,0 +1,58 @@ +package io.tapdata.connector.snowflake.config; + +import io.tapdata.common.CommonDbConfig; + +/** + * Snowflake Configuration + * + * @author Jarad + * @date 2026/03/24 + */ +public class SnowflakeConfig extends CommonDbConfig { + + private String account; + private String warehouse; + private String role; + + public SnowflakeConfig() { + setDbType("snowflake"); + setJdbcDriver("net.snowflake.client.jdbc.SnowflakeDriver"); + setEscapeChar('"'); + } + + @Override + public String getConnectionString() { + return "jdbc:snowflake://" + account + ".snowflakecomputing.com:443"; + } + + public String getDatabaseUrl() { + return "jdbc:snowflake://" + account + ".snowflakecomputing.com:443" + + "?warehouse=" + warehouse + + "&db=" + getDatabase(); + } + + public String getAccount() { + return account; + } + + public void setAccount(String account) { + this.account = account; + } + + public String getWarehouse() { + return warehouse; + } + + public void setWarehouse(String warehouse) { + this.warehouse = warehouse; + } + + public String getRole() { + return role; + } + + public void setRole(String role) { + this.role = role; + } +} + diff --git a/connectors/snowflake-connector/src/main/java/io/tapdata/connector/snowflake/dml/SnowflakeRecordWriter.java b/connectors/snowflake-connector/src/main/java/io/tapdata/connector/snowflake/dml/SnowflakeRecordWriter.java new file mode 100644 index 000000000..ee6c769a4 --- /dev/null +++ b/connectors/snowflake-connector/src/main/java/io/tapdata/connector/snowflake/dml/SnowflakeRecordWriter.java @@ -0,0 +1,38 @@ +package io.tapdata.connector.snowflake.dml; + +import io.tapdata.common.JdbcContext; +import io.tapdata.common.dml.NormalRecordWriter; +import io.tapdata.common.exception.AbstractExceptionCollector; +import io.tapdata.entity.schema.TapTable; + +import java.sql.Connection; +import java.sql.SQLException; + +public class SnowflakeRecordWriter extends NormalRecordWriter { + public SnowflakeRecordWriter(JdbcContext jdbcContext, TapTable tapTable) throws SQLException { + super(jdbcContext, tapTable); + exceptionCollector = new AbstractExceptionCollector() { + }; + insertRecorder = new SnowflakeWriteRecorder(connection, tapTable, jdbcContext.getConfig().getSchema()); + updateRecorder = new SnowflakeWriteRecorder(connection, tapTable, jdbcContext.getConfig().getSchema()); + deleteRecorder = new SnowflakeWriteRecorder(connection, tapTable, jdbcContext.getConfig().getSchema()); + } + + public SnowflakeRecordWriter(JdbcContext jdbcContext, TapTable tapTable, boolean largeSql) throws SQLException { + super(jdbcContext, tapTable, largeSql); + exceptionCollector = new AbstractExceptionCollector() { + }; + insertRecorder = new SnowflakeWriteRecorder(connection, tapTable, jdbcContext.getConfig().getSchema()); + updateRecorder = new SnowflakeWriteRecorder(connection, tapTable, jdbcContext.getConfig().getSchema()); + deleteRecorder = new SnowflakeWriteRecorder(connection, tapTable, jdbcContext.getConfig().getSchema()); + } + + public SnowflakeRecordWriter(JdbcContext jdbcContext, Connection connection, TapTable tapTable) { + super(jdbcContext, connection, tapTable); + exceptionCollector = new AbstractExceptionCollector() { + }; + insertRecorder = new SnowflakeWriteRecorder(connection, tapTable, jdbcContext.getConfig().getSchema()); + updateRecorder = new SnowflakeWriteRecorder(connection, tapTable, jdbcContext.getConfig().getSchema()); + deleteRecorder = new SnowflakeWriteRecorder(connection, tapTable, jdbcContext.getConfig().getSchema()); + } +} diff --git a/connectors/snowflake-connector/src/main/java/io/tapdata/connector/snowflake/dml/SnowflakeWriteRecorder.java b/connectors/snowflake-connector/src/main/java/io/tapdata/connector/snowflake/dml/SnowflakeWriteRecorder.java new file mode 100644 index 000000000..8010d5fbb --- /dev/null +++ b/connectors/snowflake-connector/src/main/java/io/tapdata/connector/snowflake/dml/SnowflakeWriteRecorder.java @@ -0,0 +1,18 @@ +package io.tapdata.connector.snowflake.dml; + +import io.tapdata.common.dml.MergeWriteRecorder; +import io.tapdata.entity.schema.TapTable; + +import java.sql.Connection; + +public class SnowflakeWriteRecorder extends MergeWriteRecorder { + + public SnowflakeWriteRecorder(Connection connection, TapTable tapTable, String schema) { + super(connection, tapTable, schema); + } + + protected String getSystemVirtualTable() { + return "(select 1)"; + } + +} diff --git a/connectors/snowflake-connector/src/main/resources/docs/snowflake_en_US.md b/connectors/snowflake-connector/src/main/resources/docs/snowflake_en_US.md new file mode 100644 index 000000000..e69de29bb diff --git a/connectors/snowflake-connector/src/main/resources/docs/snowflake_zh_CN.md b/connectors/snowflake-connector/src/main/resources/docs/snowflake_zh_CN.md new file mode 100644 index 000000000..e69de29bb diff --git a/connectors/snowflake-connector/src/main/resources/docs/snowflake_zh_TW.md b/connectors/snowflake-connector/src/main/resources/docs/snowflake_zh_TW.md new file mode 100644 index 000000000..e69de29bb diff --git a/connectors/snowflake-connector/src/main/resources/icons/snowflake.png b/connectors/snowflake-connector/src/main/resources/icons/snowflake.png new file mode 100644 index 000000000..560ad700f Binary files /dev/null and b/connectors/snowflake-connector/src/main/resources/icons/snowflake.png differ diff --git a/connectors/snowflake-connector/src/main/resources/spec_snowflake.json b/connectors/snowflake-connector/src/main/resources/spec_snowflake.json new file mode 100644 index 000000000..8fa020e05 --- /dev/null +++ b/connectors/snowflake-connector/src/main/resources/spec_snowflake.json @@ -0,0 +1,401 @@ +{ + "properties": { + "name": "Snowflake", + "realName": "Snowflake", + "icon": "icons/snowflake.png", + "doc": "${doc}", + "id": "snowflake", + "tags": ["Database", "Cloud", "DataWarehouse"] + }, + "configOptions": { + "capabilities": [ + { + "id": "dml_insert_policy", + "alternatives": ["update_on_exists", "ignore_on_exists", "just_insert"] + }, + { + "id": "dml_update_policy", + "alternatives": ["ignore_on_nonexists", "insert_on_nonexists", "log_on_nonexists"] + }, + { + "id": "dml_delete_policy", + "alternatives": ["ignore_on_nonexists", "log_on_nonexists"] + }, + { + "id": "api_server_supported" + } + ], + "connection": { + "type": "object", + "properties": { + "account": { + "type": "string", + "title": "${account}", + "x-decorator": "FormItem", + "x-component": "Input", + "apiServerKey": "database_host", + "x-index": 1, + "required": true + }, + "user": { + "type": "string", + "title": "${user}", + "x-decorator": "FormItem", + "x-component": "Input", + "apiServerKey": "database_username", + "x-index": 2, + "required": true + }, + "password": { + "type": "string", + "title": "${password}", + "x-decorator": "FormItem", + "x-component": "Password", + "apiServerKey": "database_password", + "x-index": 3, + "required": true + }, + "warehouse": { + "type": "string", + "title": "${warehouse}", + "x-decorator": "FormItem", + "x-component": "Input", + "x-index": 4, + "required": true + }, + "database": { + "type": "string", + "title": "${database}", + "x-decorator": "FormItem", + "x-component": "Input", + "apiServerKey": "database_name", + "x-index": 5, + "required": true + }, + "schema": { + "type": "string", + "title": "${schema}", + "default": "PUBLIC", + "x-decorator": "FormItem", + "x-component": "Input", + "apiServerKey": "database_owner", + "x-index": 6, + "required": true + }, + "role": { + "type": "string", + "title": "${role}", + "x-decorator": "FormItem", + "x-component": "Input", + "x-index": 7 + }, + "timezone": { + "type": "string", + "title": "${timezone}", + "default": "+00:00", + "x-decorator": "FormItem", + "x-component": "Select", + "x-index": 8, + "enum": [ + { + "label": "UTC -11", + "value": "-11:00" + }, + { + "label": "UTC -10", + "value": "-10:00" + }, + { + "label": "UTC -09", + "value": "-09:00" + }, + { + "label": "UTC -08", + "value": "-08:00" + }, + { + "label": "UTC -07", + "value": "-07:00" + }, + { + "label": "UTC -06", + "value": "-06:00" + }, + { + "label": "UTC -05", + "value": "-05:00" + }, + { + "label": "UTC -04", + "value": "-04:00" + }, + { + "label": "UTC -03", + "value": "-03:00" + }, + { + "label": "UTC -02", + "value": "-02:00" + }, + { + "label": "UTC -01", + "value": "-01:00" + }, + { + "label": "UTC", + "value": "+00:00" + }, + { + "label": "UTC +01", + "value": "+01:00" + }, + { + "label": "UTC +02", + "value": "+02:00" + }, + { + "label": "UTC +03", + "value": "+03:00" + }, + { + "label": "UTC +04", + "value": "+04:00" + }, + { + "label": "UTC +05", + "value": "+05:00" + }, + { + "label": "UTC +06", + "value": "+06:00" + }, + { + "label": "UTC +07", + "value": "+07:00" + }, + { + "label": "UTC +08", + "value": "+08:00" + } + ] + } + } + } + }, + "messages": { + "default": "en_US", + "en_US": { + "doc": "docs/snowflake_en_US.md", + "account": "Account", + "user": "User", + "password": "Password", + "warehouse": "Warehouse", + "database": "Database", + "schema": "Schema", + "role": "Role", + "timezone": "Timezone" + }, + "zh_CN": { + "doc": "docs/snowflake_zh_CN.md", + "account": "账户", + "user": "用户名", + "password": "密码", + "warehouse": "仓库", + "database": "数据库", + "schema": "模式", + "role": "角色", + "timezone": "时区" + }, + "zh_TW": { + "doc": "docs/snowflake_zh_TW.md", + "account": "賬戶", + "user": "用戶名", + "password": "密碼", + "warehouse": "倉庫", + "database": "數據庫", + "schema": "模式", + "role": "角色", + "timezone": "時區" + } + }, + "dataTypes": { + "BIGINT": { + "to": "TapNumber", + "bit": 64, + "precision": 19, + "value": [ + -9223372036854775808, + 9223372036854775807 + ] + }, + "NUMBER[($precision,$scale)]": { + "precision": [ + 1, + 38 + ], + "scale": [ + 0, + 37 + ], + "fixed": true, + "preferPrecision": 12, + "preferScale": 4, + "defaultPrecision": 38, + "defaultScale": 0, + "priority": 1, + "to": "TapNumber" + }, + "FLOAT": { + "priority": 2, + "precision": [ + 1, + 17 + ], + "preferPrecision": 11, + "preferScale": 4, + "scale": [ + 0, + 17 + ], + "fixed": false, + "to": "TapNumber" + }, + "DECFLOAT": { + "precision": [ + 1, + 17 + ], + "preferPrecision": 11, + "preferScale": 4, + "scale": [ + 0, + 17 + ], + "fixed": false, + "to": "TapNumber", + "queryOnly": true + }, + "TEXT[($byte)]": { + "byte": 134217728, + "priority": 1, + "defaultByte": 16777216, + "preferByte": 255, + "to": "TapString" + }, + "BINARY": { + "byte": "4g", + "priority": 1, + "to": "TapBinary" + }, + "BOOLEAN": { + "bit": 1, + "priority": 1, + "to": "TapBoolean" + }, + "DATE": { + "range": [ + "0001-01-01", + "9999-12-31" + ], + "pattern": "yyyy-MM-dd", + "priority": 1, + "to": "TapDate" + }, + "TIME[($fraction)]": { + "range": [ + "00:00:00", + "23:59:59" + ], + "pattern": "HH:mm:ss", + "fraction": [ + 0, + 9 + ], + "defaultFraction": 9, + "priority": 1, + "to": "TapTime" + }, + "TIMESTAMP_NTZ[($fraction)]": { + "range": [ + "0001-01-01 00:00:00", + "9999-12-31 23:59:59" + ], + "pattern": "yyyy-MM-dd HH:mm:ss", + "fraction": [ + 0, + 9 + ], + "withTimeZone": false, + "defaultFraction": 9, + "priority": 1, + "to": "TapDateTime" + }, + "TIMESTAMP_TZ[($fraction)]": { + "range": [ + "0001-01-01 00:00:00", + "9999-12-31 23:59:59" + ], + "pattern": "yyyy-MM-dd HH:mm:ss", + "fraction": [ + 0, + 9 + ], + "withTimeZone": true, + "defaultFraction": 9, + "to": "TapDateTime" + }, + "TIMESTAMP_LTZ[($fraction)]": { + "range": [ + "0001-01-01 00:00:00", + "9999-12-31 23:59:59" + ], + "pattern": "yyyy-MM-dd HH:mm:ss", + "fraction": [ + 0, + 9 + ], + "withTimeZone": true, + "queryOnly": true, + "defaultFraction": 9, + "to": "TapDateTime" + }, + "VARIANT": { + "byte": 16777216, + "priority": 2, + "queryOnly": true, + "to": "TapMap" + }, + "OBJECT": { + "byte": 16777216, + "priority": 1, + "to": "TapMap" + }, + "ARRAY": { + "byte": 16777216, + "priority": 1, + "to": "TapArray" + }, + "GEOGRAPHY": { + "byte": 16777216, + "priority": 1, + "queryOnly": true, + "to": "TapString" + }, + "GEOMETRY": { + "byte": 16777216, + "priority": 2, + "queryOnly": true, + "to": "TapString" + }, + "UUID": { + "to": "TapString", + "preferByte": 256, + "queryOnly": true + }, + "FILE": { + "to": "TapInputStream" + } + } +} + + diff --git a/connectors/starrocks-connector/pom.xml b/connectors/starrocks-connector/pom.xml index a353a052a..a43c1a0d1 100644 --- a/connectors/starrocks-connector/pom.xml +++ b/connectors/starrocks-connector/pom.xml @@ -21,7 +21,7 @@ 31.0.1-jre 1.0-SNAPSHOT - 2.0.0-SNAPSHOT + 2.0.7-SNAPSHOT diff --git a/connectors/starrocks-connector/src/main/java/io/tapdata/connector/starrocks/StarrocksConnector.java b/connectors/starrocks-connector/src/main/java/io/tapdata/connector/starrocks/StarrocksConnector.java index 7475028f5..73188f157 100644 --- a/connectors/starrocks-connector/src/main/java/io/tapdata/connector/starrocks/StarrocksConnector.java +++ b/connectors/starrocks-connector/src/main/java/io/tapdata/connector/starrocks/StarrocksConnector.java @@ -9,6 +9,9 @@ import io.tapdata.connector.starrocks.streamload.StarrocksTableType; import io.tapdata.connector.starrocks.streamload.exception.StarrocksRetryableException; import io.tapdata.entity.codec.TapCodecsRegistry; +import io.tapdata.entity.event.TapCallbackOffset; +import io.tapdata.entity.event.control.ControlEvent; +import io.tapdata.entity.event.control.HeartbeatEvent; import io.tapdata.entity.event.ddl.table.*; import io.tapdata.entity.event.dml.TapRecordEvent; import io.tapdata.entity.schema.TapField; @@ -19,6 +22,7 @@ import io.tapdata.entity.utils.DataMap; import io.tapdata.kit.EmptyKit; import io.tapdata.kit.ErrorKit; +import io.tapdata.kit.StringKit; import io.tapdata.pdk.apis.annotations.TapConnectorClass; import io.tapdata.pdk.apis.context.TapConnectionContext; import io.tapdata.pdk.apis.context.TapConnectorContext; @@ -53,6 +57,7 @@ public class StarrocksConnector extends CommonDbConnector { private StarrocksJdbcContext starrocksJdbcContext; private StarrocksConfig starrocksConfig; private final Map starrocksStreamLoaderMap = new ConcurrentHashMap<>(); + private Consumer flushOffsetCallback; @Override @@ -75,8 +80,13 @@ public void onStart(TapConnectionContext tapConnectionContext) { fieldDDLHandlers = new BiClassHandlers<>(); fieldDDLHandlers.register(TapNewFieldEvent.class, this::newField); fieldDDLHandlers.register(TapAlterFieldAttributesEvent.class, this::alterFieldAttr); - fieldDDLHandlers.register(TapAlterFieldNameEvent.class, this::alterFieldName); fieldDDLHandlers.register(TapDropFieldEvent.class, this::dropField); + + // 保存 flush offset callback + this.flushOffsetCallback = tapConnectionContext.getFlushOffsetCallback(); + if (this.flushOffsetCallback != null) { + tapLogger.info("Flush offset callback registered for StarRocks connector"); + } } @@ -106,6 +116,7 @@ public void registerCapabilities(ConnectorFunctions connectorFunctions, TapCodec connectorFunctions.supportDropTable(this::dropTable); connectorFunctions.supportQueryByFilter(this::queryByFilter); connectorFunctions.supportExecuteCommandFunction((a, b, c) -> SqlExecuteCommandFunction.executeCommand(a, b, () -> starrocksJdbcContext.getConnection(), this::isAlive, c)); + connectorFunctions.supportProcessControlFunction(this::processControl); codecRegistry.registerFromTapValue(TapRawValue.class, "text", tapRawValue -> { if (tapRawValue != null && tapRawValue.getValue() != null) @@ -155,10 +166,10 @@ public void registerCapabilities(ConnectorFunctions connectorFunctions, TapCodec } }); codecRegistry.registerFromTapValue(TapDateValue.class, tapDateValue -> tapDateValue.getValue().toSqlDate()); + codecRegistry.registerFromTapValue(TapMoneyValue.class, "decimal(10,4)", TapValue::getValue); connectorFunctions.supportErrorHandleFunction(this::errorHandle); connectorFunctions.supportGetTableInfoFunction(this::getTableInfo); connectorFunctions.supportNewFieldFunction(this::fieldDDLHandler); - connectorFunctions.supportAlterFieldNameFunction(this::fieldDDLHandler); connectorFunctions.supportAlterFieldAttributesFunction(this::fieldDDLHandler); connectorFunctions.supportDropFieldFunction(this::fieldDDLHandler); @@ -169,6 +180,15 @@ protected RetryOptions errorHandle(TapConnectionContext tapConnectionContext, PD if (null != matchThrowable(throwable, StarrocksRetryableException.class) || null != matchThrowable(throwable, IOException.class)) { retryOptions.needRetry(true); + retryOptions.beforeRetryMethod(() -> { + // 重试前等待一段时间,避免立即重试打满日志 + tapLogger.info("Connection error detected, waiting 5s before retry..."); + try { + Thread.sleep(5000); + } catch (InterruptedException ignored) { + Thread.currentThread().interrupt(); + } + }); return retryOptions; } return retryOptions; @@ -178,7 +198,8 @@ public StarrocksStreamLoader getStarrocksStreamLoader() { String threadName = Thread.currentThread().getName(); if (!starrocksStreamLoaderMap.containsKey(threadName)) { StarrocksJdbcContext context = new StarrocksJdbcContext(starrocksConfig); - StarrocksStreamLoader StarrocksStreamLoader = new StarrocksStreamLoader(context, new HashMap<>(), starrocksConfig.getUseHTTPS(), tapLogger); + StarrocksStreamLoader StarrocksStreamLoader = new StarrocksStreamLoader(context, starrocksConfig.getUseHTTPS(), tapLogger); + StarrocksStreamLoader.setFlushOffsetCallback(flushOffsetCallback); starrocksStreamLoaderMap.put(threadName, StarrocksStreamLoader); } return starrocksStreamLoaderMap.get(threadName); @@ -334,16 +355,16 @@ protected TapField makeTapField(DataMap dataMap) { @Override public void onStop(TapConnectionContext connectionContext) { ErrorKit.ignoreAnyError(() -> { - for (StarrocksStreamLoader StarrocksStreamLoader : starrocksStreamLoaderMap.values()) { - if (EmptyKit.isNotNull(StarrocksStreamLoader)) { + for (StarrocksStreamLoader starrocksStreamLoader : starrocksStreamLoaderMap.values()) { + if (EmptyKit.isNotNull(starrocksStreamLoader)) { // 在停止前先刷新剩余数据 try { - StarrocksStreamLoader.flushOnStop(); + starrocksStreamLoader.flushOnStop(); tapLogger.info("StarrocksConnector", "Flushed remaining data before stopping StarrocksStreamLoader"); } catch (Exception e) { tapLogger.warn("StarrocksConnector", "Failed to flush data before stopping: {}", e.getMessage()); } - StarrocksStreamLoader.shutdown(); + starrocksStreamLoader.shutdown(); } } }); @@ -368,6 +389,22 @@ protected void fieldDDLHandler(TapConnectorContext tapConnectorContext, TapField if (null == sqlList) { return; } + //执行ddl前需要将缓存的数据先flush,清空StarrocksStreamLoaderMap对象 + ErrorKit.ignoreAnyError(() -> { + for (StarrocksStreamLoader starrocksStreamLoader : starrocksStreamLoaderMap.values()) { + if (EmptyKit.isNotNull(starrocksStreamLoader)) { + // 在停止前先刷新剩余数据 + try { + starrocksStreamLoader.flushOnStop(); + tapLogger.info("StarrocksConnector", "Flushed remaining data before stopping StarrocksStreamLoader"); + } catch (Exception e) { + tapLogger.warn("StarrocksConnector", "Failed to flush data before stopping: {}", e.getMessage()); + } + starrocksStreamLoader.shutdown(); + } + } + starrocksStreamLoaderMap.clear(); + }); try { tapLogger.info("Field ddl sql: {}", sqlList); jdbcContext.batchExecute(sqlList); @@ -380,6 +417,30 @@ protected void fieldDDLHandler(TapConnectorContext tapConnectorContext, TapField } } + protected void processControl(TapConnectorContext tapConnectorContext, ControlEvent controlEvent) { + if (controlEvent instanceof HeartbeatEvent) { + if (starrocksStreamLoaderMap.entrySet().stream().allMatch(v -> EmptyKit.isEmpty(v.getValue().getPendingFlushTables()))) { + TapCallbackOffset tapOffset = new TapCallbackOffset(); + // 从 TapRecordEvent.info 中提取 offset 信息 + // 这些信息由 HazelcastTargetPdkBaseNode.handleTapdataEventDML 方法添加 + Object batchOffset = controlEvent.getInfo("batchOffset"); + Object streamOffset = controlEvent.getInfo("streamOffset"); + Object syncStage = controlEvent.getInfo("syncStage"); + Object sourceTime = controlEvent.getInfo("sourceTime"); + Object nodeIds = controlEvent.getInfo("nodeIds"); + + // 填充 TapOffset + tapOffset.batchOffset(batchOffset) + .streamOffset(streamOffset) + .syncStage(syncStage != null ? syncStage.toString() : null) + .sourceTime(sourceTime instanceof Long ? (Long) sourceTime : null) + .eventTime(((HeartbeatEvent) controlEvent).getReferenceTime()) + .nodeIds(nodeIds); + tapConnectorContext.getFlushOffsetCallback().accept(tapOffset); + } + } + } + @Override protected void processDataMap(DataMap dataMap, TapTable tapTable) { if (!starrocksConfig.getOldVersionTimezone()) { @@ -392,8 +453,13 @@ protected void processDataMap(DataMap dataMap, TapTable tapTable) { entry.setValue(((LocalDateTime) value).minusHours(starrocksConfig.getZoneOffsetHour())); } else if (value instanceof java.sql.Date) { entry.setValue(Instant.ofEpochMilli(((Date) value).getTime()).atZone(ZoneId.systemDefault()).toLocalDateTime()); - } else if (value instanceof String && tapTable.getNameFieldMap().get(entry.getKey()).getDataType().equals("largeint")) { - entry.setValue(new BigDecimal((String) value)); + } else if (value instanceof String) { + String dataType = StringKit.removeParentheses(tapTable.getNameFieldMap().get(entry.getKey()).getDataType()); + if (dataType.equals("largeint")) { + entry.setValue(new BigDecimal((String) value)); + } else if (dataType.contains("binary")) { + entry.setValue(((String) value).getBytes()); + } } } } diff --git a/connectors/starrocks-connector/src/main/java/io/tapdata/connector/starrocks/StarrocksTest.java b/connectors/starrocks-connector/src/main/java/io/tapdata/connector/starrocks/StarrocksTest.java index 6b7eae652..8cc418fb2 100644 --- a/connectors/starrocks-connector/src/main/java/io/tapdata/connector/starrocks/StarrocksTest.java +++ b/connectors/starrocks-connector/src/main/java/io/tapdata/connector/starrocks/StarrocksTest.java @@ -127,9 +127,9 @@ public Boolean testStreamLoadPrivilege() { HttpGet httpGet = new HttpGet(); httpGet.setURI(new URI("https://" + ((StarrocksConfig) commonDbConfig).getStarrocksHttp())); httpGet.setHeader("Authorization", authHeader); - testResult = EntityUtils.toString(client.execute(httpGet).getEntity()).contains("starrocks"); + testResult = EntityUtils.toString(client.execute(httpGet).getEntity()).toLowerCase().contains("starrocks"); } else { - testResult = HttpRequest.get("http://" + ((StarrocksConfig) commonDbConfig).getStarrocksHttp()).header("Authorization", authHeader).execute().body().contains("starrocks"); + testResult = HttpRequest.get("http://" + ((StarrocksConfig) commonDbConfig).getStarrocksHttp()).header("Authorization", authHeader).execute().body().toLowerCase().contains("starrocks"); } if (testResult) { diff --git a/connectors/starrocks-connector/src/main/java/io/tapdata/connector/starrocks/bean/StarrocksConfig.java b/connectors/starrocks-connector/src/main/java/io/tapdata/connector/starrocks/bean/StarrocksConfig.java index 343612abc..ca1c06217 100644 --- a/connectors/starrocks-connector/src/main/java/io/tapdata/connector/starrocks/bean/StarrocksConfig.java +++ b/connectors/starrocks-connector/src/main/java/io/tapdata/connector/starrocks/bean/StarrocksConfig.java @@ -32,7 +32,7 @@ public class StarrocksConfig extends CommonDbConfig { private Integer flushSizeMB = 100; // 新增配置:刷新超时阈值(秒) - private Integer flushTimeoutSeconds = 300; + private Integer flushTimeoutSeconds = 30; // 新增配置:每分钟写入限制(MB,0表示不限制) private Integer minuteLimitMB = 0; diff --git a/connectors/starrocks-connector/src/main/java/io/tapdata/connector/starrocks/ddl/StarrocksDDLSqlGenerator.java b/connectors/starrocks-connector/src/main/java/io/tapdata/connector/starrocks/ddl/StarrocksDDLSqlGenerator.java index b89cb8512..ca0a81dbe 100644 --- a/connectors/starrocks-connector/src/main/java/io/tapdata/connector/starrocks/ddl/StarrocksDDLSqlGenerator.java +++ b/connectors/starrocks-connector/src/main/java/io/tapdata/connector/starrocks/ddl/StarrocksDDLSqlGenerator.java @@ -136,7 +136,7 @@ public List alterColumnName(CommonDbConfig config, TapAlterFieldNameEven throw new RuntimeException("Append alter column name ddl sql failed, new column name is blank"); } String sql = String.format(ALTER_TABLE_PREFIX, config.getDatabase(), tableId); - return Collections.singletonList(sql + " rename column `" + before + "` `" + after + "`"); + return Collections.singletonList(sql + " rename column `" + before + "` to `" + after + "`"); } @Override diff --git a/connectors/starrocks-connector/src/main/java/io/tapdata/connector/starrocks/streamload/HttpUtil.java b/connectors/starrocks-connector/src/main/java/io/tapdata/connector/starrocks/streamload/HttpUtil.java index 2160c0393..61e0d56ab 100644 --- a/connectors/starrocks-connector/src/main/java/io/tapdata/connector/starrocks/streamload/HttpUtil.java +++ b/connectors/starrocks-connector/src/main/java/io/tapdata/connector/starrocks/streamload/HttpUtil.java @@ -32,7 +32,12 @@ public class HttpUtil { protected boolean isRedirectable(String method) { return true; } - }); + }) + .setDefaultRequestConfig(RequestConfig.custom() + .setConnectTimeout(CONNECT_TIMEOUT) + .setSocketTimeout(READ_TIMEOUT) + .setConnectionRequestTimeout(CONNECT_TIMEOUT) + .build()); public CloseableHttpClient getHttpClient() { return httpClientBuilder.build(); diff --git a/connectors/starrocks-connector/src/main/java/io/tapdata/connector/starrocks/streamload/StarrocksStreamLoader.java b/connectors/starrocks-connector/src/main/java/io/tapdata/connector/starrocks/streamload/StarrocksStreamLoader.java index 6626e3484..70e1afb7b 100644 --- a/connectors/starrocks-connector/src/main/java/io/tapdata/connector/starrocks/streamload/StarrocksStreamLoader.java +++ b/connectors/starrocks-connector/src/main/java/io/tapdata/connector/starrocks/streamload/StarrocksStreamLoader.java @@ -8,6 +8,7 @@ import io.tapdata.connector.starrocks.streamload.exception.StreamLoadException; import io.tapdata.connector.starrocks.streamload.rest.models.RespContent; import io.tapdata.connector.starrocks.util.MinuteWriteLimiter; +import io.tapdata.entity.event.TapCallbackOffset; import io.tapdata.entity.event.dml.TapDeleteRecordEvent; import io.tapdata.entity.event.dml.TapInsertRecordEvent; import io.tapdata.entity.event.dml.TapRecordEvent; @@ -36,6 +37,8 @@ import java.util.stream.Collectors; import static io.tapdata.base.ConnectorBase.writeListResult; +import static io.tapdata.entity.event.TapCallbackOffset.KEY_BATCH_OFFSET; +import static io.tapdata.entity.event.TapCallbackOffset.KEY_STREAM_OFFSET; /** * @author jarad @@ -49,7 +52,6 @@ public class StarrocksStreamLoader { private static final String LABEL_PREFIX_PATTERN = "tapdata_%s_%s"; private final StarrocksConfig StarrocksConfig; - private final Map httpClientMap; private final boolean useHttps; private final RecordStream recordStream; @@ -76,6 +78,9 @@ public class StarrocksStreamLoader { // 表名到 TapTable 的映射,用于刷新时获取真正的 TapTable private final Map tableNameToTapTableMap; + // 保存每个表的第一个 TapOffset,用于在 flush 成功后回调 + private final Map firstOffsetByTable; + // 日志打印控制 private long lastLogTime; private static final long LOG_INTERVAL_MS = 30 * 1000; // 30秒 @@ -85,16 +90,20 @@ public class StarrocksStreamLoader { private ScheduledExecutorService flushScheduler; private ScheduledFuture flushTask; + // 表级别的锁,用于 finalizeCacheFileForTable 方法 + private final Map tableLocks = new ConcurrentHashMap<>(); + // 内存监控 private long lastMemoryCheckTime = 0; private static final long MEMORY_CHECK_INTERVAL = 30000; // 30秒检查一次内存 private final Log taplogger; private boolean cannotClean; private AtomicReference globalException = new AtomicReference<>(); + // 回调 flush offset + private Consumer flushOffsetCallback; - public StarrocksStreamLoader(StarrocksJdbcContext StarrocksJdbcContext, Map httpClientMap, boolean useHttps, Log taplogger) { + public StarrocksStreamLoader(StarrocksJdbcContext StarrocksJdbcContext, boolean useHttps, Log taplogger) { this.StarrocksConfig = (StarrocksConfig) StarrocksJdbcContext.getConfig(); - this.httpClientMap = httpClientMap; this.useHttps = useHttps; this.taplogger = taplogger; Integer writeByteBufferCapacity = StarrocksConfig.getWriteByteBufferCapacity(); @@ -123,22 +132,19 @@ public StarrocksStreamLoader(StarrocksJdbcContext StarrocksJdbcContext, Map(); this.pendingFlushTables = ConcurrentHashMap.newKeySet(); this.tableNameToTapTableMap = new ConcurrentHashMap<>(); + this.firstOffsetByTable = Collections.synchronizedMap(new LinkedHashMap<>()); // 初始化定时刷新 initializeFlushScheduler(); } - private CloseableHttpClient getHttpClient(String tableName) { - if (httpClientMap.containsKey(tableName)) { - return httpClientMap.get(tableName); - } + private CloseableHttpClient getHttpClient() { CloseableHttpClient httpClient; if (useHttps) { httpClient = HttpUtil.generationHttpClient(); } else { httpClient = new HttpUtil().getHttpClient(); } - httpClientMap.put(tableName, httpClient); return httpClient; } @@ -425,6 +431,35 @@ public void writeRecord(final List tapRecordEvents, final TapTab long batchDataSize = 0; for (TapRecordEvent tapRecordEvent : tapRecordEvents) { + // 构建 TapOffset 对象,用于在 flush 成功后回调 + TapCallbackOffset tapOffset = new TapCallbackOffset(); + + // 从 TapRecordEvent.info 中提取 offset 信息 + // 这些信息由 HazelcastTargetPdkBaseNode.handleTapdataEventDML 方法添加 + Object batchOffset = tapRecordEvent.getInfo("batchOffset"); + Object streamOffset = tapRecordEvent.getInfo("streamOffset"); + Object syncStage = tapRecordEvent.getInfo("syncStage"); + Object sourceTime = tapRecordEvent.getInfo("sourceTime"); + Object nodeIds = tapRecordEvent.getInfo("nodeIds"); + + // 填充 TapOffset + tapOffset.batchOffset(batchOffset) + .streamOffset(streamOffset) + .tableId(tapRecordEvent.getTableId()) + .syncStage(syncStage != null ? syncStage.toString() : null) + .sourceTime(sourceTime instanceof Long ? (Long) sourceTime : null) + .eventTime(tapRecordEvent.getReferenceTime()) + .nodeIds(nodeIds); + + // 只有当 offset 有效时才保存(避免覆盖之前的有效 offset) + if (tapOffset.hasValidOffset()) { + if (!firstOffsetByTable.containsKey(tableName)) { + firstOffsetByTable.put(tableName, tapOffset); + taplogger.debug("Saved first offset for table {}: streamOffset={}, batchOffset={}", + tableName, streamOffset, batchOffset); + } + } + byte[] bytes = messageSerializer.serialize(table, tapRecordEvent, isAgg); batchDataSize += bytes.length; @@ -591,12 +626,14 @@ private Set getDataColumns(TapRecordEvent recordEvent) { public RespContent put(final TapTable table) throws StreamLoadException, StarrocksRetryableException { StarrocksConfig.WriteFormat writeFormat = StarrocksConfig.getWriteFormatEnum(); + String tableName = table.getId(); try { - final String loadUrl = buildLoadUrl(StarrocksConfig.getStarrocksHttp(), StarrocksConfig.getDatabase(), table.getId()); - final String prefix = buildPrefix(table.getId()); - String tableName = table.getId(); + final String loadUrl = buildLoadUrl(StarrocksConfig.getStarrocksHttp(), StarrocksConfig.getDatabase(), tableName); + final String prefix = buildPrefix(tableName); + + // StarRocks label naming rules: only digits, letters, and underscores are allowed + String label = prefix + "_" + UUID.randomUUID().toString().replace("-", "_"); - String label = prefix + "-" + UUID.randomUUID(); List columns = new ArrayList<>(); // 获取该表的dataColumns @@ -650,7 +687,10 @@ public RespContent put(final TapTable table) throws StreamLoadException, Starroc } } HttpPut httpPut = putBuilder.build(); - try (CloseableHttpResponse execute = getHttpClient(tableName).execute(httpPut)) { + try ( + CloseableHttpClient httpClient = getHttpClient(); + CloseableHttpResponse execute = httpClient.execute(httpPut) + ) { return handlePreCommitResponse(execute); } } catch (StarrocksRetryableException e) { @@ -670,7 +710,8 @@ public RespContent putFromFile(final TapTable table) throws StreamLoadException, final String prefix = buildPrefix(table.getId()); String tableName = table.getId(); - String label = prefix + "-" + UUID.randomUUID(); + // StarRocks label naming rules: only digits, letters, and underscores are allowed + String label = prefix + "_" + UUID.randomUUID().toString().replace("-", "_"); List columns = new ArrayList<>(); // 获取该表的dataColumns @@ -750,7 +791,10 @@ public RespContent putFromFile(final TapTable table) throws StreamLoadException, taplogger.info("====================================="); long requestStartTime = System.currentTimeMillis(); - try (CloseableHttpResponse execute = getHttpClient(tableName).execute(httpPut)) { + try ( + CloseableHttpClient client = getHttpClient(); + CloseableHttpResponse execute = client.execute(httpPut) + ) { long requestEndTime = System.currentTimeMillis(); long requestDuration = requestEndTime - requestStartTime; @@ -859,14 +903,59 @@ public RespContent flushTable(String tableName, TapTable table) throws Starrocks taplogger.info("Updated last flush time for table {}: {} -> {} (diff: {} ms)", tableName, oldFlushTime, newFlushTime, newFlushTime - oldFlushTime); - // 清理该表的缓存文件 - cleanupCacheFileForTable(tableName); + // 成功:清理该表的缓存文件(删除文件) + cleanupCacheFileForTable(tableName, true); // 从待刷新列表中移除该表 pendingFlushTables.remove(tableName); // 清理该表的批次大小 currentBatchSizeByTable.remove(tableName); - cannotClean = false; - // 注意:刷新时间只在成功时更新,失败时不更新以便重试 + + // 数据成功刷新后,主动通知引擎可以保存断点 + taplogger.debug("Table {} successfully flushed and removed from pending list. " + + "Remaining pending tables: {}", tableName, pendingFlushTables.size()); + + if (flushOffsetCallback != null) { + TapCallbackOffset offsetToSave = null; + synchronized (firstOffsetByTable) { + Map.Entry firstEntry = firstOffsetByTable.entrySet() + .stream() + .findFirst() + .orElse(null); + + if (firstEntry != null) { + String firstTableName = firstEntry.getKey(); + TapCallbackOffset firstOffset = firstEntry.getValue(); + + // 如果当前刷新的表是第一个表 + offsetToSave = firstOffset; + if (tableName.equals(firstTableName)) { + firstOffsetByTable.remove(firstTableName); + taplogger.info("Table {} is the first table in queue, saving its latest offset: " + + "batchOffset={}, streamOffset={}", + tableName, + offsetToSave != null ? offsetToSave.get(KEY_BATCH_OFFSET) : null, + offsetToSave != null ? offsetToSave.get(KEY_STREAM_OFFSET) : null); + } else { + taplogger.info("Table {} is not the first table, saving first table {}'s offset: " + + "batchOffset={}, streamOffset={}", + tableName, firstTableName, + offsetToSave.get(KEY_BATCH_OFFSET), + offsetToSave.get(KEY_STREAM_OFFSET)); + } + } + } + if (offsetToSave != null && offsetToSave.hasValidOffset()) { + taplogger.info("Table flushed successfully, triggering flush offset callback with TapOffset: {}", offsetToSave); + try { + flushOffsetCallback.accept(offsetToSave); + } catch (Exception e) { + taplogger.warn("Failed to flush offset callback: {}", e.getMessage(), e); + } + } else { + taplogger.debug("No valid TapOffset found for table {}, skipping callback", tableName); + } + } + return respContent; } catch (StarrocksRetryableException e) { long flushEndTime = System.currentTimeMillis(); @@ -874,7 +963,6 @@ public RespContent flushTable(String tableName, TapTable table) throws Starrocks taplogger.warn("Table {} flush failed: flushed_size={}, waiting_time={} ms, " + "flush_duration={} ms, error={}", tableName, formatBytes(tableDataSize), waitTime, flushDuration, e.getMessage()); - cannotClean = true; throw e; } catch (Exception e) { long flushEndTime = System.currentTimeMillis(); @@ -882,7 +970,6 @@ public RespContent flushTable(String tableName, TapTable table) throws Starrocks taplogger.warn("Table {} flush failed: flushed_size={}, waiting_time={} ms, " + "flush_duration={} ms, error={}", tableName, formatBytes(tableDataSize), waitTime, flushDuration, e.getMessage()); - cannotClean = true; throw new StarrocksRuntimeException(e); } finally { @@ -941,30 +1028,18 @@ public void shutdown() { flushScheduler = null; } - // 关闭HTTP客户端 - if (this.httpClientMap != null) { - httpClientMap.forEach((k, v) -> { - try { - v.close(); - } catch (IOException e) { - taplogger.warn("Failed to close http client", e); - } - }); - } - // 清理所有Map,释放内存 cacheFileStreamsByTable.clear(); - if (!cannotClean) { - // 清理所有表的缓存文件 - cleanupAllCacheFiles(); - - tempCacheFilesByTable.clear(); - isFirstRecordByTable.clear(); - dataColumnsByTable.clear(); - pendingFlushTables.clear(); - currentBatchSizeByTable.clear(); - lastFlushTimeByTable.clear(); - } + + // 清理所有表的缓存文件 + cleanupAllCacheFiles(); + + tempCacheFilesByTable.clear(); + isFirstRecordByTable.clear(); + dataColumnsByTable.clear(); + pendingFlushTables.clear(); + currentBatchSizeByTable.clear(); + lastFlushTimeByTable.clear(); // 注意:tableNameToTapTableMap 不清理,因为表结构信息需要持久保存 // 强制垃圾回收 @@ -984,7 +1059,26 @@ protected String buildLoadUrl(final String StarrocksHttp, final String database, } private String buildPrefix(final String tableName) { - return String.format(LABEL_PREFIX_PATTERN, Thread.currentThread().getId(), tableName); + // Sanitize table name to comply with StarRocks label naming rules + // Only digits, letters, and underscores are allowed + String sanitizedTableName = sanitizeLabelComponent(tableName); + return String.format(LABEL_PREFIX_PATTERN, Thread.currentThread().getId(), sanitizedTableName); + } + + /** + * Sanitize a string to comply with StarRocks label naming rules. + * Only digits (0-9), letters (a-z, A-Z), and underscores (_) are allowed. + * All other characters are replaced with underscores. + * + * @param input the input string + * @return sanitized string + */ + private String sanitizeLabelComponent(final String input) { + if (input == null || input.isEmpty()) { + return "unknown"; + } + // Replace all characters that are not digits, letters, or underscores with underscores + return input.replaceAll("[^a-zA-Z0-9_]", "_"); } /** @@ -1033,37 +1127,91 @@ public long getCachedMetricsTotal() { /** * 完成指定表的缓存文件写入 + * 使用表级别的锁,避免对整个对象加锁影响其他表的操作 */ private void finalizeCacheFileForTable(String tableName) throws IOException { - try { - FileOutputStream cacheFileStream = cacheFileStreamsByTable.get(tableName); - Path tempCacheFile = tempCacheFilesByTable.get(tableName); + // 获取或创建该表的锁对象 + Object tableLock = tableLocks.computeIfAbsent(tableName, k -> new Object()); - if (cacheFileStream != null && tempCacheFile != null) { - if (!cacheFileStream.getChannel().isOpen()) { - cacheFileStream = new FileOutputStream(tempCacheFile.toFile(), true); - cacheFileStreamsByTable.put(tableName, cacheFileStream); - } - if(!cannotClean) { - // 写入批次结束标记 - cacheFileStream.write(messageSerializer.batchEnd()); + synchronized (tableLock) { + try { + FileOutputStream cacheFileStream = cacheFileStreamsByTable.get(tableName); + Path tempCacheFile = tempCacheFilesByTable.get(tableName); + + if (cacheFileStream != null && tempCacheFile != null) { + if (!cacheFileStream.getChannel().isOpen()) { + cacheFileStream = new FileOutputStream(tempCacheFile.toFile(), true); + cacheFileStreamsByTable.put(tableName, cacheFileStream); + } + + // 检查文件是否已经有结束标记 + boolean needsEndMarker = true; + if (Files.exists(tempCacheFile) && Files.size(tempCacheFile) > 0) { + // 读取文件最后几个字节,检查是否已经有 ']' + byte[] lastBytes = new byte[10]; + try (FileInputStream fis = new FileInputStream(tempCacheFile.toFile())) { + long fileSize = Files.size(tempCacheFile); + long skipBytes = Math.max(0, fileSize - 10); + fis.skip(skipBytes); + int bytesRead = fis.read(lastBytes); + String lastContent = new String(lastBytes, 0, bytesRead, java.nio.charset.StandardCharsets.UTF_8); + needsEndMarker = !lastContent.trim().endsWith("]"); + } + } + + // 只在需要时写入结束标记 + if (needsEndMarker) { + cacheFileStream.write(messageSerializer.batchEnd()); + taplogger.debug("Added end marker ']' to cache file for table {}", tableName); + } else { + taplogger.debug("Cache file for table {} already has end marker, skipping", tableName); + } + + cacheFileStream.flush(); + cacheFileStream.close(); + + // 验证文件完整性 + verifyFileCompleteness(tableName, tempCacheFile); + + taplogger.debug("Finalized cache file for table {}: {}, size: {}", + tableName, tempCacheFile.toString(), formatBytes(Files.size(tempCacheFile))); } - cacheFileStream.flush(); - cacheFileStream.close(); + } catch (IOException e) { + taplogger.warn("Failed to finalize cache file for table {}: {}", tableName, e.getMessage()); + throw e; + } + } + } + + private void verifyFileCompleteness(String tableName, Path tempCacheFile) { + try { + if (Files.exists(tempCacheFile) && Files.size(tempCacheFile) > 0) { + byte[] lastBytes = new byte[100]; + try (FileInputStream fis = new FileInputStream(tempCacheFile.toFile())) { + long fileSize = Files.size(tempCacheFile); + long skipBytes = Math.max(0, fileSize - 100); + fis.skip(skipBytes); + int bytesRead = fis.read(lastBytes); + String lastContent = new String(lastBytes, 0, bytesRead, java.nio.charset.StandardCharsets.UTF_8); - taplogger.debug("Finalized cache file for table {}: {}, size: {}", - tableName, tempCacheFile.toString(), formatBytes(Files.size(tempCacheFile))); + if (lastContent.trim().endsWith("]")) { + taplogger.info("File verification passed for table {}: JSON is complete", tableName); + } else { + taplogger.warn("File verification FAILED for table {}: JSON is incomplete, last 100 chars: {}", tableName, lastContent); + } + } } - } catch (IOException e) { - taplogger.warn("Failed to finalize cache file for table {}: {}", tableName, e.getMessage()); - throw e; + } catch (Exception e) { + taplogger.warn("Failed to verify file completeness for table {}: {}", tableName, e.getMessage()); } } /** * 清理指定表的缓存文件 + * @param tableName 表名 + * @param deleteFile 是否删除文件(true=删除,false=仅关闭流但保留文件) */ - private void cleanupCacheFileForTable(String tableName) { + private void cleanupCacheFileForTable(String tableName, boolean deleteFile) { try { FileOutputStream cacheFileStream = cacheFileStreamsByTable.get(tableName); Path tempCacheFile = tempCacheFilesByTable.get(tableName); @@ -1081,18 +1229,31 @@ private void cleanupCacheFileForTable(String tableName) { try { long fileSize = Files.size(tempCacheFile); - // 直接删除缓存文件 - Files.deleteIfExists(tempCacheFile); - - taplogger.info("=== File Cleanup Completed ==="); - taplogger.info("Table: {}", tableName); - taplogger.info("Deleted File: {}", tempCacheFile.toString()); - taplogger.info("File Size: {}", formatBytes(fileSize)); - taplogger.info("=============================="); + if (deleteFile) { + // 成功时删除文件 + Files.deleteIfExists(tempCacheFile); + taplogger.info("=== File Cleanup Completed ==="); + taplogger.info("Table: {}", tableName); + taplogger.info("Deleted File: {}", tempCacheFile.toString()); + taplogger.info("File Size: {}", formatBytes(fileSize)); + taplogger.info("=============================="); + } else { + // 失败时保留文件 + taplogger.warn("=== File Preserved for Debugging ==="); + taplogger.warn("Table: {}", tableName); + taplogger.warn("Preserved File: {}", tempCacheFile.toString()); + taplogger.warn("File Size: {}", formatBytes(fileSize)); + taplogger.warn("Reason: Flush failed, file kept for troubleshooting"); + taplogger.warn("====================================="); + } } catch (IOException e) { - taplogger.warn("Failed to delete cache file for table {}: {}", tableName, e.getMessage()); + taplogger.warn("Failed to process cache file for table {}: {}", tableName, e.getMessage()); + } + + // 只有在删除文件时才从 map 中移除 + if (deleteFile) { + tempCacheFilesByTable.remove(tableName); } - tempCacheFilesByTable.remove(tableName); } // 清理相关状态 @@ -1106,6 +1267,13 @@ private void cleanupCacheFileForTable(String tableName) { } } + /** + * 清理指定表的缓存文件(默认删除文件) + */ + private void cleanupCacheFileForTable(String tableName) { + cleanupCacheFileForTable(tableName, true); + } + /** * 清理所有表的缓存文件 */ @@ -1306,4 +1474,17 @@ public WriteListResult createResultList() { return result; } } + + /** + * 设置 flush offset callback + * + * @param flushOffsetCallback 回调函数,在数据成功刷新后调用 + */ + public void setFlushOffsetCallback(Consumer flushOffsetCallback) { + this.flushOffsetCallback = flushOffsetCallback; + } + + public Set getPendingFlushTables() { + return pendingFlushTables; + } } diff --git a/connectors/starrocks-connector/src/main/resources/spec_starrocks.json b/connectors/starrocks-connector/src/main/resources/spec_starrocks.json index 82c1f8dd2..594fd4c83 100644 --- a/connectors/starrocks-connector/src/main/resources/spec_starrocks.json +++ b/connectors/starrocks-connector/src/main/resources/spec_starrocks.json @@ -1,6 +1,7 @@ { "properties": { "name": "Starrocks", + "realName": "StarRocks", "icon": "icons/starrocks.png", "id": "starrocks", "docs": "${docs}", @@ -14,6 +15,9 @@ "update_on_exists", "just_insert" ] + }, + { + "id": "flush_offset_callback" } ], "connection": { @@ -536,7 +540,7 @@ "type": "int", "x-index": 9, "title": "${flushTimeoutSeconds}", - "default": 300, + "default": 30, "x-decorator": "FormItem", "x-component": "InputNumber", "x-decorator-props": { @@ -865,7 +869,7 @@ }, "varbinary[($byte)]": { "to": "TapBinary", - "byte": "2g", + "byte": "1m", "defaultByte": 1 }, "string": { diff --git a/connectors/tablestore-connector/src/main/resources/spec_tablestore.json b/connectors/tablestore-connector/src/main/resources/spec_tablestore.json index e8e63738e..adb5d1af6 100644 --- a/connectors/tablestore-connector/src/main/resources/spec_tablestore.json +++ b/connectors/tablestore-connector/src/main/resources/spec_tablestore.json @@ -1,6 +1,7 @@ { "properties": { "name": "Tablestore", + "realName": "Tablestore", "icon": "icons/tablestore.png", "doc" : "${doc}", "id": "tablestore", diff --git a/connectors/tdengine-connector/pom.xml b/connectors/tdengine-connector/pom.xml index 02da4a5d5..72f225a64 100644 --- a/connectors/tdengine-connector/pom.xml +++ b/connectors/tdengine-connector/pom.xml @@ -24,6 +24,7 @@ 8 1.0-SNAPSHOT + com.taosdata.jdbc.rs.RestfulDriver @@ -166,6 +167,27 @@ ${project.basedir}/../../.git + + + org.apache.maven.plugins + maven-antrun-plugin + 1.8 + + + generate-log4jdbc-properties + process-resources + + run + + + + + log4jdbc.drivers = ${origin.driver.class} + + + + + maven-resources-plugin 3.1.0 diff --git a/connectors/tdengine-connector/src/main/java/io/tapdata/connector/tdengine/TDengineConnector.java b/connectors/tdengine-connector/src/main/java/io/tapdata/connector/tdengine/TDengineConnector.java index 1d60a689f..7fea404df 100644 --- a/connectors/tdengine-connector/src/main/java/io/tapdata/connector/tdengine/TDengineConnector.java +++ b/connectors/tdengine-connector/src/main/java/io/tapdata/connector/tdengine/TDengineConnector.java @@ -63,7 +63,19 @@ public class TDengineConnector extends CommonDbConnector { @Override public void onStart(TapConnectionContext connectionContext) throws Exception { tdengineConfig = (TDengineConfig) new TDengineConfig().load(connectionContext.getConnectionConfig()); - isConnectorStarted(connectionContext, connectorContext -> tdengineConfig.load(connectorContext.getNodeConfig())); + isConnectorStarted(connectionContext, connectorContext -> { + tdengineConfig.load(connectorContext.getNodeConfig()); + firstConnectorId = (String) connectorContext.getStateMap().get("firstConnectorId"); + if (EmptyKit.isNull(firstConnectorId)) { + firstConnectorId = UUID.randomUUID().toString().replace("-", ""); + connectorContext.getStateMap().put("firstConnectorId", firstConnectorId); + } + }); + tapLogger = connectionContext.getLog(); + if (tdengineConfig.getFileLog()) { + tapLogger.info("Starting Jdbc Logging, connectorId: {}", firstConnectorId); + tdengineConfig.startJdbcLog(firstConnectorId); + } tdengineJdbcContext = new TDengineJdbcContext(tdengineConfig); this.connectionTimezone = connectionContext.getConnectionConfig().getString("timezone"); if ("Database Timezone".equals(this.connectionTimezone) || StringUtils.isBlank(this.connectionTimezone)) { @@ -74,7 +86,6 @@ public void onStart(TapConnectionContext connectionContext) throws Exception { jdbcContext = tdengineJdbcContext; commonSqlMaker = new CommonSqlMaker(tdengineConfig.getEscapeChar()); ddlSqlGenerator = new TDengineDDLSqlGenerator(); - tapLogger = connectionContext.getLog(); fieldDDLHandlers = new BiClassHandlers<>(); fieldDDLHandlers.register(TapNewFieldEvent.class, this::newField); fieldDDLHandlers.register(TapAlterFieldAttributesEvent.class, this::alterFieldAttr); diff --git a/connectors/tdengine-connector/src/main/resources/tdengine-spec.json b/connectors/tdengine-connector/src/main/resources/tdengine-spec.json index 70ecd0b55..014c1ae2c 100644 --- a/connectors/tdengine-connector/src/main/resources/tdengine-spec.json +++ b/connectors/tdengine-connector/src/main/resources/tdengine-spec.json @@ -1,6 +1,7 @@ { "properties": { "name": "TDengine", + "realName": "TDengine", "icon": "icons/tdengine.png", "id": "tdengine", "doc": "${doc}", diff --git a/connectors/tencent-db-mariadb-connector/pom.xml b/connectors/tencent-db-mariadb-connector/pom.xml index 745bd7222..8aaa7a95b 100644 --- a/connectors/tencent-db-mariadb-connector/pom.xml +++ b/connectors/tencent-db-mariadb-connector/pom.xml @@ -23,7 +23,7 @@ 8 - 2.0.0-SNAPSHOT + 2.0.5-SNAPSHOT diff --git a/connectors/tencent-db-mariadb-connector/src/main/resources/tencent-db-mariadb-spec.json b/connectors/tencent-db-mariadb-connector/src/main/resources/tencent-db-mariadb-spec.json index 770bec6d9..bdab4eb7c 100644 --- a/connectors/tencent-db-mariadb-connector/src/main/resources/tencent-db-mariadb-spec.json +++ b/connectors/tencent-db-mariadb-connector/src/main/resources/tencent-db-mariadb-spec.json @@ -1,6 +1,7 @@ { "properties": { "name": "TencentDB MariaDB", + "realName": "TencentDB for MariaDB", "icon": "icons/tencent_db_mariadb.png", "id": "tencent-db-mariadb", "doc": "${doc}", diff --git a/connectors/tencent-db-mongodb-connector/pom.xml b/connectors/tencent-db-mongodb-connector/pom.xml index 418afe64b..62da6d963 100644 --- a/connectors/tencent-db-mongodb-connector/pom.xml +++ b/connectors/tencent-db-mongodb-connector/pom.xml @@ -23,7 +23,7 @@ 8 - 2.0.0-SNAPSHOT + 2.0.5-SNAPSHOT diff --git a/connectors/tencent-db-mongodb-connector/src/main/resources/tencent-db-mongodb-spec.json b/connectors/tencent-db-mongodb-connector/src/main/resources/tencent-db-mongodb-spec.json index 5e4bab426..527ce648c 100644 --- a/connectors/tencent-db-mongodb-connector/src/main/resources/tencent-db-mongodb-spec.json +++ b/connectors/tencent-db-mongodb-connector/src/main/resources/tencent-db-mongodb-spec.json @@ -1,6 +1,7 @@ { "properties": { "name": "TencentDB MongoDB", + "realName": "TencentDB for MongoDB", "icon": "icons/tencent-db-mongodb.png", "doc" : "${doc}", "id": "tencent-db-mongodb", diff --git a/connectors/tencent-db-postgres-connector/src/main/resources/tencent-db-postgres-spec.json b/connectors/tencent-db-postgres-connector/src/main/resources/tencent-db-postgres-spec.json index 10b895b3d..a51f4b4f4 100644 --- a/connectors/tencent-db-postgres-connector/src/main/resources/tencent-db-postgres-spec.json +++ b/connectors/tencent-db-postgres-connector/src/main/resources/tencent-db-postgres-spec.json @@ -1,6 +1,7 @@ { "properties": { "name": "Tencent DB PostgreSQL", + "realName": "TencentDB for PostgreSQL", "icon": "icons/tencent_db_postgres.png", "doc" : "${doc}", "id": "tencent-db-postgres", diff --git a/connectors/tidb-connector/pom.xml b/connectors/tidb-connector/pom.xml index fd82cf1ba..29adaca1c 100644 --- a/connectors/tidb-connector/pom.xml +++ b/connectors/tidb-connector/pom.xml @@ -19,13 +19,12 @@ 1.0-SNAPSHOT 1.0-SNAPSHOT 3.12.0 - 2.0.0-SNAPSHOT + 2.0.5-SNAPSHOT 1.7.15 2.17.1 2.12 - - + com.mysql.cj.jdbc.Driver @@ -149,6 +148,27 @@ ${project.basedir}/../../.git + + + org.apache.maven.plugins + maven-antrun-plugin + 1.8 + + + generate-log4jdbc-properties + process-resources + + run + + + + + log4jdbc.drivers = ${origin.driver.class} + + + + + maven-resources-plugin 3.1.0 diff --git a/connectors/tidb-connector/src/main/java/io/tapdata/connector/tidb/TidbConnector.java b/connectors/tidb-connector/src/main/java/io/tapdata/connector/tidb/TidbConnector.java index e546dacbe..6a1da4370 100644 --- a/connectors/tidb-connector/src/main/java/io/tapdata/connector/tidb/TidbConnector.java +++ b/connectors/tidb-connector/src/main/java/io/tapdata/connector/tidb/TidbConnector.java @@ -83,11 +83,23 @@ protected void initTimeZone() throws SQLException { @Override public void onStart(TapConnectionContext tapConnectionContext) throws SQLException { this.tidbConfig = new TidbConfig().load(tapConnectionContext.getConnectionConfig()); + isConnectorStarted(tapConnectionContext, connectorContext -> { + tidbConfig.load(connectorContext.getNodeConfig()); + firstConnectorId = (String) connectorContext.getStateMap().get("firstConnectorId"); + if (EmptyKit.isNull(firstConnectorId)) { + firstConnectorId = UUID.randomUUID().toString().replace("-", ""); + connectorContext.getStateMap().put("firstConnectorId", firstConnectorId); + } + }); + tapLogger = tapConnectionContext.getLog(); + if (tidbConfig.getFileLog()) { + tapLogger.info("Starting Jdbc Logging, connectorId: {}", firstConnectorId); + tidbConfig.startJdbcLog(firstConnectorId); + } tidbJdbcContext = new TidbJdbcContext(tidbConfig); commonDbConfig = tidbConfig; jdbcContext = tidbJdbcContext; initTimeZone(); - tapLogger = tapConnectionContext.getLog(); started.set(true); exceptionCollector = new TidbExceptionCollector(); commonSqlMaker = new CommonSqlMaker('`'); @@ -105,7 +117,7 @@ protected RetryOptions errorHandle(TapConnectionContext tapConnectionContext, PD RetryOptions retryOptions = super.errorHandle(tapConnectionContext, pdkMethod, throwable); retryOptions.setNeedRetry( !(throwable instanceof CoreException && ((CoreException) throwable).getCode() == TiCDCShellManager.CDC_TOOL_NOT_EXISTS) - && !(throwable instanceof CoreException && ((CoreException) throwable).getCode() == HttpUtil.ERROR_START_TS_BEFORE_GC) + && !(throwable instanceof CoreException && ((CoreException) throwable).getCode() == HttpUtil.ERROR_START_TS_BEFORE_GC) ); return retryOptions; } @@ -367,7 +379,7 @@ protected void processDataMap(DataMap dataMap, TapTable tapTable) throws Runtime } else { entry.setValue(((LocalDateTime) value).minusHours(tidbConfig.getZoneOffsetHour())); } - } else if (value instanceof java.sql.Date) { + } else if (value instanceof java.sql.Date) { if (dataType.startsWith("year")) { entry.setValue(((Date) value).toLocalDate().getYear()); } else { diff --git a/connectors/tidb-connector/src/main/resources/spec_tidb.json b/connectors/tidb-connector/src/main/resources/spec_tidb.json index 49a616f33..627a8543a 100644 --- a/connectors/tidb-connector/src/main/resources/spec_tidb.json +++ b/connectors/tidb-connector/src/main/resources/spec_tidb.json @@ -1,6 +1,7 @@ { "properties": { "name": "Tidb", + "realName": "TiDB", "icon": "icons/tidb.svg", "id": "tidb", "doc": "${doc}", diff --git a/connectors/vastbase-connector/src/main/resources/spec_vastbase.json b/connectors/vastbase-connector/src/main/resources/spec_vastbase.json index 2fe5de9f2..3ed6a15b3 100644 --- a/connectors/vastbase-connector/src/main/resources/spec_vastbase.json +++ b/connectors/vastbase-connector/src/main/resources/spec_vastbase.json @@ -1,6 +1,7 @@ { "properties": { "name": "Vastbase", + "realName": "Vastbase G100", "icon": "icons/vastbase.png", "doc" : "${doc}", "id": "vastbase", diff --git a/connectors/vika-connector/src/main/resources/spec_vika.json b/connectors/vika-connector/src/main/resources/spec_vika.json index 753f010e2..13e407ed9 100644 --- a/connectors/vika-connector/src/main/resources/spec_vika.json +++ b/connectors/vika-connector/src/main/resources/spec_vika.json @@ -1,6 +1,7 @@ { "properties": { "name": "vika", + "realName": "vika", "icon": "icons/vika.png", "doc" : "${doc}", "id": "vika", diff --git a/connectors/xml-connector/pom.xml b/connectors/xml-connector/pom.xml index 0331b1a72..2b724621a 100644 --- a/connectors/xml-connector/pom.xml +++ b/connectors/xml-connector/pom.xml @@ -15,7 +15,7 @@ jar - 2.0.1-SNAPSHOT + 2.0.5-SNAPSHOT 8 diff --git a/connectors/xml-connector/src/main/resources/spec_xml.json b/connectors/xml-connector/src/main/resources/spec_xml.json index 0b23fbcff..8b9632de5 100644 --- a/connectors/xml-connector/src/main/resources/spec_xml.json +++ b/connectors/xml-connector/src/main/resources/spec_xml.json @@ -1,6 +1,7 @@ { "properties": { "name": "XML", + "realName": "XML", "icon": "icons/xml.png", "doc": "${doc}", "id": "xml", diff --git a/connectors/yashandb-connector/pom.xml b/connectors/yashandb-connector/pom.xml index f560413af..153188cda 100644 --- a/connectors/yashandb-connector/pom.xml +++ b/connectors/yashandb-connector/pom.xml @@ -23,7 +23,7 @@ 8 - 2.0.0-SNAPSHOT + 2.0.5-SNAPSHOT diff --git a/connectors/yashandb-connector/src/main/resources/yashandb-spec.json b/connectors/yashandb-connector/src/main/resources/yashandb-spec.json index 1ac8b81c5..d9b60d3b3 100644 --- a/connectors/yashandb-connector/src/main/resources/yashandb-spec.json +++ b/connectors/yashandb-connector/src/main/resources/yashandb-spec.json @@ -1,6 +1,7 @@ { "properties": { "name": "YashanDB", + "realName": "YashanDB", "icon": "icons/yashandb.png", "id": "yashandb", "doc": "${doc}", diff --git a/connectors/zoho-desk-connector/src/main/resources/spec.json b/connectors/zoho-desk-connector/src/main/resources/spec.json index d751226ad..a208202c9 100644 --- a/connectors/zoho-desk-connector/src/main/resources/spec.json +++ b/connectors/zoho-desk-connector/src/main/resources/spec.json @@ -1,6 +1,7 @@ { "properties": { "name": "Zoho-Desk", + "realName": "Zoho Desk", "icon": "icons/zoho.png", "doc" : "${doc}", "id": "zoho-desk", diff --git a/scripts/cleanup-warehouse.sh b/scripts/cleanup-warehouse.sh new file mode 100755 index 000000000..a49b45a2a --- /dev/null +++ b/scripts/cleanup-warehouse.sh @@ -0,0 +1,58 @@ +#!/bin/bash +################################################################################ +# Paimon 仓库清理脚本 +# 功能:清理测试仓库目录,释放磁盘空间 +# 使用:./scripts/cleanup-warehouse.sh +################################################################################ + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(dirname "$SCRIPT_DIR")" +WAREHOUSE_DIR="${PROJECT_ROOT}/test-output/paimon-warehouse" + +echo "========================================" +echo " Paimon 仓库清理脚本" +echo "========================================" +echo "" + +# 检查目录是否存在 +if [ ! -d "$WAREHOUSE_DIR" ]; then + echo "仓库目录不存在:$WAREHOUSE_DIR" + exit 0 +fi + +# 计算目录大小 +if command -v du &> /dev/null; then + dir_size=$(du -sh "$WAREHOUSE_DIR" 2>/dev/null | cut -f1) + echo "当前仓库大小:$dir_size" +else + echo "当前仓库:$WAREHOUSE_DIR" +fi +echo "" + +# 确认清理(如果不是强制模式) +if [ "$1" != "-f" ] && [ "$1" != "--force" ]; then + read -p "确定要清理仓库目录吗?(y/N): " confirm + if [[ ! $confirm =~ ^[Yy]$ ]]; then + echo "已取消清理" + exit 0 + fi +fi + +# 执行清理 +echo "正在清理..." +rm -rf "$WAREHOUSE_DIR"/* +mkdir -p "$WAREHOUSE_DIR" + +# 清理 JVM 缓存(macOS) +if command -v sudo &> /dev/null && command -v purge &> /dev/null; then + echo "清理 JVM 缓存..." + sudo sync 2>/dev/null || true + sudo purge 2>/dev/null || true +fi + +echo "" +echo "========================================" +echo " 清理完成!" +echo "========================================" diff --git a/scripts/run-paimon-test.sh b/scripts/run-paimon-test.sh new file mode 100755 index 000000000..f06427d32 --- /dev/null +++ b/scripts/run-paimon-test.sh @@ -0,0 +1,315 @@ +#!/bin/bash +################################################################################ +# Paimon 性能测试 - 简化执行脚本 +# 功能:执行单个测试用例并生成简单报告 +# 使用:./scripts/run-paimon-test.sh [test_class] [test_method] +# 示例:./scripts/run-paimon-test.sh TransactionPerformanceTest testDailyPartitionWrite +################################################################################ + +set -e # 遇到错误立即退出 + +# ==================== 配置区域 ==================== +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(dirname "$SCRIPT_DIR")" +CONNECTOR_DIR="${PROJECT_ROOT}/connectors/paimon-connector" +TEST_OUTPUT_DIR="${PROJECT_ROOT}/test-output" +WAREHOUSE_DIR="${TEST_OUTPUT_DIR}/paimon-warehouse" +REPORT_DIR="${TEST_OUTPUT_DIR}/reports" +TIMESTAMP=$(date +%Y%m%d_%H%M%S) +REPORT_FILE="${REPORT_DIR}/test-result-${TIMESTAMP}.md" + +# 颜色定义 +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +# ==================== 工具函数 ==================== + +log_info() { + echo -e "${BLUE}[INFO]${NC} $(date '+%Y-%m-%d %H:%M:%S') - $1" +} + +log_success() { + echo -e "${GREEN}[SUCCESS]${NC} $(date '+%Y-%m-%d %H:%M:%S') - $1" +} + +log_warning() { + echo -e "${YELLOW}[WARNING]${NC} $(date '+%Y-%m-%d %H:%M:%S') - $1" +} + +log_error() { + echo -e "${RED}[ERROR]${NC} $(date '+%Y-%m-%d %H:%M:%S') - $1" +} + +log_step() { + echo -e "\n${BLUE}>>>${NC} $1" +} + +# 清理环境 +cleanup_environment() { + log_step "清理测试环境..." + + # 清理仓库目录 + if [ -d "$WAREHOUSE_DIR" ]; then + rm -rf "$WAREHOUSE_DIR"/* + log_info "已清理仓库目录:$WAREHOUSE_DIR" + fi + + # 创建必要目录 + mkdir -p "$WAREHOUSE_DIR" + mkdir -p "$REPORT_DIR" + + log_success "环境清理完成" +} + +# 检查依赖 +check_dependencies() { + log_step "检查依赖环境..." + + # 检查 Java + if ! command -v java &> /dev/null; then + log_error "未找到 Java,请先安装 JDK 11+" + exit 1 + fi + java_version=$(java -version 2>&1 | head -1 | cut -d'"' -f2 | cut -d'.' -f1) + if [ "$java_version" -lt 11 ]; then + log_error "Java 版本过低 ($java_version),需要 JDK 11+" + exit 1 + fi + log_info "Java 版本:$(java -version 2>&1 | head -1)" + + # 检查 Maven + if ! command -v mvn &> /dev/null; then + log_error "未找到 Maven,请先安装" + exit 1 + fi + log_info "Maven 版本:$(mvn -version 2>&1 | head -1)" + + # 检查磁盘空间 + available_space=$(df -h "$PROJECT_ROOT" | tail -1 | awk '{print $4}') + log_info "可用磁盘空间:$available_space" + + log_success "依赖检查通过" +} + +# 构建项目 +build_project() { + log_step "构建项目..." + + cd "$PROJECT_ROOT" + + # 先清理 + mvn clean -q + + # 编译 paimon-connector 模块 + cd "$CONNECTOR_DIR" + mvn compile test-compile -q -DskipTests + + if [ $? -eq 0 ]; then + log_success "项目构建成功" + else + log_error "项目构建失败" + exit 1 + fi +} + +# 执行测试 +run_test() { + local test_class=$1 + local test_method=$2 + + log_step "执行测试:${test_class}#${test_method}" + + cd "$CONNECTOR_DIR" + + # 设置 JVM 参数 + export MAVEN_OPTS="-Xmx4g -XX:+UseG1GC -XX:MaxGCPauseMillis=200" + + # 执行测试 + if [ -n "$test_method" ]; then + mvn test -Dtest="${test_class}#${test_method}" -Dmaven.test.failure.ignore=true + else + mvn test -Dtest="${test_class}" -Dmaven.test.failure.ignore=true + fi + + local exit_code=$? + + if [ $exit_code -eq 0 ]; then + log_success "测试执行成功" + else + log_warning "测试执行完成(可能有断言失败)" + fi + + return $exit_code +} + +# 生成简单报告 +generate_simple_report() { + local test_class=$1 + local test_method=$2 + local exit_code=$3 + + log_step "生成测试报告..." + + cat > "$REPORT_FILE" << EOF +# Paimon 性能测试结果报告 + +**测试时间**: $(date '+%Y-%m-%d %H:%M:%S') +**测试类**: ${test_class} +**测试方法**: ${test_method:-All} +**执行状态**: $([ $exit_code -eq 0 ] && echo "✅ 成功" || echo "⚠️ 完成(查看日志详情)") + +--- + +## 测试配置 + +| 配置项 | 值 | +|--------|-----| +| Paimon 版本 | 1.2.0 | +| 存储类型 | 本地文件系统 | +| 仓库路径 | ${WAREHOUSE_DIR} | +| 写入线程数 | 8 | +| 批量提交大小 | 10000 条 | + +--- + +## 测试说明 + +### 测试数据模型 +- **表名**: TransactionDetails +- **字段数**: 29 个字段 + 1 个分区字段 +- **单条大小**: 约 1KB +- **主键**: Id (VARCHAR 900) +- **分区**: pt_created_date (INT, 格式 YYYYMMDD) + +### 测试场景 +1. **按天分区测试** (testDailyPartitionWrite): 分区字段格式 yyyyMMdd +2. **按月分区测试** (testMonthlyPartitionWrite): 分区字段格式 yyyyMM +3. **按年分区测试** (testYearlyPartitionWrite): 分区字段格式 yyyy + +--- + +## 执行日志 + +测试日志已输出到控制台,详细日志位置: +\`\`\` +${CONNECTOR_DIR}/target/surefire-reports/ +\`\`\` + +--- + +## 性能指标参考 + +基于测试配置,预期性能指标: + +| 指标 | 目标值 | 说明 | +|------|--------|------| +| 吞吐量 | ≥5000 条/秒 | 8 线程并发 | +| 小文件占比 | <10% | <32MB 文件 | +| 内存峰值 | <4GB | JVM 堆内存限制 | +| 写放大率 | <3 | Compaction 效率 | + +--- + +## 下一步 + +1. 查看测试日志了解详细性能数据 +2. 对比不同分区策略的性能差异 +3. 根据测试结果调整参数配置 + +--- + +**报告生成时间**: $(date '+%Y-%m-%d %H:%M:%S') +**测试执行人**: $(whoami) +EOF + + log_success "报告已生成:$REPORT_FILE" + + # 显示报告摘要 + echo "" + echo "========================================" + echo " 测试报告摘要" + echo "========================================" + head -20 "$REPORT_FILE" + echo "========================================" + echo "完整报告:$REPORT_FILE" + echo "========================================" +} + +# 显示使用说明 +show_usage() { + echo "========================================" + echo " Paimon 性能测试脚本" + echo "========================================" + echo "" + echo "使用方法:" + echo " $0 [测试类] [测试方法]" + echo "" + echo "示例:" + echo " # 执行按天分区测试" + echo " $0 TransactionPerformanceTest testDailyPartitionWrite" + echo "" + echo " # 执行所有分区测试" + echo " $0 TransactionPerformanceTest" + echo "" + echo " # 清理环境" + echo " $0 clean" + echo "" + echo "========================================" +} + +# ==================== 主程序 ==================== + +main() { + local action="${1:-help}" + local test_class="${2:-}" + local test_method="${3:-}" + + case "$action" in + clean) + cleanup_environment + exit 0 + ;; + help|--help|-h) + show_usage + exit 0 + ;; + *) + test_class="$action" + test_method="$test_class" + ;; + esac + + echo "========================================" + echo " Paimon 性能测试" + echo " 版本:1.0 (Paimon 1.2.0)" + echo "========================================" + + # 1. 检查依赖 + check_dependencies + + # 2. 清理环境 + cleanup_environment + + # 3. 构建项目 + build_project + + # 4. 执行测试 + run_test "$test_class" "$test_method" + local exit_code=$? + + # 5. 生成报告 + generate_simple_report "$test_class" "$test_method" "$exit_code" + + echo "" + echo "========================================" + echo " 测试执行完成!" + echo "========================================" + + exit $exit_code +} + +# 执行主程序 +main "$@" diff --git a/tapdata-cli/pom.xml b/tapdata-cli/pom.xml index e46ae0d4f..dd5b34d04 100644 --- a/tapdata-cli/pom.xml +++ b/tapdata-cli/pom.xml @@ -27,12 +27,12 @@ io.tapdata tapdata-pdk-runner - 2.2-SNAPSHOT + 2.6-SNAPSHOT io.tapdata script-engine-module - 2.2-SNAPSHOT + 2.6-SNAPSHOT io.tapdata @@ -165,13 +165,13 @@ io.tapdata class-supervisor - 2.2-SNAPSHOT + 2.6-SNAPSHOT compile io.tapdata pdk-error-code - 2.2-SNAPSHOT + 2.6-SNAPSHOT diff --git a/tapdata-cli/src/main/java/io/tapdata/pdk/cli/RegisterMain.java b/tapdata-cli/src/main/java/io/tapdata/pdk/cli/RegisterMain.java index 99a5e4ce8..7927c9a94 100644 --- a/tapdata-cli/src/main/java/io/tapdata/pdk/cli/RegisterMain.java +++ b/tapdata-cli/src/main/java/io/tapdata/pdk/cli/RegisterMain.java @@ -102,6 +102,7 @@ private enum ConnectorEnums { MYSQL_PXC(BETA), OCEANBASE_MYSQL(GA, TAG_JDBC), OPENGAUSS(ALPHA, TAG_JDBC), + PAIMON(ALPHA), POLAR_DB_MYSQL(BETA, TAG_JDBC), POLAR_DB_POSTGRES(BETA, TAG_JDBC), POSTGRES(GA, TAG_JDBC), @@ -112,6 +113,7 @@ private enum ConnectorEnums { SALESFORCE(ALPHA), SELECTDB(BETA), SHEIN(BETA), + SNOWFLAKE(BETA), STARROCKS(BETA), TABLESTORE(ALPHA), TDENGINE(BETA), @@ -126,7 +128,6 @@ private enum ConnectorEnums { YASHANDB(BETA, TAG_JDBC), ZOHO_CRM(BETA), ZOHO_DESK(BETA), - PAIMON(ALPHA), ; private final String path; diff --git a/task_startup_analysis.md b/task_startup_analysis.md deleted file mode 100644 index 6833528da..000000000 --- a/task_startup_analysis.md +++ /dev/null @@ -1,137 +0,0 @@ -# 任务启动卡住问题分析 - -## 问题描述 -一些任务在启动时会卡在启动中状态,无法进入运行中状态。 - -## 已添加的日志 - -### 1. 管理端(Manager)日志 -- **TaskServiceImpl.java**: - - 任务启动请求接收日志 - - 状态机START事件执行日志 - - 任务停止请求接收日志 - - 状态机STOP事件执行日志 - -- **TaskScheduleServiceImpl.java**: - - WebSocket启动消息发送日志 - -- **DataSyncHandler.java**: - - 接收引擎状态变化消息日志 - -- **StateMachineExecutor.java**: - - 状态机转换详细日志 - -### 2. 引擎端(iEngine)日志 -- **DataSyncEventHandler.java**: - - 接收WebSocket启动/停止命令日志 - - 任务查找和验证日志 - -- **TapdataTaskScheduler.java**: - - 任务调度器启动任务日志 - - 任务状态更新日志 - - Hazelcast任务启动日志 - - 任务停止处理日志 - -## 可能导致任务卡在启动中的原因分析 - -### 1. WebSocket通信问题 -**现象**: 管理端发送启动消息,但引擎端未收到 -**可能原因**: -- 网络连接不稳定 -- WebSocket连接断开但未及时重连 -- 消息队列满或处理延迟 - -**排查方法**: -- 检查管理端日志: "Manager sending START task websocket message to engine" -- 检查引擎端日志: "Engine received START task websocket message" -- 如果管理端有发送日志但引擎端无接收日志,说明WebSocket通信有问题 - -### 2. 任务查找失败 -**现象**: 引擎端收到启动命令但找不到任务 -**可能原因**: -- 任务数据不一致 -- 数据库连接问题 -- 任务已被删除但状态未同步 - -**排查方法**: -- 检查引擎端日志: "Engine failed to find task for START command" -- 检查数据库中任务状态是否正确 - -### 3. 状态机转换失败 -**现象**: 状态机无法从当前状态转换到目标状态 -**可能原因**: -- 任务当前状态不允许启动操作 -- 并发操作导致状态冲突 -- 状态机配置错误 - -**排查方法**: -- 检查管理端日志: "Manager state machine START failed" -- 检查状态机转换日志中的状态信息 - -### 4. 引擎任务启动异常 -**现象**: 引擎端开始启动任务但在过程中失败 -**可能原因**: -- Hazelcast集群问题 -- 资源不足(内存、CPU) -- 任务配置错误 -- 连接器初始化失败 - -**排查方法**: -- 检查引擎端日志: "Engine scheduler starting Hazelcast task" -- 检查是否有"Engine scheduler successfully started task"日志 -- 查看异常堆栈信息 - -### 5. 状态更新失败 -**现象**: 任务实际已启动但状态未更新为running -**可能原因**: -- 数据库更新失败 -- 状态同步机制异常 -- 心跳机制问题 - -**排查方法**: -- 检查引擎端日志: "Engine scheduler updating task status to running" -- 检查管理端是否收到状态变化消息: "Manager received task status change websocket message" - -### 6. 任务锁竞争 -**现象**: 多个操作同时竞争任务锁 -**可能原因**: -- 并发启动操作 -- 任务调度器重复调度 -- 锁超时设置不合理 - -**排查方法**: -- 检查日志中的锁相关信息 -- 查看是否有"failed because of task lock"消息 - -## 排查步骤建议 - -1. **检查WebSocket通信**: - - 确认管理端发送消息: "Manager sending START task websocket message" - - 确认引擎端接收消息: "Engine received START task websocket message" - -2. **检查状态机转换**: - - 查看状态机转换日志: "State machine transition starting" - - 确认转换成功: "State machine transition completed successfully" - -3. **检查任务调度**: - - 确认引擎开始调度: "Engine scheduler starting task" - - 确认Hazelcast启动: "Engine scheduler starting Hazelcast task" - - 确认启动成功: "Engine scheduler successfully started task" - -4. **检查状态同步**: - - 确认状态更新: "Engine scheduler updating task status to running" - - 确认管理端接收: "Manager received task status change websocket message" - -## 监控建议 - -1. **设置告警**: 当任务在启动中状态超过5分钟时触发告警 -2. **定期检查**: 定期扫描卡在启动中状态的任务 -3. **日志聚合**: 将相关日志聚合到统一平台便于分析 -4. **性能监控**: 监控WebSocket连接状态、数据库性能、引擎资源使用情况 - -## 临时解决方案 - -1. **重启任务**: 停止卡住的任务后重新启动 -2. **重启引擎**: 如果多个任务都卡住,考虑重启引擎 -3. **检查资源**: 确保引擎有足够的内存和CPU资源 -4. **清理缓存**: 清理可能的缓存数据不一致问题