From f7c5db3f163bb9ebd5b12afc4bf50a14de5dd35d Mon Sep 17 00:00:00 2001 From: Roman Rinchinov Date: Mon, 22 Jun 2026 15:10:16 +0200 Subject: [PATCH 1/4] test(delta): add embedded e2e ingestion test for Delta Lake input source Adds an end-to-end ingestion test that runs inside an embedded Druid cluster (overlord, coordinator, indexer, broker, historical) and ingests a Delta table via a native IndexTask using the Delta Lake input source. The table has 2 Parquet files x 2000 rows = 4000 rows total. Because each file exceeds the Delta kernel's default 1024-row batch size, this is the integration-level counterpart of the unit regression test DeltaInputSourceTest.BatchDrainRegressionTests for the per-file batch-drain bug (GH-18606): without the fix the reader returns 1024 * 2 = 2048 rows. Modeled on the Iceberg embedded test (IcebergRestCatalogIngestionTest). Delta needs no catalog/container since it reads directly from a local path. Co-Authored-By: Claude Opus 4.8 (1M context) --- embedded-tests/pom.xml | 13 ++ .../DeltaLakeInputSourceIngestionTest.java | 120 ++++++++++++++++++ ...-a6fe-5397f37d29d8-c000.snappy.parquet.crc | Bin 0 -> 152 bytes ...-af60-5eaca2f7ba03-c000.snappy.parquet.crc | Bin 0 -> 152 bytes .../_delta_log/.00000000000000000000.json.crc | Bin 0 -> 20 bytes .../_delta_log/00000000000000000000.json | 5 + ...42a0-a6fe-5397f37d29d8-c000.snappy.parquet | Bin 0 -> 18078 bytes ...4094-af60-5eaca2f7ba03-c000.snappy.parquet | Bin 0 -> 18185 bytes 8 files changed, 138 insertions(+) create mode 100644 embedded-tests/src/test/java/org/apache/druid/testing/embedded/deltalake/DeltaLakeInputSourceIngestionTest.java create mode 100644 embedded-tests/src/test/resources/delta/large-row-group-table/.part-00000-42349806-104f-42a0-a6fe-5397f37d29d8-c000.snappy.parquet.crc create mode 100644 embedded-tests/src/test/resources/delta/large-row-group-table/.part-00001-42b5d278-2c32-4094-af60-5eaca2f7ba03-c000.snappy.parquet.crc create mode 100644 embedded-tests/src/test/resources/delta/large-row-group-table/_delta_log/.00000000000000000000.json.crc create mode 100644 embedded-tests/src/test/resources/delta/large-row-group-table/_delta_log/00000000000000000000.json create mode 100644 embedded-tests/src/test/resources/delta/large-row-group-table/part-00000-42349806-104f-42a0-a6fe-5397f37d29d8-c000.snappy.parquet create mode 100644 embedded-tests/src/test/resources/delta/large-row-group-table/part-00001-42b5d278-2c32-4094-af60-5eaca2f7ba03-c000.snappy.parquet diff --git a/embedded-tests/pom.xml b/embedded-tests/pom.xml index bad770bf572f..f16440109f95 100644 --- a/embedded-tests/pom.xml +++ b/embedded-tests/pom.xml @@ -311,6 +311,19 @@ + + org.apache.druid.extensions.contrib + druid-deltalake-extensions + ${project.parent.version} + test + + + + org.apache.hadoop + hadoop-common + + + org.apache.druid.extensions.contrib kafka-emitter diff --git a/embedded-tests/src/test/java/org/apache/druid/testing/embedded/deltalake/DeltaLakeInputSourceIngestionTest.java b/embedded-tests/src/test/java/org/apache/druid/testing/embedded/deltalake/DeltaLakeInputSourceIngestionTest.java new file mode 100644 index 000000000000..ac7646f9a311 --- /dev/null +++ b/embedded-tests/src/test/java/org/apache/druid/testing/embedded/deltalake/DeltaLakeInputSourceIngestionTest.java @@ -0,0 +1,120 @@ +/* + * 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.druid.testing.embedded.deltalake; + +import org.apache.druid.data.input.impl.TimestampSpec; +import org.apache.druid.delta.common.DeltaLakeDruidModule; +import org.apache.druid.delta.input.DeltaInputSource; +import org.apache.druid.indexing.common.task.IndexTask; +import org.apache.druid.indexing.common.task.TaskBuilder; +import org.apache.druid.testing.embedded.EmbeddedBroker; +import org.apache.druid.testing.embedded.EmbeddedClusterApis; +import org.apache.druid.testing.embedded.EmbeddedCoordinator; +import org.apache.druid.testing.embedded.EmbeddedDruidCluster; +import org.apache.druid.testing.embedded.EmbeddedHistorical; +import org.apache.druid.testing.embedded.EmbeddedIndexer; +import org.apache.druid.testing.embedded.EmbeddedOverlord; +import org.apache.druid.testing.embedded.indexing.Resources; +import org.apache.druid.testing.embedded.junit5.EmbeddedClusterTestBase; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.io.File; + +/** + * End-to-end ingestion test for the Delta Lake input source running inside an embedded Druid cluster. + * + *

