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-13046-f08a248b12c71f39d9b13eab1691bd0a7ea613b5 in repository https://gitbox.apache.org/repos/asf/seatunnel.git
commit 909a217c09e628631afe6edf29a01ec85de1cbc9 Author: Sepuri Sai Krishna <[email protected]> AuthorDate: Fri Oct 30 02:39:52 2026 +0000 [Fix][Connector-V2] Keep the column of a null field in the Druid sink (#13046) --- docs/en/connectors/sink/Druid.md | 2 + docs/zh/connectors/sink/Druid.md | 2 + .../connectors/druid/sink/DruidWriter.java | 7 +- .../connectors/druid/sink/DruidWriterTest.java | 166 +++++++++++++++++++++ .../seatunnel/e2e/connector/druid/DruidIT.java | 48 ++++++ .../resources/fakesource_to_druid_with_null.conf | 61 ++++++++ 6 files changed, 283 insertions(+), 3 deletions(-) diff --git a/docs/en/connectors/sink/Druid.md b/docs/en/connectors/sink/Druid.md index 8fe4d19499..978390038d 100644 --- a/docs/en/connectors/sink/Druid.md +++ b/docs/en/connectors/sink/Druid.md @@ -79,6 +79,8 @@ Only the data types listed in [Data Type Mapping](#data-type-mapping) are suppor Because the connector submits inline CSV data to Druid, string values should not contain raw commas or line breaks unless they are normalized before the Druid sink. +A null field is written as an empty CSV value, so it keeps its column. Druid stores it according to `druid.generic.useDefaultValueForNull`: with the default `true`, as `''` for string columns and `0` for numeric columns; with `false`, as a SQL null. + ## Example ### Write One Table diff --git a/docs/zh/connectors/sink/Druid.md b/docs/zh/connectors/sink/Druid.md index 8e82ba40eb..806c4cf10c 100644 --- a/docs/zh/connectors/sink/Druid.md +++ b/docs/zh/connectors/sink/Druid.md @@ -79,6 +79,8 @@ Druid 写入需要主时间列。连接器会自动追加一个名为 `timestamp 由于连接器会把数据以内联 CSV 的形式提交给 Druid,字符串字段中不建议直接包含英文逗号或换行符;如有这类内容,建议在进入 Druid Sink 前先做清洗或替换。 +值为 null 的字段会写成空的 CSV 值,因此仍占据原来的列。Druid 按 `druid.generic.useDefaultValueForNull` 存储该值:默认值为 `true` 时,字符串列存为 `''`,数值列存为 `0`;为 `false` 时存为 SQL null。 + ## 示例 ### 写入单表 diff --git a/seatunnel-connectors-v2/connector-druid/src/main/java/org/apache/seatunnel/connectors/druid/sink/DruidWriter.java b/seatunnel-connectors-v2/connector-druid/src/main/java/org/apache/seatunnel/connectors/druid/sink/DruidWriter.java index af8e3a330f..bb2112111a 100644 --- a/seatunnel-connectors-v2/connector-druid/src/main/java/org/apache/seatunnel/connectors/druid/sink/DruidWriter.java +++ b/seatunnel-connectors-v2/connector-druid/src/main/java/org/apache/seatunnel/connectors/druid/sink/DruidWriter.java @@ -112,9 +112,10 @@ public class DruidWriter extends AbstractSinkWriter<SeaTunnelRow, Void> final StringJoiner joiner = new StringJoiner(DEFAULT_FIELD_DELIMITER, "", ""); for (int i = 0; i < element.getArity(); i++) { final Object v = element.getField(i); - if (v != null) { - joiner.add(v.toString()); - } + // A null is written as an empty field so that it keeps its column. Leaving it out + // shifts every later value one column left, the timestamp column comes out missing, + // and Druid drops the row as unparseable without failing the task. + joiner.add(v == null ? "" : v.toString()); } // timestamp column is a required field to add in Druid. // See https://druid.apache.org/docs/24.0.0/ingestion/data-model.html#primary-timestamp diff --git a/seatunnel-connectors-v2/connector-druid/src/test/java/org/apache/seatunnel/connectors/druid/sink/DruidWriterTest.java b/seatunnel-connectors-v2/connector-druid/src/test/java/org/apache/seatunnel/connectors/druid/sink/DruidWriterTest.java new file mode 100644 index 0000000000..b1885cc045 --- /dev/null +++ b/seatunnel-connectors-v2/connector-druid/src/test/java/org/apache/seatunnel/connectors/druid/sink/DruidWriterTest.java @@ -0,0 +1,166 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.seatunnel.connectors.druid.sink; + +import org.apache.seatunnel.api.table.type.BasicType; +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.druid.common.config.NullHandling; +import org.apache.druid.data.input.ColumnsFilter; +import org.apache.druid.data.input.InputRow; +import org.apache.druid.data.input.InputRowSchema; +import org.apache.druid.data.input.impl.CsvInputFormat; +import org.apache.druid.data.input.impl.DimensionsSpec; +import org.apache.druid.data.input.impl.InlineInputSource; +import org.apache.druid.data.input.impl.StringDimensionSchema; +import org.apache.druid.data.input.impl.TimestampSpec; +import org.apache.druid.java.util.common.parsers.CloseableIterator; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sun.net.httpserver.HttpServer; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +public class DruidWriterTest { + + private static final SeaTunnelRowType ROW_TYPE = + new SeaTunnelRowType( + new String[] {"id", "name", "amount"}, + new SeaTunnelDataType<?>[] { + BasicType.INT_TYPE, BasicType.STRING_TYPE, BasicType.LONG_TYPE + }); + + private static final List<SeaTunnelRow> ROWS = + Arrays.asList( + new SeaTunnelRow(new Object[] {1, "a", 10L}), + new SeaTunnelRow(new Object[] {2, null, 20L}), + new SeaTunnelRow(new Object[] {3, "c", null})); + + @Test + public void aNullFieldKeepsItsColumn() throws IOException { + JsonNode ioConfig = submitIoConfig(ROWS); + String[] lines = ioConfig.get("inputSource").get("data").asText().split("\n"); + + Assertions.assertEquals(3, lines.length); + // The last field is the processing time the writer appends, the same on every line. + String timestamp = lines[0].substring(lines[0].lastIndexOf(',') + 1); + Assertions.assertEquals("1,a,10," + timestamp, lines[0]); + Assertions.assertEquals("2,,20," + timestamp, lines[1]); + Assertions.assertEquals("3,c,," + timestamp, lines[2]); + } + + @Test + public void druidReadsEveryRowThatHasANullField(@TempDir Path temporaryDirectory) + throws IOException { + JsonNode ioConfig = submitIoConfig(ROWS); + List<String> columns = new ArrayList<>(); + ioConfig.get("inputFormat").get("columns").forEach(column -> columns.add(column.asText())); + Assertions.assertEquals(Arrays.asList("id", "name", "amount", "timestamp"), columns); + + // Read the submitted data with Druid's own CSV reader and the columns the task declares. + // A row that comes out a field short is rejected there as having no timestamp. + NullHandling.initializeForTests(); + InputRowSchema schema = + new InputRowSchema( + new TimestampSpec("timestamp", "auto", null), + new DimensionsSpec( + Arrays.asList( + new StringDimensionSchema("id"), + new StringDimensionSchema("name"), + new StringDimensionSchema("amount"))), + ColumnsFilter.all()); + List<InputRow> rows = new ArrayList<>(); + try (CloseableIterator<InputRow> iterator = + new InlineInputSource(ioConfig.get("inputSource").get("data").asText()) + .reader( + schema, + new CsvInputFormat(columns, "\n", null, false, 0), + temporaryDirectory.toFile()) + .read()) { + iterator.forEachRemaining(rows::add); + } + + Assertions.assertEquals(3, rows.size()); + Assertions.assertNull(rows.get(1).getRaw("name")); + Assertions.assertEquals("20", rows.get(1).getRaw("amount")); + Assertions.assertEquals("c", rows.get(2).getRaw("name")); + Assertions.assertNull(rows.get(2).getRaw("amount")); + } + + /** + * Writes the rows through a {@link DruidWriter} pointed at a local stand-in for the Druid + * Overlord, and returns the {@code ioConfig} of the indexing task it submits on close. + */ + private static JsonNode submitIoConfig(List<SeaTunnelRow> rows) throws IOException { + List<String> requestBodies = new ArrayList<>(); + HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext( + "/druid/indexer/v1/task", + exchange -> { + try (InputStream in = exchange.getRequestBody()) { + ByteArrayOutputStream body = new ByteArrayOutputStream(); + byte[] buffer = new byte[8192]; + int read; + while ((read = in.read(buffer)) > 0) { + body.write(buffer, 0, read); + } + requestBodies.add(new String(body.toByteArray(), StandardCharsets.UTF_8)); + } + byte[] response = "{\"task\":\"test\"}".getBytes(StandardCharsets.UTF_8); + exchange.sendResponseHeaders(200, response.length); + try (OutputStream out = exchange.getResponseBody()) { + out.write(response); + } + }); + server.start(); + try { + // A batch size above the row count, so the only submission happens on close. + DruidWriter writer = + new DruidWriter( + ROW_TYPE, + "127.0.0.1:" + server.getAddress().getPort(), + "test_datasource", + rows.size() + 1); + for (SeaTunnelRow row : rows) { + writer.write(row); + } + writer.close(); + } finally { + server.stop(0); + } + + Assertions.assertEquals(1, requestBodies.size()); + return new ObjectMapper().readTree(requestBodies.get(0)).get("spec").get("ioConfig"); + } +} diff --git a/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-druid-e2e/src/test/java/org/apache/seatunnel/e2e/connector/druid/DruidIT.java b/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-druid-e2e/src/test/java/org/apache/seatunnel/e2e/connector/druid/DruidIT.java index 87390f0f46..e9ff70efb1 100644 --- a/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-druid-e2e/src/test/java/org/apache/seatunnel/e2e/connector/druid/DruidIT.java +++ b/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-druid-e2e/src/test/java/org/apache/seatunnel/e2e/connector/druid/DruidIT.java @@ -18,6 +18,10 @@ package org.apache.seatunnel.e2e.connector.druid; +import org.apache.seatunnel.shade.com.fasterxml.jackson.databind.JsonNode; +import org.apache.seatunnel.shade.com.fasterxml.jackson.databind.node.ArrayNode; + +import org.apache.seatunnel.common.utils.JsonUtils; import org.apache.seatunnel.e2e.common.TestResource; import org.apache.seatunnel.e2e.common.TestSuiteBase; import org.apache.seatunnel.e2e.common.container.EngineType; @@ -46,7 +50,11 @@ import java.io.File; import java.io.IOException; import java.net.InetAddress; import java.time.Duration; +import java.util.Arrays; import java.util.Collections; +import java.util.HashMap; +import java.util.Locale; +import java.util.Map; import java.util.concurrent.TimeUnit; import static org.awaitility.Awaitility.given; @@ -60,6 +68,8 @@ public class DruidIT extends TestSuiteBase implements TestResource { private static final String DATASOURCE = "testDataSource"; private static final String MULTI_DATASOURCE_1 = "druid_sink_1"; private static final String MULTI_DATASOURCE_2 = "druid_sink_2"; + private static final String NULL_FIELD_DATASOURCE_PREFIX = "druid_null_field_"; + private static final String NULL_FIELD_DATASOURCE_VARIABLE = "nullFieldDatasource"; private static final String SQL_QUERY_TEMPLATE = "SELECT * FROM "; private static final String DRUID_SERVICE_NAME = "router"; private static final int DRUID_SERVICE_PORT = 8888; @@ -161,6 +171,44 @@ public class DruidIT extends TestSuiteBase implements TestResource { }); } + @TestTemplate + public void testDruidSinkWithNullFields(TestContainer container) throws Exception { + // The Druid cluster is shared by every engine this template runs on, so each run writes + // to its own datasource. Otherwise a run could pass on rows an earlier run indexed. + String datasource = + NULL_FIELD_DATASOURCE_PREFIX + + container.identifier().name().toLowerCase(Locale.ROOT); + Container.ExecResult execResult = + container.executeJob( + "/fakesource_to_druid_with_null.conf", + Arrays.asList( + COORDINATOR_URL_VARIABLE + "=" + coordinatorURL, + NULL_FIELD_DATASOURCE_VARIABLE + "=" + datasource)); + Assertions.assertEquals(0, execResult.getExitCode()); + given().ignoreExceptions() + .await() + .atLeast(100L, TimeUnit.MILLISECONDS) + .atMost(400L, TimeUnit.SECONDS) + .untilAsserted( + () -> { + ArrayNode rows = JsonUtils.parseArray(getSelectResponse(datasource)); + // Rows 2 and 3 each have a null. Before the fix both were dropped, + // because the null was left out of the CSV line. How Druid renders + // the null itself depends on its null handling, so it is not + // asserted. + Assertions.assertEquals(3, rows.size(), rows.toString()); + Map<Long, JsonNode> rowsById = new HashMap<>(); + for (JsonNode row : rows) { + rowsById.put(row.get("id").asLong(), row); + } + Assertions.assertEquals("a", rowsById.get(1L).get("name").asText()); + Assertions.assertEquals(10L, rowsById.get(1L).get("amount").asLong()); + // The value after the null name stayed in its own column. + Assertions.assertEquals(20L, rowsById.get(2L).get("amount").asLong()); + Assertions.assertEquals("c", rowsById.get(3L).get("name").asText()); + }); + } + private String getSelectResponse(String datasource) throws IOException { try (CloseableHttpClient client = HttpClients.createDefault()) { HttpPost request = new HttpPost("http://" + coordinatorURL + "/druid/v2/sql"); diff --git a/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-druid-e2e/src/test/resources/fakesource_to_druid_with_null.conf b/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-druid-e2e/src/test/resources/fakesource_to_druid_with_null.conf new file mode 100644 index 0000000000..d542bc4fe1 --- /dev/null +++ b/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-druid-e2e/src/test/resources/fakesource_to_druid_with_null.conf @@ -0,0 +1,61 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +env { + parallelism = 1 + job.mode = "BATCH" +} + +source { + FakeSource { + plugin_output = "fake" + schema = { + fields { + id = int + name = string + amount = bigint + } + } + # The second row has a null string and the third a null number. Each must still be ingested, + # and every value after the null must stay in its own column. + rows = [ + { + kind = INSERT + fields = [1, "a", 10] + }, + { + kind = INSERT + fields = [2, null, 20] + }, + { + kind = INSERT + fields = [3, "c", null] + } + ] + } +} + +transform { +} + +sink { + Druid { + coordinatorUrl = "${druidCoordinatorUrl}" + # Set per engine run by DruidIT, so each run reads back only the rows it wrote. + datasource = "${nullFieldDatasource}" + } +}
