This is an automated email from the ASF dual-hosted git repository. github-merge-queue[bot] pushed a commit to branch gh-readonly-queue/dev/pr-12686-8ceebc754c6f4b63efb925b56a29cdcafc06b5b6 in repository https://gitbox.apache.org/repos/asf/seatunnel.git
commit 0f8913a8c3f30dc5bb8cab07bda91365a8811cb5 Author: Goutam Adwant <[email protected]> AuthorDate: Sun Oct 11 09:46:04 2026 +0000 [Feature][Connector-V2] Add ClickHouse sink connectivity dry-run (#12686) Signed-off-by: Goutam Adwant <[email protected]> --- docs/en/connectors/sink/Clickhouse.md | 24 +++ docs/en/engines/zeta/user-command.md | 1 + docs/zh/connectors/sink/Clickhouse.md | 19 ++ docs/zh/engines/zeta/user-command.md | 1 + .../exception/ClickhouseConnectorErrorCode.java | 3 +- .../sink/client/ClickhouseSinkFactory.java | 118 ++++++++++- .../clickhouse/ClickhouseFactoryTest.java | 227 +++++++++++++++++++++ .../seatunnel/clickhouse/ClickhouseIT.java | 128 ++++++++++++ 8 files changed, 519 insertions(+), 2 deletions(-) diff --git a/docs/en/connectors/sink/Clickhouse.md b/docs/en/connectors/sink/Clickhouse.md index 123eb0ee3b..0a248fa4f3 100644 --- a/docs/en/connectors/sink/Clickhouse.md +++ b/docs/en/connectors/sink/Clickhouse.md @@ -24,6 +24,30 @@ import ChangeLog from '../changelog/connector-clickhouse.md'; Used to write data to Clickhouse. +## Connectivity dry-run + +`--dry-run connect` checks the first configured `host`, using the sink's connection +settings and a read-only query against `system.tables`. It does not create a writer, +read business rows, run `custom_sql`, or execute schema/data save modes. + +- With `schema_save_mode = ERROR_WHEN_SCHEMA_NOT_EXIST`, the target table must be + visible in `system.tables`. A missing or invisible target fails validation. +- Other schema save modes allow a missing table. A missing database is also allowed + for creation modes; validation does not create either object or verify creation permissions. +- Connect and socket timeouts are capped at 10 seconds, preserving shorter positive + values. Server query time is capped at 10 seconds, retries/failover are disabled, + and the check does not reuse a configured session. These overrides apply only to dry-run. +- `clickhouse.config.custom_http_params` is not supported by dry-run because it can + add SQL to the request. A custom `X-ClickHouse-Query-Id` header is also rejected to + avoid reusing a running query's identifier. An empty username, or a non-default username with an empty + password, is rejected: the current sink cannot resolve those credentials without + falling back to the driver's default account. The default user with an empty password + is supported. + +Successful validation confirms this metadata connection, not field/type compatibility, +write or DDL permissions, `ERROR_WHEN_DATA_EXISTS`, custom SQL correctness, or connectivity +to every shard in `split_mode`. Clickhouse source and ClickhouseFile sink are unchanged. + ## Supported DataSource Info In order to use the Clickhouse connector, the following dependencies are required. diff --git a/docs/en/engines/zeta/user-command.md b/docs/en/engines/zeta/user-command.md index 3ddb44734f..40ba238697 100644 --- a/docs/en/engines/zeta/user-command.md +++ b/docs/en/engines/zeta/user-command.md @@ -88,6 +88,7 @@ The `--dry-run connect` option runs the static checks first, then uses connector | Jdbc | Yes (connectivity + schema inference) | Yes (connectivity + table existence + field compatibility) | | Kafka | Yes ([topic metadata + runtime output schema](../../connectors/source/Kafka.md#connectivity-dry-run), not consumer/group permissions) | Yes ([metadata + local serializer checks](../../connectors/sink/Kafka.md#connectivity-dry-run); dynamic topics have limited checks, Produce/transaction permissions are not checked) | | FakeSource | Yes (schema inference only, no external system) | - | +| Clickhouse | No | Yes ([initial-host connectivity + target metadata](../../connectors/sink/Clickhouse.md#connectivity-dry-run); no write permission or field compatibility check) | | MongoDB | No | Yes ([connectivity + configured authentication](../../connectors/sink/MongoDB.md#connectivity-dry-run); no collection, schema, write permission or transaction checks) | | Neo4j | Yes ([driver connectivity + configured schema; not database/query validation](../../connectors/source/Neo4j.md#connectivity-dry-run)) | No | | RabbitMQ | Yes ([existing-queue metadata + configured schema; not consumer permissions](../../connectors/source/Rabbitmq.md#connectivity-dry-run)) | No | diff --git a/docs/zh/connectors/sink/Clickhouse.md b/docs/zh/connectors/sink/Clickhouse.md index a92b131d13..b825f8ea45 100644 --- a/docs/zh/connectors/sink/Clickhouse.md +++ b/docs/zh/connectors/sink/Clickhouse.md @@ -24,6 +24,25 @@ import ChangeLog from '../changelog/connector-clickhouse.md'; 用于将数据写入 Clickhouse。 +## 连通性 dry-run + +`--dry-run connect` 使用 sink 的连接配置检查第一个 `host`,并对 `system.tables` +执行只读元数据查询。它不会创建 writer、读取业务数据、执行 `custom_sql` 或执行任何表结构/数据保存模式。 + +- 当 `schema_save_mode = ERROR_WHEN_SCHEMA_NOT_EXIST` 时,目标表必须在 `system.tables` + 中可见。表不存在或不可见都会导致校验失败。 +- 其他表结构保存模式允许目标表不存在。创建模式也允许数据库不存在;校验不会创建数据库或表,也不验证创建权限。 +- 连接和 socket 超时最多为 10 秒,保留更短的正值配置。服务端查询超时最多为 10 秒, + 禁用重试和故障转移,不复用配置的 session。这些限制仅适用于 dry-run。 +- dry-run 不支持 `clickhouse.config.custom_http_params`,因为它可以在请求中附加 SQL。 + 也不支持自定义 `X-ClickHouse-Query-Id` 请求头,避免复用正在执行的查询标识。 + 空用户名或密码为空的非 default 用户也会被拒绝:当前 sink 无法解析这些凭据而不回退到驱动的默认账户。 + 支持密码为空的 default 用户。 + +校验成功仅确认元数据连接可用,不保证字段/类型兼容性、写入或 DDL 权限、 +`ERROR_WHEN_DATA_EXISTS` 条件、自定义 SQL 的正确性,或 `split_mode` 下所有分片的连通性。 +Clickhouse source 和 ClickhouseFile sink 的行为不变。 + ## 支持的数据源信息 为了使用 Clickhouse 连接器,需要以下依赖项。它们可以通过 install-plugin.sh 或从 Maven 中央存储库下载。 diff --git a/docs/zh/engines/zeta/user-command.md b/docs/zh/engines/zeta/user-command.md index 87f66b5493..c206b90198 100644 --- a/docs/zh/engines/zeta/user-command.md +++ b/docs/zh/engines/zeta/user-command.md @@ -102,6 +102,7 @@ bin/seatunnel.sh --config $SEATUNNEL_HOME/config/v2.batch.config.template --dry- | Jdbc | 支持(连通性 + schema 推断) | 支持(连通性 + 表存在性 + 字段兼容性) | | Kafka | 支持([主题元数据 + 运行时输出 schema](../../connectors/source/Kafka.md#连通性-dry-run),不含消费或消费组权限) | 支持([元数据 + 本地序列化检查](../../connectors/sink/Kafka.md#连通性-dry-run);动态主题仅执行有限检查,不检查 Produce/事务权限) | | FakeSource | 支持(仅 schema 推断,无外部系统) | - | +| Clickhouse | 不支持 | 支持([首个 host 的连通性 + 目标元数据](../../connectors/sink/Clickhouse.md#连通性-dry-run),不校验写入权限或字段兼容性) | | MongoDB | 不支持 | 支持([连通性 + 配置的认证](../../connectors/sink/MongoDB.md#连通性-dry-run),不检查集合、schema、写入权限或事务) | | Neo4j | 支持([驱动连通性 + 配置 schema,不含数据库/查询校验](../../connectors/source/Neo4j.md#连接预检查)) | 不支持 | | RabbitMQ | 支持([已有队列元数据 + 配置 schema,不含消费权限](../../connectors/source/Rabbitmq.md#连接预检查)) | 不支持 | diff --git a/seatunnel-connectors-v2/connector-clickhouse/src/main/java/org/apache/seatunnel/connectors/seatunnel/clickhouse/exception/ClickhouseConnectorErrorCode.java b/seatunnel-connectors-v2/connector-clickhouse/src/main/java/org/apache/seatunnel/connectors/seatunnel/clickhouse/exception/ClickhouseConnectorErrorCode.java index b9b99657e7..3794b748e5 100644 --- a/seatunnel-connectors-v2/connector-clickhouse/src/main/java/org/apache/seatunnel/connectors/seatunnel/clickhouse/exception/ClickhouseConnectorErrorCode.java +++ b/seatunnel-connectors-v2/connector-clickhouse/src/main/java/org/apache/seatunnel/connectors/seatunnel/clickhouse/exception/ClickhouseConnectorErrorCode.java @@ -43,7 +43,8 @@ public enum ClickhouseConnectorErrorCode implements SeaTunnelErrorCode { COMPLEX_SQL_NOT_SUPPORT_PARALLEL_ERROR( "CLICKHOUSE-15", "Complex sql not support parallel read."), ROW_BATCH_GET_FAILED("CLICKHOUSE-16", "Row batch get error"), - GET_TABLE_LIST_CONFIG_ERROR("CLICKHOUSE-17", "Get table list config error."); + GET_TABLE_LIST_CONFIG_ERROR("CLICKHOUSE-17", "Get table list config error."), + DRY_RUN_VALIDATION_FAILED("CLICKHOUSE-18", "Connect dry-run validation failed."); private final String code; private final String description; diff --git a/seatunnel-connectors-v2/connector-clickhouse/src/main/java/org/apache/seatunnel/connectors/seatunnel/clickhouse/sink/client/ClickhouseSinkFactory.java b/seatunnel-connectors-v2/connector-clickhouse/src/main/java/org/apache/seatunnel/connectors/seatunnel/clickhouse/sink/client/ClickhouseSinkFactory.java index d01982307e..c24197e15b 100644 --- a/seatunnel-connectors-v2/connector-clickhouse/src/main/java/org/apache/seatunnel/connectors/seatunnel/clickhouse/sink/client/ClickhouseSinkFactory.java +++ b/seatunnel-connectors-v2/connector-clickhouse/src/main/java/org/apache/seatunnel/connectors/seatunnel/clickhouse/sink/client/ClickhouseSinkFactory.java @@ -17,18 +17,34 @@ package org.apache.seatunnel.connectors.seatunnel.clickhouse.sink.client; +import org.apache.seatunnel.api.common.SeaTunnelAPIErrorCode; import org.apache.seatunnel.api.configuration.ReadonlyConfig; import org.apache.seatunnel.api.configuration.util.OptionRule; import org.apache.seatunnel.api.options.SinkConnectorCommonOptions; import org.apache.seatunnel.api.sink.DataSaveMode; +import org.apache.seatunnel.api.sink.SchemaSaveMode; import org.apache.seatunnel.api.table.catalog.CatalogTable; import org.apache.seatunnel.api.table.connector.TableSink; import org.apache.seatunnel.api.table.factory.Factory; +import org.apache.seatunnel.api.table.factory.SupportSinkDryRunValidation; import org.apache.seatunnel.api.table.factory.TableSinkFactory; import org.apache.seatunnel.api.table.factory.TableSinkFactoryContext; +import org.apache.seatunnel.connectors.seatunnel.clickhouse.exception.ClickhouseConnectorErrorCode; +import org.apache.seatunnel.connectors.seatunnel.clickhouse.exception.ClickhouseConnectorException; +import org.apache.seatunnel.connectors.seatunnel.clickhouse.util.ClickhouseUtil; +import com.clickhouse.client.ClickHouseClient; +import com.clickhouse.client.ClickHouseFormat; +import com.clickhouse.client.ClickHouseNode; +import com.clickhouse.client.ClickHouseResponse; +import com.clickhouse.client.ClickHouseUtils; +import com.clickhouse.client.config.ClickHouseClientOption; +import com.clickhouse.client.http.config.ClickHouseHttpOption; import com.google.auto.service.AutoService; +import java.util.HashMap; +import java.util.Map; + import static org.apache.seatunnel.connectors.seatunnel.clickhouse.config.ClickhouseBaseOptions.CLICKHOUSE_CONFIG; import static org.apache.seatunnel.connectors.seatunnel.clickhouse.config.ClickhouseBaseOptions.DATABASE; import static org.apache.seatunnel.connectors.seatunnel.clickhouse.config.ClickhouseBaseOptions.HOST; @@ -48,7 +64,9 @@ import static org.apache.seatunnel.connectors.seatunnel.clickhouse.config.Clickh import static org.apache.seatunnel.connectors.seatunnel.clickhouse.config.ClickhouseSinkOptions.TABLE; @AutoService(Factory.class) -public class ClickhouseSinkFactory implements TableSinkFactory { +public class ClickhouseSinkFactory implements TableSinkFactory, SupportSinkDryRunValidation { + private static final int DRY_RUN_TIMEOUT_MILLIS = 10_000; + @Override public String factoryIdentifier() { return "Clickhouse"; @@ -61,6 +79,104 @@ public class ClickhouseSinkFactory implements TableSinkFactory { return () -> new ClickhouseSink(catalogTable, readonlyConfig); } + /** Checks the runtime's initial server using system metadata, without invoking save modes. */ + @Override + public void validateConnectionForDryRun(TableSinkFactoryContext context) { + if (Thread.currentThread().isInterrupted()) { + throw new ClickhouseConnectorException( + ClickhouseConnectorErrorCode.DRY_RUN_VALIDATION_FAILED, + "Clickhouse sink dry-run was interrupted before connecting."); + } + ReadonlyConfig config = context.getOptions(); + String username = config.get(USERNAME); + String password = config.get(PASSWORD); + // createNodes only installs credentials when both fields are nonempty. Do not report + // validation of a named account when the runtime would fall back to the default account. + if (username.isEmpty() || (!"default".equals(username) && password.isEmpty())) { + throw new ClickhouseConnectorException( + ClickhouseConnectorErrorCode.DRY_RUN_VALIDATION_FAILED, + "Clickhouse sink dry-run requires a nonempty username and a nonempty password" + + " for non-default users; the sink cannot resolve these credentials."); + } + Map<String, String> options = new HashMap<>(config.get(CLICKHOUSE_CONFIG)); + if (!options.getOrDefault(ClickHouseHttpOption.CUSTOM_PARAMS.getKey(), "").isEmpty()) { + // HTTP parameters can supply an additional query, bypassing our metadata-only query. + throw new ClickhouseConnectorException( + ClickhouseConnectorErrorCode.DRY_RUN_VALIDATION_FAILED, + "Clickhouse sink dry-run does not support clickhouse.config.custom_http_params."); + } + if (ClickHouseUtils.getKeyValuePairs( + options.getOrDefault(ClickHouseHttpOption.CUSTOM_HEADERS.getKey(), "")) + .keySet().stream() + .anyMatch("X-ClickHouse-Query-Id"::equalsIgnoreCase)) { + throw new ClickhouseConnectorException( + ClickhouseConnectorErrorCode.DRY_RUN_VALIDATION_FAILED, + "Clickhouse sink dry-run does not support X-ClickHouse-Query-Id in custom_http_headers."); + } + boolean tableExists; + try { + capDryRunTimeout( + options, ClickHouseClientOption.CONNECTION_TIMEOUT, DRY_RUN_TIMEOUT_MILLIS); + capDryRunTimeout( + options, ClickHouseClientOption.SOCKET_TIMEOUT, DRY_RUN_TIMEOUT_MILLIS); + capDryRunTimeout(options, ClickHouseClientOption.MAX_EXECUTION_TIME, 10); + options.put(ClickHouseClientOption.RETRY.getKey(), "0"); + options.put(ClickHouseClientOption.FAILOVER.getKey(), "0"); + options.put(ClickHouseClientOption.REPEAT_ON_SESSION_LOCK.getKey(), "false"); + options.put(ClickHouseClientOption.SESSION_ID.getKey(), ""); + options.put(ClickHouseClientOption.ASYNC.getKey(), "false"); + // Do not select the target database: a create-schema mode can create it later. + options.put(ClickHouseClientOption.DATABASE.getKey(), ""); + ClickHouseNode node = + ClickhouseUtil.createNodes( + config.get(HOST), + "", + config.get(SERVER_TIME_ZONE), + username, + password, + options) + .get(0); + try (ClickHouseClient client = ClickHouseClient.newInstance(node.getProtocol()); + ClickHouseResponse response = + client.connect(node) + .format(ClickHouseFormat.RowBinaryWithNamesAndTypes) + .query( + "SELECT count() FROM system.tables" + + " WHERE database = :database AND name = :table") + // Object[] binds literal values; the String[] overload accepts + // SQL. + .params(new Object[] {config.get(DATABASE), config.get(TABLE)}) + .executeAndWait()) { + tableExists = response.firstRecord().getValue(0).asInteger() > 0; + } + } catch (Exception e) { + // Driver messages and suppressed close failures may include credentials or HTTP + // options. + throw new ClickhouseConnectorException( + ClickhouseConnectorErrorCode.DRY_RUN_VALIDATION_FAILED, + "Clickhouse sink dry-run failed. Check host, username, password, TLS settings," + + " timeouts, and access to system.tables."); + } + if (!tableExists + && config.get(SCHEMA_SAVE_MODE) == SchemaSaveMode.ERROR_WHEN_SCHEMA_NOT_EXIST) { + throw new ClickhouseConnectorException( + SeaTunnelAPIErrorCode.SINK_TABLE_NOT_EXIST, + "Clickhouse sink table is missing or not visible in system.tables and" + + " schema_save_mode is ERROR_WHEN_SCHEMA_NOT_EXIST." + + " Check the database and table options."); + } + } + + private static void capDryRunTimeout( + Map<String, String> options, ClickHouseClientOption option, int maximum) { + int configured = + Integer.parseInt( + options.getOrDefault(option.getKey(), option.getDefaultValue().toString())); + options.put( + option.getKey(), + Integer.toString(configured > 0 ? Math.min(configured, maximum) : maximum)); + } + @Override public OptionRule optionRule() { return OptionRule.builder() diff --git a/seatunnel-connectors-v2/connector-clickhouse/src/test/java/org/apache/seatunnel/connectors/seatunnel/clickhouse/ClickhouseFactoryTest.java b/seatunnel-connectors-v2/connector-clickhouse/src/test/java/org/apache/seatunnel/connectors/seatunnel/clickhouse/ClickhouseFactoryTest.java index 7d68a83add..2afdc559f3 100644 --- a/seatunnel-connectors-v2/connector-clickhouse/src/test/java/org/apache/seatunnel/connectors/seatunnel/clickhouse/ClickhouseFactoryTest.java +++ b/seatunnel-connectors-v2/connector-clickhouse/src/test/java/org/apache/seatunnel/connectors/seatunnel/clickhouse/ClickhouseFactoryTest.java @@ -20,19 +20,246 @@ package org.apache.seatunnel.connectors.seatunnel.clickhouse; import org.apache.seatunnel.api.configuration.ReadonlyConfig; import org.apache.seatunnel.api.configuration.util.ConfigValidator; import org.apache.seatunnel.api.configuration.util.OptionValidationException; +import org.apache.seatunnel.api.sink.SchemaSaveMode; +import org.apache.seatunnel.api.table.factory.SupportSinkDryRunValidation; +import org.apache.seatunnel.api.table.factory.TableSinkFactoryContext; import org.apache.seatunnel.connectors.seatunnel.clickhouse.config.ClickhouseBaseOptions; +import org.apache.seatunnel.connectors.seatunnel.clickhouse.exception.ClickhouseConnectorException; import org.apache.seatunnel.connectors.seatunnel.clickhouse.sink.client.ClickhouseSinkFactory; import org.apache.seatunnel.connectors.seatunnel.clickhouse.sink.file.ClickhouseFileSinkFactory; import org.apache.seatunnel.connectors.seatunnel.clickhouse.source.ClickhouseSourceFactory; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.MockedStatic; +import org.mockito.Mockito; +import com.clickhouse.client.ClickHouseClient; +import com.clickhouse.client.ClickHouseConfig; +import com.clickhouse.client.ClickHouseNode; +import com.clickhouse.client.ClickHouseRecord; +import com.clickhouse.client.ClickHouseRequest; +import com.clickhouse.client.ClickHouseResponse; +import com.clickhouse.client.ClickHouseValue; + +import java.io.PrintWriter; +import java.io.StringWriter; +import java.util.Collections; import java.util.HashMap; import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; public class ClickhouseFactoryTest { + @Test + public void testSinkSupportsConnectDryRun() { + Assertions.assertTrue(new ClickhouseSinkFactory() instanceof SupportSinkDryRunValidation); + } + + @Test + public void testSinkDryRunUsesMetadataAndClosesResources() throws Exception { + Map<String, Object> config = createValidSinkConfig(); + config.put("table", "quoted'\\table"); + Map<String, String> properties = new HashMap<>(); + properties.put("connect_timeout", "250"); + properties.put("socket_timeout", "0"); + properties.put("retry", "100"); + properties.put("max_execution_time", "2"); + properties.put("failover", "100"); + properties.put("ssl", "true"); + properties.put("sslrootcert", "/test-only/ca.pem"); + properties.put("session_id", "existing-session"); + config.put("clickhouse.config", properties); + ClickHouseClient client = mock(ClickHouseClient.class); + ClickHouseRequest<?> request = mock(ClickHouseRequest.class, Mockito.RETURNS_SELF); + ClickHouseResponse response = metadataResponse(1); + when(client.connect(any(ClickHouseNode.class))).thenAnswer(invocation -> request); + when(request.executeAndWait()).thenReturn(response); + try (MockedStatic<ClickHouseClient> clients = Mockito.mockStatic(ClickHouseClient.class)) { + clients.when(() -> ClickHouseClient.newInstance(any())).thenReturn(client); + validateSinkDryRun(config); + } + ArgumentCaptor<ClickHouseNode> node = ArgumentCaptor.forClass(ClickHouseNode.class); + verify(client).connect(node.capture()); + Assertions.assertEquals("localhost", node.getValue().getHost()); + Assertions.assertEquals(8123, node.getValue().getPort()); + Assertions.assertEquals("250", node.getValue().getOptions().get("connect_timeout")); + Assertions.assertEquals("10000", node.getValue().getOptions().get("socket_timeout")); + Assertions.assertEquals("0", node.getValue().getOptions().get("retry")); + Assertions.assertEquals("2", node.getValue().getOptions().get("max_execution_time")); + Assertions.assertEquals("0", node.getValue().getOptions().get("failover")); + Assertions.assertEquals("", node.getValue().getOptions().get("session_id")); + Assertions.assertEquals("true", node.getValue().getOptions().get("ssl")); + Assertions.assertEquals( + "/test-only/ca.pem", node.getValue().getOptions().get("sslrootcert")); + Assertions.assertEquals("100", properties.get("retry")); + verify(request) + .query( + "SELECT count() FROM system.tables WHERE database = :database AND name = :table"); + verify(request).params(new Object[] {"default", "quoted'\\table"}); + verify(response).close(); + verify(client).close(); + } + + @Test + public void testSinkDryRunRespectsSchemaSaveModesWithoutCreatingTables() throws Exception { + for (SchemaSaveMode mode : SchemaSaveMode.values()) { + Map<String, Object> config = createValidSinkConfig(); + config.put("schema_save_mode", mode.name()); + config.put("data_save_mode", "CUSTOM_PROCESSING"); + config.put("custom_sql", "DROP TABLE must_not_execute"); + ClickHouseClient client = mock(ClickHouseClient.class); + ClickHouseRequest<?> request = mock(ClickHouseRequest.class, Mockito.RETURNS_SELF); + ClickHouseResponse response = metadataResponse(0); + when(client.connect(any(ClickHouseNode.class))).thenAnswer(invocation -> request); + when(request.executeAndWait()).thenReturn(response); + try (MockedStatic<ClickHouseClient> clients = + Mockito.mockStatic(ClickHouseClient.class)) { + clients.when(() -> ClickHouseClient.newInstance(any())).thenReturn(client); + if (mode == SchemaSaveMode.ERROR_WHEN_SCHEMA_NOT_EXIST) { + ClickhouseConnectorException error = + Assertions.assertThrows( + ClickhouseConnectorException.class, + () -> validateSinkDryRun(config)); + Assertions.assertTrue( + error.getMessage().contains("ERROR_WHEN_SCHEMA_NOT_EXIST")); + } else { + validateSinkDryRun(config); + } + } + verify(request, never()).query("DROP TABLE must_not_execute"); + verify(response).close(); + verify(client).close(); + } + } + + @Test + public void testSinkDryRunSanitizesQueryAndCloseFailures() throws Exception { + for (boolean failQuery : new boolean[] {true, false}) { + ClickHouseClient client = mock(ClickHouseClient.class); + ClickHouseRequest<?> request = mock(ClickHouseRequest.class, Mockito.RETURNS_SELF); + ClickHouseResponse response = metadataResponse(0); + when(client.connect(any(ClickHouseNode.class))).thenAnswer(invocation -> request); + if (failQuery) { + when(request.executeAndWait()) + .thenThrow(new IllegalStateException("private-password")); + } else { + when(request.executeAndWait()).thenReturn(response); + Mockito.doThrow(new IllegalStateException("private-password")) + .when(response) + .close(); + } + Mockito.doThrow(new IllegalStateException("private-token")).when(client).close(); + try (MockedStatic<ClickHouseClient> clients = + Mockito.mockStatic(ClickHouseClient.class)) { + clients.when(() -> ClickHouseClient.newInstance(any())).thenReturn(client); + ClickhouseConnectorException error = + Assertions.assertThrows( + ClickhouseConnectorException.class, + () -> validateSinkDryRun(createValidSinkConfig())); + StringWriter trace = new StringWriter(); + error.printStackTrace(new PrintWriter(trace)); + Assertions.assertFalse(trace.toString().contains("private-password")); + Assertions.assertFalse(trace.toString().contains("private-token")); + Assertions.assertTrue(error.getMessage().contains("access to system.tables")); + } + verify(client).close(); + } + } + + @Test + public void testSinkDryRunRejectsUnsafeOptionsAndUnresolvedCredentialsBeforeConnecting() { + try (MockedStatic<ClickHouseClient> clients = Mockito.mockStatic(ClickHouseClient.class)) { + Map<String, Object> config = createValidSinkConfig(); + config.put( + "clickhouse.config", + Collections.singletonMap("custom_http_params", "query=SELECT 1")); + Assertions.assertThrows( + ClickhouseConnectorException.class, () -> validateSinkDryRun(config)); + config.put( + "clickhouse.config", + Collections.singletonMap( + "custom_http_headers", "x-clickhouse-query-id=existing-query")); + Assertions.assertThrows( + ClickhouseConnectorException.class, () -> validateSinkDryRun(config)); + config.remove("clickhouse.config"); + config.put("username", "non-default-user"); + config.put("password", ""); + Assertions.assertThrows( + ClickhouseConnectorException.class, () -> validateSinkDryRun(config)); + config.put("username", ""); + config.put("password", "password"); + Assertions.assertThrows( + ClickhouseConnectorException.class, () -> validateSinkDryRun(config)); + clients.verifyNoInteractions(); + } + } + + @Test + public void testSinkDryRunPreservesInterruption() throws Exception { + try (MockedStatic<ClickHouseClient> clients = Mockito.mockStatic(ClickHouseClient.class)) { + Thread.currentThread().interrupt(); + try { + Assertions.assertThrows( + ClickhouseConnectorException.class, + () -> validateSinkDryRun(createValidSinkConfig())); + Assertions.assertTrue(Thread.currentThread().isInterrupted()); + clients.verifyNoInteractions(); + } finally { + Thread.interrupted(); + } + ClickHouseClient client = mock(ClickHouseClient.class, Mockito.CALLS_REAL_METHODS); + when(client.getConfig()).thenReturn(new ClickHouseConfig()); + CompletableFuture<ClickHouseResponse> response = mock(CompletableFuture.class); + when(response.get(anyLong(), any(TimeUnit.class))) + .thenThrow(new InterruptedException("interrupted")); + when(client.execute(any(ClickHouseRequest.class))).thenReturn(response); + clients.when(() -> ClickHouseClient.newInstance(any())).thenReturn(client); + try { + Assertions.assertThrows( + ClickhouseConnectorException.class, + () -> validateSinkDryRun(createValidSinkConfig())); + Assertions.assertTrue(Thread.currentThread().isInterrupted()); + } finally { + Thread.interrupted(); + } + verify(client).close(); + } + } + + private ClickHouseResponse metadataResponse(int count) { + ClickHouseResponse response = mock(ClickHouseResponse.class); + ClickHouseRecord record = mock(ClickHouseRecord.class); + ClickHouseValue value = mock(ClickHouseValue.class); + when(response.firstRecord()).thenReturn(record); + when(record.getValue(0)).thenReturn(value); + when(value.asInteger()).thenReturn(count); + return response; + } + + private void validateSinkDryRun(Map<String, Object> config) { + new ClickhouseSinkFactory() + .validateConnectionForDryRun( + new TableSinkFactoryContext( + null, ReadonlyConfig.fromMap(config), getClass().getClassLoader())); + } + + private Map<String, Object> createValidSinkConfig() { + Map<String, Object> config = createValidSourceConfig(); + config.put("database", "default"); + config.put("table", "target"); + return config; + } + private void validateSource(Map<String, Object> configMap) { ClickhouseSourceFactory factory = new ClickhouseSourceFactory(); ConfigValidator.of(ReadonlyConfig.fromMap(configMap)).validate(factory.optionRule()); diff --git a/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-clickhouse-e2e/src/test/java/org/apache/seatunnel/connectors/seatunnel/clickhouse/ClickhouseIT.java b/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-clickhouse-e2e/src/test/java/org/apache/seatunnel/connectors/seatunnel/clickhouse/ClickhouseIT.java index 87fe215152..b0a9b51dd4 100644 --- a/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-clickhouse-e2e/src/test/java/org/apache/seatunnel/connectors/seatunnel/clickhouse/ClickhouseIT.java +++ b/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-clickhouse-e2e/src/test/java/org/apache/seatunnel/connectors/seatunnel/clickhouse/ClickhouseIT.java @@ -18,9 +18,11 @@ package org.apache.seatunnel.connectors.seatunnel.clickhouse; import org.apache.seatunnel.api.configuration.ReadonlyConfig; +import org.apache.seatunnel.api.sink.SchemaSaveMode; import org.apache.seatunnel.api.table.catalog.CatalogTable; import org.apache.seatunnel.api.table.catalog.Column; import org.apache.seatunnel.api.table.catalog.TablePath; +import org.apache.seatunnel.api.table.factory.TableSinkFactoryContext; import org.apache.seatunnel.api.table.type.ArrayType; import org.apache.seatunnel.api.table.type.BasicType; import org.apache.seatunnel.api.table.type.DecimalType; @@ -30,6 +32,8 @@ import org.apache.seatunnel.api.table.type.SeaTunnelDataType; import org.apache.seatunnel.api.table.type.SeaTunnelRow; import org.apache.seatunnel.api.table.type.SeaTunnelRowType; import org.apache.seatunnel.connectors.seatunnel.clickhouse.catalog.ClickhouseCatalog; +import org.apache.seatunnel.connectors.seatunnel.clickhouse.exception.ClickhouseConnectorException; +import org.apache.seatunnel.connectors.seatunnel.clickhouse.sink.client.ClickhouseSinkFactory; import org.apache.seatunnel.e2e.common.TestResource; import org.apache.seatunnel.e2e.common.TestSuiteBase; import org.apache.seatunnel.e2e.common.container.TestContainer; @@ -39,6 +43,7 @@ import org.awaitility.Awaitility; import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; import org.junit.jupiter.api.TestTemplate; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -57,6 +62,8 @@ import java.io.File; import java.io.IOException; import java.io.InputStream; import java.math.BigDecimal; +import java.net.InetAddress; +import java.net.ServerSocket; import java.nio.charset.StandardCharsets; import java.sql.Array; import java.sql.Connection; @@ -67,6 +74,7 @@ import java.sql.ResultSet; import java.sql.SQLException; import java.sql.Statement; import java.sql.Timestamp; +import java.time.Duration; import java.time.LocalDate; import java.time.LocalDateTime; import java.util.ArrayList; @@ -110,6 +118,125 @@ public class ClickhouseIT extends TestSuiteBase implements TestResource { private static final String FIX_PARTITION_DATE = "2025-06-17"; + @Test + public void testSinkConnectDryRunDoesNotExecuteSaveModes() throws Exception { + String table = "dry_run_target"; + try (Statement statement = connection.createStatement()) { + statement.execute("CREATE TABLE default." + table + " (id Int32) ENGINE = Memory"); + statement.execute("INSERT INTO default." + table + " VALUES (7)"); + try { + for (SchemaSaveMode mode : SchemaSaveMode.values()) { + Map<String, Object> options = dryRunSinkOptions(table); + options.put("schema_save_mode", mode.name()); + options.put("data_save_mode", "DROP_DATA"); + validateSinkDryRun(options); + options.put("data_save_mode", "CUSTOM_PROCESSING"); + options.put("custom_sql", "DROP TABLE default." + table); + validateSinkDryRun(options); + Assertions.assertEquals(1, countData(table)); + } + try (ResultSet rows = statement.executeQuery("SELECT id FROM default." + table)) { + Assertions.assertTrue(rows.next()); + Assertions.assertEquals(7, rows.getInt(1)); + Assertions.assertFalse(rows.next()); + } + } finally { + statement.execute("DROP TABLE default." + table); + } + } + } + + @Test + public void testSinkConnectDryRunAllowsCreationWithoutCreatingTarget() throws Exception { + Map<String, Object> options = dryRunSinkOptions("dry_run_missing_table"); + for (SchemaSaveMode mode : + Arrays.asList( + SchemaSaveMode.CREATE_SCHEMA_WHEN_NOT_EXIST, + SchemaSaveMode.RECREATE_SCHEMA, + SchemaSaveMode.IGNORE)) { + options.put("schema_save_mode", mode.name()); + validateSinkDryRun(options); + } + options.put("schema_save_mode", SchemaSaveMode.ERROR_WHEN_SCHEMA_NOT_EXIST.name()); + ClickhouseConnectorException error = + Assertions.assertThrows( + ClickhouseConnectorException.class, () -> validateSinkDryRun(options)); + Assertions.assertTrue(error.getMessage().contains("ERROR_WHEN_SCHEMA_NOT_EXIST")); + try (Statement statement = connection.createStatement(); + ResultSet tables = + statement.executeQuery("EXISTS TABLE default.dry_run_missing_table")) { + Assertions.assertTrue(tables.next()); + Assertions.assertEquals(0, tables.getInt(1)); + } + options.put("database", "dry_run_missing_database"); + options.put("schema_save_mode", SchemaSaveMode.CREATE_SCHEMA_WHEN_NOT_EXIST.name()); + validateSinkDryRun(options); + try (Statement statement = connection.createStatement(); + ResultSet databases = + statement.executeQuery( + "SELECT count() FROM system.databases WHERE name = 'dry_run_missing_database'")) { + Assertions.assertTrue(databases.next()); + Assertions.assertEquals(0, databases.getInt(1)); + } + } + + @Test + public void testSinkConnectDryRunRejectsAuthenticationAndConnectionFailures() throws Exception { + Map<String, Object> options = dryRunSinkOptions(SINK_TABLE); + try (Statement statement = connection.createStatement()) { + statement.execute( + "CREATE USER seatunnel_dry_run_reader IDENTIFIED BY 'test-only-password'"); + try { + statement.execute("GRANT SELECT ON *.* TO seatunnel_dry_run_reader"); + options.put("username", "seatunnel_dry_run_reader"); + options.put("password", "test-only-password"); + options.put("schema_save_mode", SchemaSaveMode.ERROR_WHEN_SCHEMA_NOT_EXIST.name()); + // Metadata access does not require INSERT/CREATE/DROP privileges. + validateSinkDryRun(options); + } finally { + statement.execute("DROP USER seatunnel_dry_run_reader"); + } + } + options.put("username", container.getUsername()); + options.put("password", "dry-run-invalid-password"); + ClickhouseConnectorException error = + Assertions.assertThrows( + ClickhouseConnectorException.class, () -> validateSinkDryRun(options)); + Assertions.assertFalse(error.getMessage().contains("dry-run-invalid-password")); + Assertions.assertNull(error.getCause()); + try (ServerSocket unavailable = new ServerSocket(0, 1, InetAddress.getLoopbackAddress())) { + options.put("host", "localhost:" + unavailable.getLocalPort()); + options.put("clickhouse.config", Collections.singletonMap("socket_timeout", "200")); + // An accepting-but-unresponsive endpoint exercises the socket deadline without port + // races. + Assertions.assertTimeoutPreemptively( + Duration.ofSeconds(5), + () -> + Assertions.assertThrows( + ClickhouseConnectorException.class, + () -> validateSinkDryRun(options))); + } + } + + private Map<String, Object> dryRunSinkOptions(String table) { + Map<String, Object> options = new HashMap<>(); + options.put("host", container.getHost() + ":" + container.getMappedPort(8123)); + options.put("username", container.getUsername()); + options.put("password", container.getPassword()); + options.put("database", DATABASE); + options.put("table", table); + return options; + } + + private void validateSinkDryRun(Map<String, Object> options) { + new ClickhouseSinkFactory() + .validateConnectionForDryRun( + new TableSinkFactoryContext( + null, + ReadonlyConfig.fromMap(options), + getClass().getClassLoader())); + } + @TestTemplate public void testClickhouse(TestContainer container) throws Exception { Container.ExecResult execResult = container.executeJob(CLICKHOUSE_JOB_CONFIG); @@ -507,6 +634,7 @@ public class ClickhouseIT extends TestSuiteBase implements TestResource { public void startUp() throws Exception { this.container = new ClickHouseContainer(CLICKHOUSE_DOCKER_IMAGE) + .withEnv("CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT", "1") .withNetwork(NETWORK) .withNetworkAliases(HOST) .withLogConsumer(