The test ingests a Delta table whose two Parquet files contain 2000 rows each (4000 rows total). + * Because each file has more than the Delta kernel's default batch size of 1024 rows, this exercises the + * per-file batch-drain path in {@code DeltaInputSourceReader} that regressed in + * GH-18606: the reader only returned the first + * 1024 rows of each file, yielding {@code 1024 * 2 = 2048} rows instead of 4000. This is the integration-level + * counterpart of the unit regression test {@code DeltaInputSourceTest.BatchDrainRegressionTests}. + */ +public class DeltaLakeInputSourceIngestionTest extends EmbeddedClusterTestBase +{ + /** + * Delta table with 2 Parquet files x 2000 rows = 4000 rows total, columns {@code id} (long) and + * {@code name} (string). Copied from the {@code druid-deltalake-extensions} test resources + * ({@code large-row-group-table}). + */ + private static final String DELTA_TABLE_RESOURCE = "delta/large-row-group-table"; + private static final int EXPECTED_ROW_COUNT = 4000; + + private final EmbeddedOverlord overlord = new EmbeddedOverlord(); + private final EmbeddedCoordinator coordinator = new EmbeddedCoordinator(); + private final EmbeddedIndexer indexer = new EmbeddedIndexer() + .setServerMemory(300_000_000L) + .addProperty("druid.worker.capacity", "2"); + private final EmbeddedBroker broker = new EmbeddedBroker(); + + @Override + protected EmbeddedDruidCluster createCluster() + { + return EmbeddedDruidCluster + .withEmbeddedDerbyAndZookeeper() + .useLatchableEmitter() + .addExtension(DeltaLakeDruidModule.class) + .addServer(overlord) + .addServer(coordinator) + .addServer(indexer) + .addServer(broker) + .addServer(new EmbeddedHistorical()); + } + + @Test + public void testIngestAllRowsFromDeltaTable() + { + final File deltaTableDir = Resources.getFileForResource(DELTA_TABLE_RESOURCE); + + final String taskId = EmbeddedClusterApis.newTaskId(dataSource); + final IndexTask task = TaskBuilder + .ofTypeIndex() + .inputSource(new DeltaInputSource(deltaTableDir.getAbsolutePath(), null, null, null)) + // The Delta input source produces rows directly, so no inputFormat is required. + .dataSource(dataSource) + // The 'id' column holds POSIX seconds, used here as the timestamp. + .dataSchema(builder -> builder.withTimestamp(new TimestampSpec("id", "posix", null))) + .dimensions("name") + .granularitySpec("DAY", "NONE", false) + .dynamicPartitionWithMaxRows(EXPECTED_ROW_COUNT + 1) + .appendToExisting(false) + .withId(taskId); + + cluster.callApi().runTask(task, overlord); + cluster.callApi().waitForAllSegmentsToBeAvailable(dataSource, coordinator, broker); + + // The core regression assertion: all 4000 rows must be ingested. Before the GH-18606 fix this + // returned 1024 * 2 = 2048 because only the first batch of each Parquet file was read. COUNT(*) is + // exact (unlike SQL COUNT(DISTINCT), which is approximate by default), so the comparison is stable. + Assertions.assertEquals( + String.valueOf(EXPECTED_ROW_COUNT), + cluster.runSql("SELECT COUNT(*) FROM %s", dataSource), + "Expected all rows to be ingested. A count of 2048 indicates the per-file batch-drain bug (GH-18606) regressed." + ); + + // The 'id' column was used as the POSIX timestamp; its documented min/max (0 and 3999) bound __time, + // confirming rows from both Parquet files (not just the first batch of each) were ingested. + Assertions.assertEquals( + "1970-01-01T00:00:00.000Z,1970-01-01T01:06:39.000Z", + cluster.runSql("SELECT MIN(__time), MAX(__time) FROM %s", dataSource) + ); + } +} diff --git a/embedded-tests/src/test/resources/delta/large-row-group-table/.part-00000-42349806-104f-42a0-a6fe-5397f37d29d8-c000.snappy.parquet.crc b/embedded-tests/src/test/resources/delta/large-row-group-table/.part-00000-42349806-104f-42a0-a6fe-5397f37d29d8-c000.snappy.parquet.crc new file mode 100644 index 0000000000000000000000000000000000000000..aa967daecef790eae712f4c108eab2c7dcb931fd GIT binary patch literal 152 zcmV;J0B8SWa$^7h00IE!fF&R8pAaH!0A0Qzs#hSHyF3vbm;&`*-PyZf+w)90e|lng zm~A4PEfoF1#9F_pa>r5ON$0;oiX1|5E{R~FVR95(xG6SpL-`M(N_g${0uZmH*R|O2 zj9T(C1e>~D*knZZiZZiht*NKTJr|xjhf&Q(DVtcx|Gg)IRq8lbqh%cOX@VJY@2HSF GMUNRL<3{WN literal 0 HcmV?d00001 diff --git a/embedded-tests/src/test/resources/delta/large-row-group-table/.part-00001-42b5d278-2c32-4094-af60-5eaca2f7ba03-c000.snappy.parquet.crc b/embedded-tests/src/test/resources/delta/large-row-group-table/.part-00001-42b5d278-2c32-4094-af60-5eaca2f7ba03-c000.snappy.parquet.crc new file mode 100644 index 0000000000000000000000000000000000000000..a46c186eed7b7b8e5d0ba694f6c213f2fb8180bc GIT binary patch literal 152 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 uJ@EYTW3!=`9+wZ&W5 Date: Mon, 22 Jun 2026 16:16:55 +0200 Subject: [PATCH 2/4] test(delta): clarify that COUNT(*) (not __time bounds) guarantees completeness Co-Authored-By: Claude Opus 4.8 (1M context) --- .../deltalake/DeltaLakeInputSourceIngestionTest.java | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/embedded-tests/src/test/java/org/apache/druid/testing/embedded/deltalake/DeltaLakeInputSourceIngestionTest.java b/embedded-tests/src/test/java/org/apache/druid/testing/embedded/deltalake/DeltaLakeInputSourceIngestionTest.java index ac7646f9a311..278115f1f6a5 100644 --- a/embedded-tests/src/test/java/org/apache/druid/testing/embedded/deltalake/DeltaLakeInputSourceIngestionTest.java +++ b/embedded-tests/src/test/java/org/apache/druid/testing/embedded/deltalake/DeltaLakeInputSourceIngestionTest.java @@ -110,8 +110,9 @@ public void testIngestAllRowsFromDeltaTable() "Expected all rows to be ingested. A count of 2048 indicates the per-file batch-drain bug (GH-18606) regressed." ); - // The 'id' column was used as the POSIX timestamp; its documented min/max (0 and 3999) bound __time, - // confirming rows from both Parquet files (not just the first batch of each) were ingested. + // Secondary sanity check on timestamp parsing and value range: the 'id' column (used as the POSIX + // timestamp) has a documented min of 0 and max of 3999, which should bound __time after ingestion. + // Completeness across both files is guaranteed by the COUNT(*) assertion above, not by these bounds. Assertions.assertEquals( "1970-01-01T00:00:00.000Z,1970-01-01T01:06:39.000Z", cluster.runSql("SELECT MIN(__time), MAX(__time) FROM %s", dataSource) From 7708a2c2fb956b8c246e9ccb947b069c21e4e1c8 Mon Sep 17 00:00:00 2001 From: Roman Rinchinov Date: Mon, 22 Jun 2026 17:00:59 +0200 Subject: [PATCH 3/4] test(delta): ingest via MSQ SQL EXTERN to mirror the Iceberg embedded test Switches the embedded e2e test from a native IndexTask to an MSQ INSERT ... EXTERN(...) statement, matching the structure of IcebergRestCatalogIngestionTest that this test was modeled on, and exercising the SQL ingestion path users actually write. EXTERN requires a non-null inputFormat argument, but the Delta input source reads Parquet via the Delta kernel and ignores it (DeltaInputSource.needsFormat() == false), so a throwaway '{"type":"json"}' is supplied and never used. The test still validates GH-18606 end to end: COUNT(*) must be 4000 (the bug returned 1024 * 2 = 2048). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../DeltaLakeInputSourceIngestionTest.java | 79 +++++++++++-------- 1 file changed, 45 insertions(+), 34 deletions(-) diff --git a/embedded-tests/src/test/java/org/apache/druid/testing/embedded/deltalake/DeltaLakeInputSourceIngestionTest.java b/embedded-tests/src/test/java/org/apache/druid/testing/embedded/deltalake/DeltaLakeInputSourceIngestionTest.java index 278115f1f6a5..a59bbea9ea37 100644 --- a/embedded-tests/src/test/java/org/apache/druid/testing/embedded/deltalake/DeltaLakeInputSourceIngestionTest.java +++ b/embedded-tests/src/test/java/org/apache/druid/testing/embedded/deltalake/DeltaLakeInputSourceIngestionTest.java @@ -19,13 +19,10 @@ package org.apache.druid.testing.embedded.deltalake; -import org.apache.druid.data.input.impl.TimestampSpec; import org.apache.druid.delta.common.DeltaLakeDruidModule; -import org.apache.druid.delta.input.DeltaInputSource; -import org.apache.druid.indexing.common.task.IndexTask; -import org.apache.druid.indexing.common.task.TaskBuilder; +import org.apache.druid.java.util.common.StringUtils; +import org.apache.druid.query.http.SqlTaskStatus; import org.apache.druid.testing.embedded.EmbeddedBroker; -import org.apache.druid.testing.embedded.EmbeddedClusterApis; import org.apache.druid.testing.embedded.EmbeddedCoordinator; import org.apache.druid.testing.embedded.EmbeddedDruidCluster; import org.apache.druid.testing.embedded.EmbeddedHistorical; @@ -33,19 +30,22 @@ import org.apache.druid.testing.embedded.EmbeddedOverlord; import org.apache.druid.testing.embedded.indexing.Resources; import org.apache.druid.testing.embedded.junit5.EmbeddedClusterTestBase; -import org.junit.jupiter.api.Assertions; +import org.apache.druid.testing.embedded.msq.EmbeddedMSQApis; import org.junit.jupiter.api.Test; import java.io.File; /** * End-to-end ingestion test for the Delta Lake input source running inside an embedded Druid cluster. + * Modeled on the Iceberg embedded test ({@code IcebergRestCatalogIngestionTest}): it ingests an external + * table with an MSQ {@code INSERT ... EXTERN(...)} SQL statement and verifies the result over the cluster. + * Unlike Iceberg, Delta needs no catalog or testcontainer since it reads directly from a filesystem path. * - *

