github-actions[bot] commented on code in PR #63719:
URL: https://github.com/apache/doris/pull/63719#discussion_r3613062617
##########
regression-test/suites/external_table_p0/iceberg/test_iceberg_v2_to_v3_doris_spark_compare.groovy:
##########
@@ -31,7 +30,100 @@ suite("test_iceberg_v2_to_v3_doris_spark_compare",
"p0,external,iceberg,external
def formats = ["parquet", "orc"]
def tableNameForFormat = { baseName, format ->
- return format == "parquet" ? baseName : "${baseName}_orc"
+ return "${baseName}_${format}"
+ }
+
+ def createSparkUpgradeFixture = { tableName, format, partitioned,
datePrefix, deleteBeforeUpgrade ->
+ def partitionClause = partitioned ? "partitioned by (days(dt))" : ""
+ def deleteClause = deleteBeforeUpgrade ? """
+ delete from ${tableName}
+ where id = 2;
+ """ : ""
+ spark_iceberg_multi """
+ use demo.${dbName};
+
+ drop table if exists ${tableName};
+
+ create table ${tableName} (
+ id int,
+ tag string,
+ score int,
+ dt date
+ ) using iceberg
+ ${partitionClause}
+ tblproperties (
+ 'format-version' = '2',
+ 'write.format.default' = '${format}',
+ 'write.delete.mode' = 'merge-on-read',
+ 'write.update.mode' = 'merge-on-read',
+ 'write.merge.mode' = 'merge-on-read'
+ );
+
+ insert into ${tableName} values
+ (1, 'base', 100, date '${datePrefix}-01'),
+ (2, 'base', 200, date '${datePrefix}-02');
+
+ insert into ${tableName} values
+ (3, 'base', 300, date '${datePrefix}-03');
+
+ update ${tableName}
+ set tag = 'base_u', score = score + 10
+ where id = 1;
+
+ ${deleteClause}
+ alter table ${tableName}
+ set tblproperties ('format-version' = '3');
+ """
+ }
+
+ def createSparkReferenceFixture = { tableName, format ->
+ createSparkUpgradeFixture(tableName, format, true, "2024-02", false)
+ spark_iceberg_multi """
+ use demo.${dbName};
+
+ update ${tableName}
+ set tag = 'post_v3_u', score = score + 20
+ where id = 2;
+
+ insert into ${tableName} values
+ (4, 'post_v3_i', 400, date '2024-02-04');
+
+ call demo.system.rewrite_data_files(
+ table => 'demo.${dbName}.${tableName}',
+ options => map('target-file-size-bytes', '10485760',
'min-input-files', '1')
+ );
+ """
+ }
+
+ spark_iceberg """create database if not exists demo.${dbName}"""
Review Comment:
The Docker entrypoint still sources the unchanged 391-line
`iceberg/run29.sql`, which pre-creates 14 v2-to-v3 fixtures. This new loop then
builds 26 fixtures again: the `_orc` names drop and recreate the preinstalled
ORC tables, while the new `_parquet` names leave the preinstalled unsuffixed
Parquet tables unused. Please use one fixture strategy—remove the obsolete
`run29.sql` bootstrap when creating these dynamically, or reuse/extend it—so
every external P0 run does not pay for both workloads.
##########
regression-test/suites/external_table_p2/iceberg/test_iceberg_v3_row_lineage_random_cross_engine.groovy:
##########
@@ -0,0 +1,270 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+import java.util.Random
+
+suite("test_iceberg_v3_row_lineage_random_cross_engine",
"p2,external,iceberg,external_docker,external_docker_iceberg") {
+ String enabled = context.config.otherConfigs.get("enableIcebergTest")
+ if (enabled == null || !enabled.equalsIgnoreCase("true")) {
+ logger.info("Iceberg test is disabled")
+ return
+ }
+
+ String catalogName = "test_iceberg_v3_row_lineage_random_cross_engine"
+ String dbName = "test_row_lineage_random_cross_db"
+ String restPort = context.config.otherConfigs.get("iceberg_rest_uri_port")
+ String minioPort = context.config.otherConfigs.get("iceberg_minio_port")
+ String externalEnvIp = context.config.otherConfigs.get("externalEnvIp")
+ String endpoint = "http://${externalEnvIp}:${minioPort}"
+
+ int rounds =
(context.config.otherConfigs.get("icebergRowLineageRandomRounds") ?:
"20").toInteger()
+ long seed =
(context.config.otherConfigs.get("icebergRowLineageRandomSeed") ?:
"61398").toLong()
+ def formats = ["parquet", "orc"]
+
+ def hasSparkIcebergJdbc = {
+ try {
+ spark_iceberg """select 1"""
+ return true
+ } catch (Exception e) {
+ logger.info("Check spark-iceberg JDBC failed: ${e.message}")
+ return false
+ }
+ }
+
+ def dorisChecksum = { tableName ->
+ def rows = sql("""
+ select count(*), coalesce(sum(id), 0), coalesce(sum(score), 0)
+ from ${tableName}
+ """)
+ return rows[0].collect { it.toString() }.join(",")
+ }
+
+ def sparkChecksum = { tableName ->
+ def rows = spark_iceberg("""
+ select concat_ws(',', cast(count(*) as string),
+ cast(coalesce(sum(id), 0) as string),
+ cast(coalesce(sum(score), 0) as string))
+ from demo.${dbName}.${tableName}
+ """)
Review Comment:
This helper is named as a business-row equality check, but the oracle only
compares `count`, `sum(id)`, and `sum(score)`. It cannot detect any divergence
in `name` or `dt` (both are changed by the randomized operations), and distinct
row sets can also collide on these sums. Please compare the complete ordered
`(id, name, score, dt)` results, or use a collision-resistant digest over every
column, so the randomized interoperability test can catch the row-level bugs it
is meant to cover.
##########
regression-test/suites/external_table_p2/iceberg/test_iceberg_v3_row_lineage_large_stability.groovy:
##########
@@ -0,0 +1,169 @@
+// 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.
+
+suite("test_iceberg_v3_row_lineage_large_stability",
"p2,external,iceberg,external_docker,external_docker_iceberg") {
+ String enabled = context.config.otherConfigs.get("enableIcebergTest")
+ if (enabled == null || !enabled.equalsIgnoreCase("true")) {
+ logger.info("Iceberg test is disabled")
+ return
+ }
+
+
+ String catalogName = "test_iceberg_v3_row_lineage_large_stability"
+ String dbName = "test_row_lineage_large_stability_db"
+ String restPort = context.config.otherConfigs.get("iceberg_rest_uri_port")
+ String minioPort = context.config.otherConfigs.get("iceberg_minio_port")
+ String externalEnvIp = context.config.otherConfigs.get("externalEnvIp")
+ String endpoint = "http://${externalEnvIp}:${minioPort}"
+
+ int rowCount =
(context.config.otherConfigs.get("icebergRowLineageLargeRows") ?:
"500000").toInteger()
+ def formats = ["parquet", "orc"]
+ def dataColumnNames = (1..498).collect { idx -> String.format("c%03d",
idx) }
+
+ def assertChecksum = { tableName, expectedRows ->
+ sql """refresh table ${dbName}.${tableName}"""
+ spark_iceberg """refresh table demo.${dbName}.${tableName}"""
+ def sparkRows = spark_iceberg("""
+ select count(*), sum(id), sum(c001)
Review Comment:
This creates 498 payload columns, but every validation query projects only
`c001`; `c002` through `c498` are column-pruned in both engines. A wide-schema
mapping/rewrite bug that corrupts any later column therefore leaves the suite
green, so the advertised 500-column stability path is not actually validated.
Please include a deterministic full-row digest over all payload columns, or at
least validate representative early/middle/last columns (including `c498`)
before and after rewrite in both engines.
##########
regression-test/suites/external_table_p0/iceberg/test_iceberg_v3_doris_create_update_spark_read.groovy:
##########
@@ -0,0 +1,170 @@
+// 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.
+
+suite("test_iceberg_v3_doris_create_update_spark_read",
"p0,external,iceberg,external_docker,external_docker_iceberg") {
+ String enabled = context.config.otherConfigs.get("enableIcebergTest")
+ if (enabled == null || !enabled.equalsIgnoreCase("true")) {
+ logger.info("Iceberg test is disabled")
+ return
+ }
+
+ String catalogName = "test_iceberg_v3_doris_create_update_spark_read"
+ String dbName = "test_v3_doris_create_update_spark_read_db"
+ String restPort = context.config.otherConfigs.get("iceberg_rest_uri_port")
+ String minioPort = context.config.otherConfigs.get("iceberg_minio_port")
+ String externalEnvIp = context.config.otherConfigs.get("externalEnvIp")
+
+ def formats = ["parquet", "orc"]
+ def partitionFlags = [true, false]
+
+ def checksum = { rows ->
+ assertEquals(1, rows.size())
+ return rows[0].collect { col -> col == null ? null : col.toString() }
+ }
+
+ def assertDorisSparkChecksumEqual = { tableName ->
+ sql """refresh table ${dbName}.${tableName}"""
+ spark_iceberg """refresh table demo.${dbName}.${tableName}"""
+ def sparkChecksum = checksum(spark_iceberg("""
+ select count(*), sum(id), sum(score),
+ max(case when id = 2 then tag end)
+ from demo.${dbName}.${tableName}
+ """))
+ def dorisChecksum = checksum(sql """
+ select count(*), sum(id), sum(score),
+ max(case when id = 2 then tag end)
+ from ${tableName}
+ """)
+ log.info("Doris checksum for ${tableName}: ${dorisChecksum}")
+ log.info("Spark checksum for ${tableName}: ${sparkChecksum}")
+ assertEquals(dorisChecksum, sparkChecksum)
+ }
+
+ def assertDorisSparkBusinessRowsEqual = { tableName ->
+ sql """refresh table ${dbName}.${tableName}"""
+ spark_iceberg """refresh table demo.${dbName}.${tableName}"""
+ def sparkRows = spark_iceberg("""
+ select id, tag, score, dt
+ from demo.${dbName}.${tableName}
+ order by id
+ """)
+ def dorisRows = sql("""
+ select id, tag, score, dt
+ from ${tableName}
+ order by id
+ """)
+ log.info("Spark business rows for ${tableName}: ${sparkRows}")
+ log.info("Doris business rows for ${tableName}: ${dorisRows}")
+ assertSparkDorisResultEquals(sparkRows, dorisRows)
+ }
+
+ def assertDorisLineageReadable = { tableName ->
+ def rows = sql """
+ select id, _row_id, _last_updated_sequence_number
+ from ${tableName}
+ order by id
+ """
+ log.info("Doris lineage rows for ${tableName}: ${rows}")
+ assertEquals(3, rows.size())
+ rows.each { row ->
+ assertTrue(row[1] != null, "_row_id should be non-null for
${tableName}, row=${row}")
+ assertTrue(row[2] != null,
+ "_last_updated_sequence_number should be non-null for
${tableName}, row=${row}")
+ }
+ }
+
+ sql """drop catalog if exists ${catalogName}"""
+ sql """
+ create catalog if not exists ${catalogName} properties (
+ "type" = "iceberg",
+ "iceberg.catalog.type" = "rest",
+ "uri" = "http://${externalEnvIp}:${restPort}",
+ "s3.access_key" = "admin",
+ "s3.secret_key" = "password",
+ "s3.endpoint" = "http://${externalEnvIp}:${minioPort}",
+ "s3.region" = "us-east-1"
+ )
+ """
+
+ sql """switch ${catalogName}"""
+ sql """create database if not exists ${dbName}"""
+ sql """use ${dbName}"""
+ sql """set enable_fallback_to_original_planner = false"""
+
+ boolean sparkReady = false
+ try {
+ spark_iceberg """select 1"""
+ sparkReady = true
+ formats.each { format ->
+ partitionFlags.each { partitioned ->
+ String partitionSuffix = partitioned ? "part" : "unpart"
+ String tableName =
"doris_v3_update_spark_read_${format}_${partitionSuffix}"
+ String partitionClause = partitioned ? "partition by list
(day(dt)) ()" : ""
+
+ sql """drop table if exists ${tableName}"""
+ sql """
+ create table ${tableName} (
+ id int,
+ tag string,
+ score int,
+ dt date
+ ) engine=iceberg
+ ${partitionClause}
+ properties (
+ "format-version" = "3",
+ "write.format.default" = "${format}",
+ "write.delete.mode" = "merge-on-read",
+ "write.update.mode" = "merge-on-read",
+ "write.merge.mode" = "merge-on-read"
+ )
+ """
+ sql """
+ insert into ${tableName} values
+ (1, 'doris_v3_i', 100, date '2024-03-01'),
+ (2, 'doris_v3_i', 200, date '2024-03-02'),
+ (3, 'doris_v3_i', 300, date '2024-03-03')
+ """
+ sql """
+ update ${tableName}
+ set tag = 'doris_v3_u', score = score + 20
+ where id = 2
+ """
+
+ def rows = sql """
+ select id, tag, score
+ from ${tableName}
+ order by id
+ """
+ assertEquals(3, rows.size())
+ assertEquals("doris_v3_u", rows[1][1].toString())
+ assertEquals(220, rows[1][2].toString().toInteger())
+ assertDorisSparkBusinessRowsEqual(tableName)
+ assertDorisLineageReadable(tableName)
+ assertDorisSparkChecksumEqual(tableName)
+ }
+ }
+ } catch (Exception e) {
+ if (!sparkReady) {
+ logger.info("spark-iceberg JDBC is unavailable, skip Doris v3
create/update Spark read test: ${e.message}")
Review Comment:
With `enableIcebergTest=true`, Spark is a required side of this
interoperability suite. If the initial JDBC probe fails, this catch logs and
returns successfully, so all four format/partition scenarios are skipped and a
broken Spark endpoint makes the suite green without testing anything. The new
P2 random-cross-engine suite repeats the same false-success pattern in
`hasSparkIcebergJdbc()`. Please let both probe failures fail their suites; if
Spark is genuinely optional, gate that at the harness/configuration level
instead of swallowing a runtime dependency failure.
##########
regression-test/suites/external_table_p0/iceberg/test_iceberg_v3_row_lineage_query_insert.groovy:
##########
@@ -138,6 +166,111 @@ suite("test_iceberg_v3_row_lineage_query_insert",
"p0,external,iceberg,external_
assertEquals(2, combinedPredicate.size())
assertEquals(expectedIds[1],
combinedPredicate[0][0].toString().toInteger())
assertEquals(expectedIds[2],
combinedPredicate[1][0].toString().toInteger())
+
+ def aggregateRows = sql("""
+ select count(*), min(_row_id), max(_last_updated_sequence_number)
+ from ${tableName}
+ where _row_id is not null
+ """)
+ log.info("Checking row lineage aggregate for ${tableName}:
result=${aggregateRows}")
+ assertEquals(expectedIds.size(),
aggregateRows[0][0].toString().toInteger())
+ assertTrue(aggregateRows[0][1] != null, "min(_row_id) should be
non-null for ${tableName}")
+ assertTrue(aggregateRows[0][2] != null,
+ "max(_last_updated_sequence_number) should be non-null for
${tableName}")
+ }
+
+ def assertV2DoesNotExposeRowLineageColumns = { tableName ->
+ sql("""set show_hidden_columns = true""")
+ def descRows = sql("""desc ${tableName}""")
+ def columns = collectDescColumns(descRows)
+ log.info("Checking v2 row lineage compatibility for ${tableName}:
desc=${descRows}")
+ assertTrue(!columns.contains("_row_id"),
+ "Iceberg v2 table should not expose _row_id for ${tableName},
got ${columns}")
+ assertTrue(!columns.contains("_last_updated_sequence_number"),
+ "Iceberg v2 table should not expose
_last_updated_sequence_number for ${tableName}, got ${columns}")
+
+ test {
+ sql """select id, _row_id from ${tableName}"""
+ exception "_row_id"
+ }
+
+ test {
+ sql """select id, _last_updated_sequence_number from
${tableName}"""
+ exception "_last_updated_sequence_number"
+ }
+
+ sql("""set show_hidden_columns = false""")
+ }
+
+ def assertRowLineagePredicatesAreNotPushedDown = { tableName, rowId,
sequenceNumber ->
+ def explainRowId = sql("""
+ explain verbose
+ select id
+ from ${tableName}
+ where id = 1 and _row_id = ${rowId}
+ """)
+ def explainSeq = sql("""
+ explain verbose
+ select id
+ from ${tableName}
+ where _last_updated_sequence_number >= ${sequenceNumber}
+ """)
+
+ [explainRowId, explainSeq].each { explainRows ->
+ String pushdownText = explainRows.collect { row ->
row.toString().toLowerCase() }
+ .findAll { line ->
line.contains("icebergpredicatepushdown") }
Review Comment:
The EXPLAIN result is returned one line per row: `IcebergScanNode` prints
the `icebergPredicatePushdown=` header first and the actual predicates on
following lines, and `StmtExecutor` splits on `\n`. Filtering to rows that
contain only the header therefore removes the rows where `_row_id` or
`_last_updated_sequence_number` would appear, so this test passes even if those
predicates are incorrectly pushed down. Please inspect the complete Iceberg
scan block (and assert the block is present for the mixed `id` predicate case)
before checking that the lineage columns are absent.
##########
regression-test/suites/external_table_p2/iceberg/test_iceberg_v3_row_lineage_large_stability.groovy:
##########
@@ -0,0 +1,169 @@
+// 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.
+
+suite("test_iceberg_v3_row_lineage_large_stability",
"p2,external,iceberg,external_docker,external_docker_iceberg") {
+ String enabled = context.config.otherConfigs.get("enableIcebergTest")
+ if (enabled == null || !enabled.equalsIgnoreCase("true")) {
+ logger.info("Iceberg test is disabled")
+ return
+ }
+
+
+ String catalogName = "test_iceberg_v3_row_lineage_large_stability"
+ String dbName = "test_row_lineage_large_stability_db"
+ String restPort = context.config.otherConfigs.get("iceberg_rest_uri_port")
+ String minioPort = context.config.otherConfigs.get("iceberg_minio_port")
+ String externalEnvIp = context.config.otherConfigs.get("externalEnvIp")
+ String endpoint = "http://${externalEnvIp}:${minioPort}"
+
+ int rowCount =
(context.config.otherConfigs.get("icebergRowLineageLargeRows") ?:
"500000").toInteger()
+ def formats = ["parquet", "orc"]
+ def dataColumnNames = (1..498).collect { idx -> String.format("c%03d",
idx) }
+
+ def assertChecksum = { tableName, expectedRows ->
+ sql """refresh table ${dbName}.${tableName}"""
+ spark_iceberg """refresh table demo.${dbName}.${tableName}"""
+ def sparkRows = spark_iceberg("""
+ select count(*), sum(id), sum(c001)
+ from demo.${dbName}.${tableName}
+ """)
+ def dorisRows = sql("""
+ select count(*), sum(id), sum(c001)
+ from ${tableName}
+ """)
+ log.info("Spark checksum for ${tableName}: ${sparkRows}")
+ log.info("Doris checksum for ${tableName}: ${dorisRows}")
+ assertSparkDorisResultEquals(sparkRows, dorisRows)
+ assertEquals(expectedRows, dorisRows[0][0].toString().toLong())
+ assertTrue(dorisRows[0][1] != null, "sum(id) should be non-null for
${tableName}")
+ assertTrue(dorisRows[0][2] != null, "sum(c001) should be non-null for
${tableName}")
+ return dorisRows[0].collect { it == null ? null : it.toString() }
+ }
+
+ def assertPuffinDeleteFiles = { tableName ->
+ def deleteFiles = sql("""
+ select file_path, lower(file_format), record_count
+ from ${tableName}\$delete_files
+ order by file_path
+ """)
+ log.info("Large stability delete files for ${tableName}:
${deleteFiles}")
+ assertTrue(deleteFiles.size() > 0, "Large stability DML should create
delete files for ${tableName}")
+ deleteFiles.each { row ->
+ assertEquals("puffin", row[1].toString())
+ assertTrue(row[0].toString().toLowerCase().endsWith(".puffin"))
+ }
+ }
+
+ sql """drop catalog if exists ${catalogName}"""
+ sql """
+ create catalog if not exists ${catalogName} properties (
+ "type" = "iceberg",
+ "iceberg.catalog.type" = "rest",
+ "uri" = "http://${externalEnvIp}:${restPort}",
+ "s3.access_key" = "admin",
+ "s3.secret_key" = "password",
+ "s3.endpoint" = "${endpoint}",
+ "s3.region" = "us-east-1"
+ )
+ """
+
+ sql """switch ${catalogName}"""
+ sql """create database if not exists ${dbName}"""
+ sql """use ${dbName}"""
+ sql """set enable_fallback_to_original_planner = false"""
+ sql """set show_hidden_columns = false"""
+
+ try {
+ formats.each { format ->
+ String tableName = "large_stability_${format}"
+ try {
+ sql """drop table if exists ${tableName}"""
+ String dataColumns = dataColumnNames.collect { col -> "${col}
int" }.join(",\n ")
+ String dataSelectExprs = dataColumnNames.withIndex().collect {
col, idx ->
+ "cast(number % ${1000 + idx} as int) as ${col}"
+ }.join(",\n ")
+ sql """
+ create table ${tableName} (
+ id int,
+ dt date,
+ ${dataColumns}
+ ) engine=iceberg
+ partition by list (day(dt)) ()
+ properties (
+ "format-version" = "3",
+ "write.format.default" = "${format}",
+ "write.delete.mode" = "merge-on-read",
+ "write.update.mode" = "merge-on-read",
+ "write.merge.mode" = "merge-on-read"
+ )
+ """
+
+ sql """
+ insert into ${tableName}
+ select
+ cast(number as int) as id,
+ cast(case when number % 2 = 0 then '2024-05-01' else
'2024-05-02' end as date) as dt,
+ ${dataSelectExprs}
+ from numbers("number" = "${rowCount}")
+ """
+
+ sql """
+ update ${tableName}
+ set c001 = c001 + 1
+ where dt = date '2024-05-01' and id % 10 = 0
+ """
+ sql """
+ delete from ${tableName}
+ where dt = date '2024-05-02' and id % 20 = 1
+ """
+
+ long deletedRows = rowCount <= 1 ? 0L : (((rowCount - 2) /
20).toLong() + 1L)
+ long expectedRows = rowCount - deletedRows
+ def beforeRewriteChecksum = assertChecksum(tableName,
expectedRows)
+ assertPuffinDeleteFiles(tableName)
+
+ def rewriteResult = sql("""
+ alter table ${catalogName}.${dbName}.${tableName}
+ execute rewrite_data_files(
+ "target-file-size-bytes" = "536870912",
+ "min-input-files" = "1"
+ )
+ """)
+ log.info("Large stability rewrite result for ${tableName}:
${rewriteResult}")
+ assertTrue(rewriteResult.size() > 0,
Review Comment:
A summary row is returned even when `rewrite_data_files` rewrites zero
files, so `rewriteResult.size() > 0` does not exercise the rewrite path (the
established v3 rewrite suite checks `rewriteResult[0][0] > 0`). This case also
records no lineage state before the action; replacing every row ID/sequence
with different non-null values would still pass the post-rewrite counts. Please
require a positive rewritten-file count and compare a deterministic `(id,
_row_id, _last_updated_sequence_number)` digest before and after the actual
rewrite.
##########
regression-test/suites/external_table_p0/iceberg/test_iceberg_v3_row_lineage_dml_mode_matrix.groovy:
##########
@@ -0,0 +1,279 @@
+// 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.
+
+suite("test_iceberg_v3_row_lineage_dml_mode_matrix",
"p0,external,iceberg,external_docker,external_docker_iceberg") {
+ String enabled = context.config.otherConfigs.get("enableIcebergTest")
+ if (enabled == null || !enabled.equalsIgnoreCase("true")) {
+ logger.info("Iceberg test is disabled")
+ return
+ }
+
+ String catalogName = "test_iceberg_v3_row_lineage_dml_mode_matrix"
+ String dbName = "test_row_lineage_dml_mode_matrix_db"
+ String restPort = context.config.otherConfigs.get("iceberg_rest_uri_port")
+ String minioPort = context.config.otherConfigs.get("iceberg_minio_port")
+ String externalEnvIp = context.config.otherConfigs.get("externalEnvIp")
+ String endpoint = "http://${externalEnvIp}:${minioPort}"
+
+ def formats = ["parquet", "orc"]
+ def formatVersions = [2, 3]
+ def partitionFlags = [false, true]
+ def scenarios = [
+ [name: "m01_delete_cow", operation: "delete", property:
"write.delete.mode", mode: "copy-on-write"],
+ [name: "m02_delete_mor", operation: "delete", property:
"write.delete.mode", mode: "merge-on-read"],
+ [name: "m03_update_cow", operation: "update", property:
"write.update.mode", mode: "copy-on-write"],
+ [name: "m04_update_mor", operation: "update", property:
"write.update.mode", mode: "merge-on-read"],
+ [name: "m05_merge_cow", operation: "merge", property:
"write.merge.mode", mode: "copy-on-write"],
+ [name: "m06_merge_mor", operation: "merge", property:
"write.merge.mode", mode: "merge-on-read"]
+ ]
+ def initialRows = [
+ [1, "Alice", 10, "2024-01-01"],
+ [2, "Bob", 20, "2024-01-02"],
+ [3, "Carol", 30, "2024-01-03"]
+ ]
+
+ def createTable = { tableName, formatVersion, format, partitioned,
propertyName, mode ->
+ sql """drop table if exists ${tableName}"""
+ String partitionClause = partitioned ? "partition by list (day(dt))
()" : ""
+ sql """
+ create table ${tableName} (
+ id int,
+ name string,
+ score int,
+ dt date
+ ) engine=iceberg
+ ${partitionClause}
+ properties (
+ "format-version" = "${formatVersion}",
+ "write.format.default" = "${format}",
+ "${propertyName}" = "${mode}"
+ )
+ """
+ sql """
+ insert into ${tableName} values
+ (1, 'Alice', 10, date '2024-01-01'),
+ (2, 'Bob', 20, date '2024-01-02'),
+ (3, 'Carol', 30, date '2024-01-03')
+ """
+ }
+
+ def lineageMap = { tableName ->
+ def rows = sql("""
+ select id, _row_id, _last_updated_sequence_number
+ from ${tableName}
+ order by id
+ """)
+ Map<Integer, List<Long>> result = [:]
+ rows.each { row ->
+ result[row[0].toString().toInteger()] =
[row[1].toString().toLong(), row[2].toString().toLong()]
+ }
+ log.info("Lineage map for ${tableName}: ${result}")
+ return result
+ }
+
+ def assertV3LineageNonNull = { tableName, expectedIds ->
+ def rows = sql("""
+ select id, _row_id, _last_updated_sequence_number
+ from ${tableName}
+ order by id
+ """)
+ log.info("Checking v3 row lineage rows for ${tableName}: ${rows}")
+ assertEquals(expectedIds.size(), rows.size())
+ for (int i = 0; i < expectedIds.size(); i++) {
+ assertEquals(expectedIds[i], rows[i][0].toString().toInteger())
+ assertTrue(rows[i][1] != null, "_row_id should be non-null for
${tableName}, row=${rows[i]}")
+ assertTrue(rows[i][2] != null,
+ "_last_updated_sequence_number should be non-null for
${tableName}, row=${rows[i]}")
+ }
+ }
+
+ def assertDeleteFilesForVersion = { tableName, formatVersion, format,
scenario ->
+ def deleteFiles = sql("""
+ select file_path, lower(file_format), record_count
+ from ${tableName}\$delete_files
+ order by file_path
+ """)
+ log.info("Checking delete files for ${tableName},
scenario=${scenario.name}: ${deleteFiles}")
+ if (scenario.mode == "copy-on-write") {
+ assertEquals(0, deleteFiles.size(),
+ "COW scenario ${scenario.name} should not create delete files
for ${tableName}, actual=${deleteFiles}")
+ }
+ if (scenario.mode == "merge-on-read" && formatVersion == 3) {
Review Comment:
This condition requires a nonempty delete-file set only for v3. For every v2
merge-on-read case, an empty result skips all of the `file_format`,
`delete_pos`, and extension checks below, so all twelve v2 MOR combinations can
pass even if the operation rewrites data files like copy-on-write instead of
producing position deletes. Please require at least one delete file for every
successful MOR scenario, then retain the version-specific format checks.
##########
regression-test/suites/external_table_p0/iceberg/test_iceberg_v3_row_lineage_equality_delete_dv_interop.groovy:
##########
@@ -0,0 +1,317 @@
+// 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.
+
+suite("test_iceberg_v3_row_lineage_equality_delete_dv_interop",
"p0,external,iceberg,external_docker,external_docker_iceberg") {
+ String enabled = context.config.otherConfigs.get("enableIcebergTest")
+ if (enabled == null || !enabled.equalsIgnoreCase("true")) {
+ logger.info("Iceberg test is disabled")
+ return
+ }
+
+ String catalogName =
"test_iceberg_v3_row_lineage_equality_delete_dv_interop"
+ String dbName = "test_row_lineage_eq_delete_dv_db"
+ String restPort = context.config.otherConfigs.get("iceberg_rest_uri_port")
+ String minioPort = context.config.otherConfigs.get("iceberg_minio_port")
+ String externalEnvIp = context.config.otherConfigs.get("externalEnvIp")
+ String endpoint = "http://${externalEnvIp}:${minioPort}"
+ String runSuffix = "${System.currentTimeMillis()}"
+
+ def fixtureMetadataFiles = [
+ parquet:
"s3a://warehouse/wh/multi_catalog/equality_delete_par_1/metadata/00011-f7268cac-dd2f-4061-a2c9-b5aa54ab52f4.metadata.json",
+ orc:
"s3a://warehouse/wh/multi_catalog/equality_delete_orc_1/metadata/00011-568385a2-14af-4237-b186-ce46d350be19.metadata.json"
+ ]
+
+ def normalizeRows = { rows ->
+ return rows.collect { row -> row.collect { col -> col == null ? null :
col.toString() } }
+ }
+
+ def unregisterSparkTable = { tableName ->
+ try {
+ spark_iceberg """
+ call demo.system.unregister_table(table =>
'${dbName}.${tableName}')
+ """
+ } catch (Throwable t) {
+ logger.info("Skip unregister ${dbName}.${tableName}: ${t.message}")
+ }
+ }
+
+ def refreshTable = { tableName ->
+ spark_iceberg """refresh table demo.${dbName}.${tableName}"""
+ sql """refresh table ${dbName}.${tableName}"""
+ }
+
+ def assertSparkDorisBusinessRows = { tableName ->
+ refreshTable(tableName)
+ def sparkRows = spark_iceberg("""
+ select new_new_id, new_name, data, id
+ from demo.${dbName}.${tableName}
+ order by new_new_id, new_name, data, id
+ """)
+ def dorisRows = sql("""
+ select new_new_id, new_name, data, id
+ from ${tableName}
+ order by new_new_id, new_name, data, id
+ """)
+ log.info("Spark business rows for ${tableName}: ${sparkRows}")
+ log.info("Doris business rows for ${tableName}: ${dorisRows}")
+ assertSparkDorisResultEquals(sparkRows, dorisRows)
+ }
+
+ def assertDorisBusinessRows = { tableName, expectedRows ->
+ refreshTable(tableName)
+ def sparkRows = spark_iceberg("""
+ select new_new_id, new_name, data, id
+ from demo.${dbName}.${tableName}
+ order by new_new_id, new_name, data, id
+ """)
+ def dorisRows = sql("""
+ select new_new_id, new_name, data, id
+ from ${tableName}
+ order by new_new_id, new_name, data, id
+ """)
+ log.info("Spark business rows for ${tableName}: ${sparkRows}")
+ log.info("Doris business rows for ${tableName}: ${dorisRows}")
+ assertSparkDorisResultEquals(sparkRows, dorisRows)
+ def normalizedRows = normalizeRows(dorisRows)
+ log.info("Checking concrete Doris business rows for ${tableName}:
${normalizedRows}")
+ assertEquals(expectedRows, normalizedRows)
+ }
+
+ def assertSparkDorisAggregateRows = { tableName ->
+ refreshTable(tableName)
+ def sparkRows = spark_iceberg("""
+ select count(*), count(distinct new_new_id)
+ from demo.${dbName}.${tableName}
+ """)
+ def dorisRows = sql("""
+ select count(*), count(distinct new_new_id)
+ from ${tableName}
+ """)
+ log.info("Spark aggregate rows for ${tableName}: ${sparkRows}")
+ log.info("Doris aggregate rows for ${tableName}: ${dorisRows}")
+ assertSparkDorisResultEquals(sparkRows, dorisRows)
+ }
+
+ def assertDorisAggregateRows = { tableName, expectedRows ->
+ refreshTable(tableName)
+ def sparkRows = spark_iceberg("""
+ select count(*), count(distinct new_new_id)
+ from demo.${dbName}.${tableName}
+ """)
+ def dorisRows = sql("""
+ select count(*), count(distinct new_new_id)
+ from ${tableName}
+ """)
+ log.info("Spark aggregate rows for ${tableName}: ${sparkRows}")
+ log.info("Doris aggregate rows for ${tableName}: ${dorisRows}")
+ assertSparkDorisResultEquals(sparkRows, dorisRows)
+ def normalizedRows = normalizeRows(dorisRows)
+ log.info("Checking concrete Doris aggregate rows for ${tableName}:
${normalizedRows}")
+ assertEquals(expectedRows, normalizedRows)
+ }
+
+ def assertSparkDorisRowsByPredicate = { tableName, predicate ->
+ refreshTable(tableName)
+ def sparkRows = spark_iceberg("""
+ select new_new_id, new_name, data, id
+ from demo.${dbName}.${tableName}
+ where ${predicate}
+ order by new_new_id, new_name, data, id
+ """)
+ def dorisRows = sql("""
+ select new_new_id, new_name, data, id
+ from ${tableName}
+ where ${predicate}
+ order by new_new_id, new_name, data, id
+ """)
+ log.info("Spark rows for ${tableName} with predicate ${predicate}:
${sparkRows}")
+ log.info("Doris rows for ${tableName} with predicate ${predicate}:
${dorisRows}")
+ assertSparkDorisResultEquals(sparkRows, dorisRows)
+ }
+
+ def assertDorisRowsByPredicate = { tableName, predicate, expectedRows ->
+ refreshTable(tableName)
+ def sparkRows = spark_iceberg("""
+ select new_new_id, new_name, data, id
+ from demo.${dbName}.${tableName}
+ where ${predicate}
+ order by new_new_id, new_name, data, id
+ """)
+ def dorisRows = sql("""
+ select new_new_id, new_name, data, id
+ from ${tableName}
+ where ${predicate}
+ order by new_new_id, new_name, data, id
+ """)
+ log.info("Spark rows for ${tableName} with predicate ${predicate}:
${sparkRows}")
+ log.info("Doris rows for ${tableName} with predicate ${predicate}:
${dorisRows}")
+ assertSparkDorisResultEquals(sparkRows, dorisRows)
+ def normalizedRows = normalizeRows(dorisRows)
+ log.info("Checking concrete Doris rows for ${tableName} with predicate
${predicate}: ${normalizedRows}")
+ assertEquals(expectedRows, normalizedRows)
+ }
+
+ def assertLineageProjectionReadable = { tableName, expectNonNullLineage ->
+ def rows = sql("""
+ select new_new_id, _row_id, _last_updated_sequence_number
+ from ${tableName}
+ order by new_new_id
+ """)
+ logger.info("Row lineage projection for ${tableName},
expectNonNullLineage=${expectNonNullLineage}: ${rows}")
+ assertTrue(rows.size() > 0, "Row lineage projection should return
visible rows for ${tableName}")
+ rows.each { row ->
+ if (expectNonNullLineage) {
+ assertTrue(row[1] != null,
+ "_row_id should be non-null after v2-to-v3 write in
${tableName}, row=${row}")
+ assertTrue(row[2] != null,
+ "_last_updated_sequence_number should be non-null
after v2-to-v3 write in ${tableName}, row=${row}")
+ } else {
+ assertEquals(null, row[1],
+ "_row_id should be NULL before v2-to-v3 write in
${tableName}, row=${row}")
+ assertEquals(null, row[2],
+ "_last_updated_sequence_number should be NULL before
v2-to-v3 write in ${tableName}, row=${row}")
+ }
+ }
+ }
+
+ def deleteFileFormats = { tableName ->
+ refreshTable(tableName)
+ def sparkRows = spark_iceberg("""
+ select lower(file_format), count(*)
+ from demo.${dbName}.${tableName}.delete_files
+ group by lower(file_format)
+ order by lower(file_format)
+ """)
+ def dorisRows = sql("""
+ select lower(file_format), count(*)
+ from ${tableName}\$delete_files
+ group by lower(file_format)
+ order by lower(file_format)
+ """)
+ log.info("Spark delete file formats for ${tableName}: ${sparkRows}")
+ log.info("Doris delete file formats for ${tableName}: ${dorisRows}")
+ assertSparkDorisResultEquals(sparkRows, dorisRows)
+ return dorisRows
+ }
+
+ def assertOnlyEqualityDeleteFilesBeforeDorisDelete = { tableName ->
+ def deleteFiles = deleteFileFormats(tableName)
+ logger.info("Delete files before Doris v3 DELETE for ${tableName}:
${deleteFiles}")
+ assertTrue(deleteFiles.size() > 0, "Fixture should contain equality
delete files for ${tableName}")
+ assertTrue(!deleteFiles.any { row -> row[0].toString() == "puffin" },
+ "Fixture should not contain Puffin DV before Doris DELETE for
${tableName}: ${deleteFiles}")
+ }
+
+ def assertEqualityDeleteAndPuffinDvCoexist = { tableName ->
+ def deleteFiles = deleteFileFormats(tableName)
+ logger.info("Delete files after Doris v3 DELETE for ${tableName}:
${deleteFiles}")
+ assertTrue(deleteFiles.any { row -> row[0].toString() == "puffin" },
+ "Doris v3 DELETE should write Puffin DV for ${tableName}:
${deleteFiles}")
+ assertTrue(deleteFiles.any { row -> row[0].toString() != "puffin" },
Review Comment:
`file_format` does not identify the delete kind: both equality deletes and
ordinary position deletes can be Parquet/ORC. Therefore “Puffin plus any
non-Puffin” can pass even if the original equality deletes were replaced by, or
confused with, position-delete files. Please compare `content` (and
`equality_ids`/DV metadata where available) in both engines: require the
original entries to remain equality deletes and the new Puffin entries to be
position-delete DVs.
##########
regression-test/suites/external_table_p0/iceberg/test_iceberg_v3_row_lineage_schema_evolution.groovy:
##########
@@ -0,0 +1,234 @@
+// 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.
+
+suite("test_iceberg_v3_row_lineage_schema_evolution",
"p0,external,iceberg,external_docker,external_docker_iceberg") {
+ String enabled = context.config.otherConfigs.get("enableIcebergTest")
+ if (enabled == null || !enabled.equalsIgnoreCase("true")) {
+ logger.info("Iceberg test is disabled")
+ return
+ }
+
+ String catalogName = "test_iceberg_v3_row_lineage_schema_evolution"
+ String dbName = "test_row_lineage_schema_evolution_db"
+ String restPort = context.config.otherConfigs.get("iceberg_rest_uri_port")
+ String minioPort = context.config.otherConfigs.get("iceberg_minio_port")
+ String externalEnvIp = context.config.otherConfigs.get("externalEnvIp")
+ String endpoint = "http://${externalEnvIp}:${minioPort}"
+
+ def formats = ["parquet", "orc"]
+ def partitionFlags = [true, false]
+
+ def descColumns = { tableName -> return sql("""desc
${tableName}""").collect { row -> row[0].toString().toLowerCase() }
+ }
+
+ def normalizeRows = { rows ->
+ return rows.collect { row -> row.collect { col -> col == null ? null :
col.toString() } }
+ }
+
+ def assertSparkDorisBusinessRows = { tableName, columns, orderBy,
expectedRows = null ->
+ sql """refresh table ${dbName}.${tableName}"""
+ spark_iceberg """refresh table demo.${dbName}.${tableName}"""
+ def sparkRows = spark_iceberg("""
+ select ${columns}
+ from demo.${dbName}.${tableName}
+ order by ${orderBy}
+ """)
+ def dorisRows = sql("""
+ select ${columns}
+ from ${tableName}
+ order by ${orderBy}
+ """)
+ log.info("Spark business rows for ${tableName}: ${sparkRows}")
+ log.info("Doris business rows for ${tableName}: ${dorisRows}")
+ assertSparkDorisResultEquals(sparkRows, dorisRows)
+
+ def normalizedRows = normalizeRows(dorisRows)
+ if (expectedRows != null) {
+ assertEquals(expectedRows, normalizedRows)
+ }
+ return normalizedRows
+ }
+
+ def lineageMap = { tableName ->
+ def rows = sql("""
+ select id, _row_id, _last_updated_sequence_number
+ from ${tableName}
+ order by id
+ """)
+ Map<Integer, List<Long>> result = [:]
+ rows.each { row ->
+ assertTrue(row[1] != null, "_row_id should be non-null for
${tableName}, row=${row}")
+ assertTrue(row[2] != null,
+ "_last_updated_sequence_number should be non-null for
${tableName}, row=${row}")
+ result[row[0].toString().toInteger()] =
[row[1].toString().toLong(), row[2].toString().toLong()]
+ }
+ return result
+ }
+
+ sql """drop catalog if exists ${catalogName}"""
+ sql """
+ create catalog if not exists ${catalogName} properties (
+ "type" = "iceberg",
+ "iceberg.catalog.type" = "rest",
+ "uri" = "http://${externalEnvIp}:${restPort}",
+ "s3.access_key" = "admin",
+ "s3.secret_key" = "password",
+ "s3.endpoint" = "${endpoint}",
+ "s3.region" = "us-east-1"
+ )
+ """
+
+ sql """switch ${catalogName}"""
+ sql """create database if not exists ${dbName}"""
+ sql """use ${dbName}"""
+ sql """set enable_fallback_to_original_planner = false"""
+ sql """set show_hidden_columns = false"""
+
+ try {
+ formats.each { format ->
+ partitionFlags.each { partitioned ->
+ String partitionSuffix = partitioned ? "part" : "unpart"
+ String tableName = "schema_evo_${format}_${partitionSuffix}"
+ String partitionClause = partitioned ? "partition by list
(day(dt)) ()" : ""
+ try {
+ sql """drop table if exists ${tableName}"""
+ sql """
+ create table ${tableName} (
+ id int,
+ name string,
+ score int,
+ dt date
+ ) engine=iceberg
+ ${partitionClause}
+ properties (
+ "format-version" = "3",
+ "write.format.default" = "${format}",
+ "write.update.mode" = "merge-on-read",
+ "write.merge.mode" = "merge-on-read"
+ )
+ """
+ sql """
+ insert into ${tableName} values
+ (1, 'a', 10, date '2024-10-01'),
+ (2, 'b', 20, date '2024-10-01')
+ """
+
+ Map<Integer, List<Long>> beforeLineage =
lineageMap(tableName)
+ def initialColumns = descColumns(tableName)
+ assertTrue(!initialColumns.contains("_row_id"))
+
assertTrue(!initialColumns.contains("_last_updated_sequence_number"))
+
+ sql """alter table ${tableName} add column extra string"""
+ sql """refresh table ${dbName}.${tableName}"""
+ def addColumns = descColumns(tableName)
+ assertTrue(addColumns.contains("extra"), "ADD COLUMN
should expose extra for ${tableName}")
+
+ def afterAddRows = sql("""
+ select id, name, score, extra, dt, _row_id,
_last_updated_sequence_number
+ from ${tableName}
+ order by id
+ """)
+ log.info("Rows after ADD COLUMN for ${tableName}:
${afterAddRows}")
+ assertEquals(2, afterAddRows.size())
+ afterAddRows.each { row ->
+ assertTrue(row[3] == null, "New extra column should be
null for old rows in ${tableName}")
+ assertTrue(row[5] != null, "_row_id should remain
readable after ADD COLUMN in ${tableName}")
+ assertTrue(row[6] != null,
+ "_last_updated_sequence_number should remain
readable after ADD COLUMN in ${tableName}")
+ }
+ assertSparkDorisBusinessRows(tableName, "id, name, score,
extra, dt", "id", [
+ ["1", "a", "10", null, "2024-10-01"],
+ ["2", "b", "20", null, "2024-10-01"]
+ ])
+
+ sql """alter table ${tableName} drop column extra"""
+ sql """refresh table ${dbName}.${tableName}"""
+ def dropColumns = descColumns(tableName)
+ assertTrue(!dropColumns.contains("extra"), "DROP COLUMN
should remove extra for ${tableName}")
+
+ def afterDropRows = sql("""
+ select id, name, score, dt, _row_id,
_last_updated_sequence_number
+ from ${tableName}
+ order by id
+ """)
+ log.info("Rows after DROP COLUMN for ${tableName}:
${afterDropRows}")
+ assertEquals(2, afterDropRows.size())
+ assertEquals("a", afterDropRows[0][1].toString())
+ assertEquals(10,
afterDropRows[0][2].toString().toInteger())
+ assertEquals("2024-10-01", afterDropRows[0][3].toString())
+ assertSparkDorisBusinessRows(tableName, "id, name, score,
dt", "id", [
+ ["1", "a", "10", "2024-10-01"],
+ ["2", "b", "20", "2024-10-01"]
+ ])
+
+ sql """update ${tableName} set score = score + 100 where
id = 1"""
+ Map<Integer, List<Long>> afterUpdateLineage =
lineageMap(tableName)
+ assertEquals(beforeLineage[1][0], afterUpdateLineage[1][0])
+ assertTrue(afterUpdateLineage[1][1] > beforeLineage[1][1],
+ "UPDATE after schema evolution should advance sequence
for id=1 in ${tableName}")
+ assertEquals(beforeLineage[2], afterUpdateLineage[2])
+
+ def finalRows = sql("""
+ select id, name, score, dt, _row_id,
_last_updated_sequence_number
+ from ${tableName}
+ order by id
+ """)
+ assertEquals(110, finalRows[0][2].toString().toInteger())
+ assertEquals(20, finalRows[1][2].toString().toInteger())
+ assertSparkDorisBusinessRows(tableName, "id, name, score,
dt", "id", [
+ ["1", "a", "110", "2024-10-01"],
+ ["2", "b", "20", "2024-10-01"]
+ ])
+
+ spark_iceberg """refresh table
demo.${dbName}.${tableName}"""
+ spark_iceberg """
+ alter table demo.${dbName}.${tableName}
+ add columns (spark_added string)
+ """
+ sql """refresh table ${dbName}.${tableName}"""
+ def sparkAddColumns = descColumns(tableName)
+ assertTrue(sparkAddColumns.contains("spark_added"),
+ "Spark ADD COLUMN should be visible after Doris
REFRESH TABLE for ${tableName}")
+
+ def afterSparkAddRows = sql("""
+ select id, name, score, spark_added, dt, _row_id,
_last_updated_sequence_number
+ from ${tableName}
+ order by id
+ """)
+ log.info("Rows after Spark ADD COLUMN for ${tableName}:
${afterSparkAddRows}")
+ assertEquals(2, afterSparkAddRows.size())
+ afterSparkAddRows.each { row ->
+ assertTrue(row[3] == null,
+ "Spark-added column should be null for existing
rows in ${tableName}")
+ assertTrue(row[5] != null, "_row_id should remain
readable after Spark schema evolution")
Review Comment:
`afterUpdateLineage` already captures the exact row IDs and sequence numbers
before this Spark metadata-only `ADD COLUMN`, but this loop checks only that
the refreshed lineage values are non-null. Reassigning every row's lineage to
different non-null values would therefore leave the suite green. Please compare
`lineageMap(tableName)` with `afterUpdateLineage` here so the schema-evolution
path proves that existing rows retain their exact lineage state.
##########
regression-test/suites/external_table_p0/iceberg/test_iceberg_v3_row_lineage_uniqueness_stability.groovy:
##########
@@ -0,0 +1,188 @@
+// 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.
+
+suite("test_iceberg_v3_row_lineage_uniqueness_stability",
"p0,external,iceberg,external_docker,external_docker_iceberg") {
+ String enabled = context.config.otherConfigs.get("enableIcebergTest")
+ if (enabled == null || !enabled.equalsIgnoreCase("true")) {
+ logger.info("Iceberg test is disabled")
+ return
+ }
+
+ String catalogName = "test_iceberg_v3_row_lineage_uniqueness_stability"
+ String dbName = "test_row_lineage_uniqueness_stability_db"
+ String restPort = context.config.otherConfigs.get("iceberg_rest_uri_port")
+ String minioPort = context.config.otherConfigs.get("iceberg_minio_port")
+ String externalEnvIp = context.config.otherConfigs.get("externalEnvIp")
+ String endpoint = "http://${externalEnvIp}:${minioPort}"
+
+ def formats = ["parquet", "orc"]
+ def partitionFlags = [false, true]
+
+ def lineageMap = { tableName ->
+ def rows = sql("""
+ select id, _row_id, _last_updated_sequence_number
+ from ${tableName}
+ order by id
+ """)
+ Map<Integer, List<Long>> result = [:]
+ rows.each { row ->
+ assertTrue(row[1] != null, "_row_id should be non-null for
${tableName}, row=${row}")
+ assertTrue(row[2] != null,
+ "_last_updated_sequence_number should be non-null for
${tableName}, row=${row}")
+ result[row[0].toString().toInteger()] =
[row[1].toString().toLong(), row[2].toString().toLong()]
+ }
+ return result
+ }
+
+ def assertUniqueRowIds = { tableName ->
+ def counts = sql("""
+ select count(*), count(distinct _row_id)
+ from ${tableName}
+ where _row_id is not null
+ """)
+ log.info("Checking row_id uniqueness for ${tableName}: ${counts}")
+ assertEquals(counts[0][0].toString().toLong(),
counts[0][1].toString().toLong())
+ }
+
+ def assertSparkDorisBusinessRows = { tableName, expectedRows ->
+ sql """refresh table ${dbName}.${tableName}"""
+ spark_iceberg """refresh table demo.${dbName}.${tableName}"""
+ def sparkRows = spark_iceberg("""
+ select id, name, score, dt
+ from demo.${dbName}.${tableName}
+ order by id
+ """)
+ def dorisRows = sql("""
+ select id, name, score, dt
+ from ${tableName}
+ order by id
+ """)
+ log.info("Spark business rows for ${tableName}: ${sparkRows}")
+ log.info("Doris business rows for ${tableName}: ${dorisRows}")
+ assertSparkDorisResultEquals(sparkRows, dorisRows)
+
+ def normalizedRows = dorisRows.collect {
+ [it[0].toString().toInteger(), it[1].toString(),
it[2].toString().toInteger(), it[3].toString()]
+ }
+ assertEquals(expectedRows, normalizedRows)
+ }
+
+ sql """drop catalog if exists ${catalogName}"""
+ sql """
+ create catalog if not exists ${catalogName} properties (
+ "type" = "iceberg",
+ "iceberg.catalog.type" = "rest",
+ "uri" = "http://${externalEnvIp}:${restPort}",
+ "s3.access_key" = "admin",
+ "s3.secret_key" = "password",
+ "s3.endpoint" = "${endpoint}",
+ "s3.region" = "us-east-1"
+ )
+ """
+
+ sql """switch ${catalogName}"""
+ sql """create database if not exists ${dbName}"""
+ sql """use ${dbName}"""
+ sql """set enable_fallback_to_original_planner = false"""
+ sql """set show_hidden_columns = false"""
+
+ try {
+ formats.each { format ->
+ partitionFlags.each { partitioned ->
+ String partitionSuffix = partitioned ? "part" : "unpart"
+ String tableName = "unique_stable_${format}_${partitionSuffix}"
+ String partitionClause = partitioned ? "partition by list
(day(dt)) ()" : ""
+ try {
+ sql """drop table if exists ${tableName}"""
+ sql """
+ create table ${tableName} (
+ id int,
+ name string,
+ score int,
+ dt date
+ ) engine=iceberg
+ ${partitionClause}
+ properties (
+ "format-version" = "3",
+ "write.format.default" = "${format}",
+ "write.update.mode" = "merge-on-read",
+ "write.merge.mode" = "merge-on-read"
+ )
+ """
+ sql """insert into ${tableName} values (1, 'a', 10, date
'2024-08-01'), (2, 'b', 20, date '2024-08-02')"""
+ sql """insert into ${tableName} values (3, 'c', 30, date
'2024-08-03'), (4, 'd', 40, date '2024-08-04')"""
+ sql """insert into ${tableName} values (5, 'e', 50, date
'2024-08-05'), (6, 'f', 60, date '2024-08-06')"""
+
+ Map<Integer, List<Long>> initialLineage =
lineageMap(tableName)
+ assertUniqueRowIds(tableName)
+
+ sql """update ${tableName} set score = score + 10 where id
= 1"""
+ Map<Integer, List<Long>> afterUpdateLineage =
lineageMap(tableName)
+ assertUniqueRowIds(tableName)
+ assertEquals(initialLineage[1][0],
afterUpdateLineage[1][0])
+ assertTrue(afterUpdateLineage[1][1] > initialLineage[1][1],
+ "UPDATE should advance sequence for id=1 in
${tableName}")
+
+ sql """
+ merge into ${tableName} t
+ using (
+ select 4 as id, 'stable_m' as name, 404 as score,
date '2024-08-04' as dt
+ ) s
+ on t.id = s.id
+ when matched then update set name = s.name, score =
s.score
+ """
+ Map<Integer, List<Long>> afterMergeLineage =
lineageMap(tableName)
+ assertUniqueRowIds(tableName)
+ assertEquals(initialLineage[4][0], afterMergeLineage[4][0])
+ assertTrue(afterMergeLineage[4][1] > initialLineage[4][1],
+ "MERGE UPDATE should advance sequence for id=4 in
${tableName}")
+
+ def rewriteResult = sql("""
+ alter table ${catalogName}.${dbName}.${tableName}
+ execute rewrite_data_files(
+ "target-file-size-bytes" = "10485760",
+ "min-input-files" = "1"
+ )
+ """)
+ log.info("rewrite_data_files result for ${tableName}:
${rewriteResult}")
+ assertTrue(rewriteResult.size() > 0,
+ "rewrite_data_files should return summary rows for
${tableName}")
+
+ Map<Integer, List<Long>> afterRewriteLineage =
lineageMap(tableName)
+ assertUniqueRowIds(tableName)
+ assertEquals(afterUpdateLineage[1][0],
afterRewriteLineage[1][0])
Review Comment:
Both maps already contain the exact lineage state for all six visible rows,
but these assertions compare only two row IDs and one sequence number. A
rewrite can reassign IDs/sequences for the other rows (or ID 1's sequence) and
still pass uniqueness plus business-row checks. Please require
`afterRewriteLineage == afterMergeLineage` after also proving the action
rewrote at least one file.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]