The test ingests a Delta table whose two Parquet files contain 2000 rows each (4000 rows total). - * Because each file has more than the Delta kernel's default batch size of 1024 rows, this exercises the - * per-file batch-drain path in {@code DeltaInputSourceReader} that regressed in - * GH-18606: the reader only returned the first - * 1024 rows of each file, yielding {@code 1024 * 2 = 2048} rows instead of 4000. This is the integration-level + *

The table has two Parquet files of 2000 rows each (4000 rows total). Because each file exceeds the + * Delta kernel's default batch size of 1024 rows, this exercises the per-file batch-drain path in + * {@code DeltaInputSourceReader} that regressed in + * GH-18606, where the reader returned only the + * first 1024 rows of each file ({@code 1024 * 2 = 2048} instead of 4000). This is the integration-level * counterpart of the unit regression test {@code DeltaInputSourceTest.BatchDrainRegressionTests}. */ public class DeltaLakeInputSourceIngestionTest extends EmbeddedClusterTestBase @@ -82,40 +82,51 @@ protected EmbeddedDruidCluster createCluster() @Test public void testIngestAllRowsFromDeltaTable() { + final EmbeddedMSQApis msqApis = new EmbeddedMSQApis(cluster, overlord); + final File deltaTableDir = Resources.getFileForResource(DELTA_TABLE_RESOURCE); + // Escape backslashes so a Windows path stays valid inside the JSON input source spec. + final String tablePath = deltaTableDir.getAbsolutePath().replace("\\", "\\\\"); - final String taskId = EmbeddedClusterApis.newTaskId(dataSource); - final IndexTask task = TaskBuilder - .ofTypeIndex() - .inputSource(new DeltaInputSource(deltaTableDir.getAbsolutePath(), null, null, null)) - // The Delta input source produces rows directly, so no inputFormat is required. - .dataSource(dataSource) - // The 'id' column holds POSIX seconds, used here as the timestamp. - .dataSchema(builder -> builder.withTimestamp(new TimestampSpec("id", "posix", null))) - .dimensions("name") - .granularitySpec("DAY", "NONE", false) - .dynamicPartitionWithMaxRows(EXPECTED_ROW_COUNT + 1) - .appendToExisting(false) - .withId(taskId); + final String sql = StringUtils.format( + "INSERT INTO %s\n" + + "SELECT\n" + // The 'id' column holds POSIX seconds; convert to a millisecond timestamp. + + " MILLIS_TO_TIMESTAMP(\"id\" * 1000) AS __time,\n" + + " \"name\"\n" + + "FROM TABLE(\n" + + " EXTERN(\n" + + " '{\"type\":\"delta\",\"tablePath\":\"%s\"}',\n" + // EXTERN requires a non-null inputFormat, but the Delta input source reads Parquet via the + // Delta kernel and ignores it (DeltaInputSource.needsFormat() == false). This value is unused. + + " '{\"type\":\"json\"}',\n" + + " '[{\"type\":\"long\",\"name\":\"id\"},{\"type\":\"string\",\"name\":\"name\"}]'\n" + + " )\n" + + ")\n" + + "PARTITIONED BY DAY", + dataSource, + tablePath + ); - cluster.callApi().runTask(task, overlord); + final SqlTaskStatus taskStatus = msqApis.submitTaskSql(sql); + cluster.callApi().waitForTaskToSucceed(taskStatus.getTaskId(), overlord); cluster.callApi().waitForAllSegmentsToBeAvailable(dataSource, coordinator, broker); // The core regression assertion: all 4000 rows must be ingested. Before the GH-18606 fix this - // returned 1024 * 2 = 2048 because only the first batch of each Parquet file was read. COUNT(*) is - // exact (unlike SQL COUNT(DISTINCT), which is approximate by default), so the comparison is stable. - Assertions.assertEquals( - String.valueOf(EXPECTED_ROW_COUNT), - cluster.runSql("SELECT COUNT(*) FROM %s", dataSource), - "Expected all rows to be ingested. A count of 2048 indicates the per-file batch-drain bug (GH-18606) regressed." + // returned 1024 * 2 = 2048 because only the first batch of each Parquet file was read. + cluster.callApi().verifySqlQuery( + "SELECT COUNT(*) FROM %s", + dataSource, + String.valueOf(EXPECTED_ROW_COUNT) ); // Secondary sanity check on timestamp parsing and value range: the 'id' column (used as the POSIX // timestamp) has a documented min of 0 and max of 3999, which should bound __time after ingestion. // Completeness across both files is guaranteed by the COUNT(*) assertion above, not by these bounds. - Assertions.assertEquals( - "1970-01-01T00:00:00.000Z,1970-01-01T01:06:39.000Z", - cluster.runSql("SELECT MIN(__time), MAX(__time) FROM %s", dataSource) + cluster.callApi().verifySqlQuery( + "SELECT MIN(__time), MAX(__time) FROM %s", + dataSource, + "1970-01-01T00:00:00.000Z,1970-01-01T01:06:39.000Z" ); } } From 593993efcfc4af10f22f5f7636464f303da277f0 Mon Sep 17 00:00:00 2001 From: Roman Rinchinov Date: Fri, 26 Jun 2026 15:23:15 +0200 Subject: [PATCH 4/4] fix(test): use StringUtils.replace to satisfy forbidden-apis testCheck String#replace(CharSequence, CharSequence) is banned by Druid's forbidden-apis ruleset; the full-reactor `mvn install` in CI runs forbiddenapis:testCheck over test classes and failed on it, which cascaded to every job that builds the reactor (static-checks, strict-compilation, packaging-check, openrewrite, validate-dist). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../embedded/deltalake/DeltaLakeInputSourceIngestionTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/embedded-tests/src/test/java/org/apache/druid/testing/embedded/deltalake/DeltaLakeInputSourceIngestionTest.java b/embedded-tests/src/test/java/org/apache/druid/testing/embedded/deltalake/DeltaLakeInputSourceIngestionTest.java index a59bbea9ea37..10aa9c7783ae 100644 --- a/embedded-tests/src/test/java/org/apache/druid/testing/embedded/deltalake/DeltaLakeInputSourceIngestionTest.java +++ b/embedded-tests/src/test/java/org/apache/druid/testing/embedded/deltalake/DeltaLakeInputSourceIngestionTest.java @@ -86,7 +86,7 @@ public void testIngestAllRowsFromDeltaTable() final File deltaTableDir = Resources.getFileForResource(DELTA_TABLE_RESOURCE); // Escape backslashes so a Windows path stays valid inside the JSON input source spec. - final String tablePath = deltaTableDir.getAbsolutePath().replace("\\", "\\\\"); + final String tablePath = StringUtils.replace(deltaTableDir.getAbsolutePath(), "\\", "\\\\"); final String sql = StringUtils.format( "INSERT INTO %s\n"