This is an automated email from the ASF dual-hosted git repository.
Gabriel39 pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/doris.git
The following commit(s) were added to refs/heads/master by this push:
new 25ee5555841 [fix](iceberg) Fail on missing snapshot files (#68142)
25ee5555841 is described below
commit 25ee555584147a539c70e306940f6d6ad532b39d
Author: Gabriel <[email protected]>
AuthorDate: Fri Sep 18 11:54:38 2026 +0800
[fix](iceberg) Fail on missing snapshot files (#68142)
### What problem does this PR solve?
An Iceberg snapshot can reference a data file that has been removed from
storage. With the default
`ignore_not_found_file_in_external_table=true`, both file scanners can
skip the missing file and return a successful but incomplete result.
Exclude Iceberg ranges from the missing-file skip policy, regardless of
that setting, while preserving the existing behavior for other table
formats. Apply the shared policy at reader initialization and read
boundaries. This also prevents a missing delete file reported as
`NOT_FOUND` from being swallowed by the scanner.
Add unit coverage for Iceberg Parquet/ORC/JNI and ordinary external
ranges with both setting values. Add an external regression that creates
multiple data files, deletes one actual object, and expects row scans to
fail with V1/V2 and both setting values. The regression disables
file/result caches and restores the BE configuration after testing.
### Release note
Iceberg scans now fail when a required snapshot file is missing instead
of silently skipping it, including when
`ignore_not_found_file_in_external_table` is enabled.
### Check List (For Author)
- Test: The shared-policy GoogleTests passed in a standalone build with
actual generated Thrift types; the Iceberg assertion failed with the
extracted original policy. Groovy compilation, clang-format 16, and
whitespace checks passed. The full BE UT runner was blocked during local
clucene/Zstd dependency configuration. The external regression has not
been executed locally.
- Behavior changed: Yes; missing Iceberg files are no longer ignored.
Other table formats retain their existing behavior.
- Does this need documentation: No new configuration or API; the
corrected behavior is described above.
---
be/src/exec/scan/file_scan_range_utils.h | 31 ++++++++
be/src/exec/scan/file_scanner.cpp | 17 +++--
be/src/exec/scan/file_scanner_v2.cpp | 11 ++-
be/test/exec/scan/file_scan_range_utils_test.cpp | 48 ++++++++++++
.../iceberg/test_iceberg_missing_data_file.groovy | 89 ++++++++++++++++++++++
5 files changed, 188 insertions(+), 8 deletions(-)
diff --git a/be/src/exec/scan/file_scan_range_utils.h
b/be/src/exec/scan/file_scan_range_utils.h
new file mode 100644
index 00000000000..cd4c06c9534
--- /dev/null
+++ b/be/src/exec/scan/file_scan_range_utils.h
@@ -0,0 +1,31 @@
+// 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.
+
+#pragma once
+
+#include "gen_cpp/PlanNodes_types.h"
+
+namespace doris {
+
+inline bool can_ignore_not_found_file(const TFileRangeDesc& range, bool
ignore_not_found) {
+ // Iceberg ranges belong to a committed snapshot, not a stale directory
listing. Skipping a
+ // missing data or delete file would silently return an incomplete or
incorrect snapshot.
+ return ignore_not_found && !(range.__isset.table_format_params &&
+ range.table_format_params.table_format_type
== "iceberg");
+}
+
+} // namespace doris
diff --git a/be/src/exec/scan/file_scanner.cpp
b/be/src/exec/scan/file_scanner.cpp
index 5c5dce40a31..18ce636062a 100644
--- a/be/src/exec/scan/file_scanner.cpp
+++ b/be/src/exec/scan/file_scanner.cpp
@@ -51,6 +51,7 @@
#include "core/string_ref.h"
#include "exec/common/stringop_substring.h"
#include "exec/rowid_fetcher.h"
+#include "exec/scan/file_scan_range_utils.h"
#include "exec/scan/scan_node.h"
#include "exprs/aggregate/aggregate_function.h"
#include "exprs/function/function.h"
@@ -545,9 +546,11 @@ Status FileScanner::_get_block_wrapped(RuntimeState*
state, Block* block, bool*
_finalize_reader_condition_cache();
// The file may not exist because the file list is got from meta
cache,
// And the file may already be removed from storage.
- // Just ignore not found files.
+ // Only formats without Iceberg snapshot guarantees may ignore
missing files.
Status st = _get_next_reader();
- if (st.is<ErrorCode::NOT_FOUND>() &&
config::ignore_not_found_file_in_external_table) {
+ if (st.is<ErrorCode::NOT_FOUND>() &&
+ can_ignore_not_found_file(_current_range,
+
config::ignore_not_found_file_in_external_table)) {
_cur_reader_eof = true;
COUNTER_UPDATE(_not_found_file_counter, 1);
continue;
@@ -581,7 +584,9 @@ Status FileScanner::_get_block_wrapped(RuntimeState* state,
Block* block, bool*
// Some of column in block may not be filled (column not exist in
file)
Status st = _cur_reader->get_next_block(_src_block_ptr,
&read_rows, &_cur_reader_eof);
// Lazy open may surface NOT_FOUND on the first read; skip as
above.
- if (st.is<ErrorCode::NOT_FOUND>() &&
config::ignore_not_found_file_in_external_table) {
+ if (st.is<ErrorCode::NOT_FOUND>() &&
+ can_ignore_not_found_file(_current_range,
+
config::ignore_not_found_file_in_external_table)) {
_cur_reader_eof = true;
COUNTER_UPDATE(_not_found_file_counter, 1);
continue;
@@ -1274,13 +1279,13 @@ Status FileScanner::_get_next_reader() {
COUNTER_UPDATE(_file_counter, 1);
// The FileScanner for external table may try to open not exist files,
// Because FE file cache for external table may out of date.
- // So, NOT_FOUND for FileScanner is not a fail case.
- // Will remove this after file reader refactor.
+ // Iceberg snapshot files must still fail the query if they are
missing.
if (init_status.is<END_OF_FILE>()) {
COUNTER_UPDATE(_empty_file_counter, 1);
continue;
} else if (init_status.is<ErrorCode::NOT_FOUND>()) {
- if (config::ignore_not_found_file_in_external_table) {
+ if (can_ignore_not_found_file(_current_range,
+
config::ignore_not_found_file_in_external_table)) {
COUNTER_UPDATE(_not_found_file_counter, 1);
continue;
}
diff --git a/be/src/exec/scan/file_scanner_v2.cpp
b/be/src/exec/scan/file_scanner_v2.cpp
index 284860233ef..93ea455512b 100644
--- a/be/src/exec/scan/file_scanner_v2.cpp
+++ b/be/src/exec/scan/file_scanner_v2.cpp
@@ -46,6 +46,7 @@
#include "exec/operator/scan_operator.h"
#include "exec/scan/access_path_parser.h"
#include "exec/scan/file_scan_io_context.h"
+#include "exec/scan/file_scan_range_utils.h"
#include "exprs/runtime_filter_expr.h"
#include "exprs/vexpr.h"
#include "exprs/vexpr_context.h"
@@ -468,7 +469,10 @@ Status FileScannerV2::_get_block_impl(RuntimeState* state,
Block* block, bool* e
_table_reader->set_batch_size(_predict_reader_batch_rows());
}
const auto status = _table_reader->get_block(block, eof);
- if (_should_skip_not_found(status,
config::ignore_not_found_file_in_external_table)) {
+ if (_should_skip_not_found(
+ status,
+ can_ignore_not_found_file(
+ _current_range,
config::ignore_not_found_file_in_external_table))) {
RETURN_IF_ERROR(_table_reader->abort_split());
COUNTER_UPDATE(_not_found_file_counter, 1);
_state->update_num_finished_scan_range(1);
@@ -554,7 +558,10 @@ Status FileScannerV2::_prepare_next_split(bool* eos) {
RETURN_IF_ERROR(_generate_partition_values(_current_range,
&partition_values));
const auto status =
_prepare_table_reader_split(_current_range,
std::move(partition_values));
- if (_should_skip_not_found(status,
config::ignore_not_found_file_in_external_table)) {
+ if (_should_skip_not_found(
+ status,
+ can_ignore_not_found_file(_current_range,
+
config::ignore_not_found_file_in_external_table))) {
RETURN_IF_ERROR(_table_reader->abort_split());
COUNTER_UPDATE(_not_found_file_counter, 1);
_state->update_num_finished_scan_range(1);
diff --git a/be/test/exec/scan/file_scan_range_utils_test.cpp
b/be/test/exec/scan/file_scan_range_utils_test.cpp
new file mode 100644
index 00000000000..bc8bbfaa465
--- /dev/null
+++ b/be/test/exec/scan/file_scan_range_utils_test.cpp
@@ -0,0 +1,48 @@
+// 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.
+
+#include "exec/scan/file_scan_range_utils.h"
+
+#include <gtest/gtest.h>
+
+namespace doris {
+
+TEST(FileScanRangeUtilsTest, IcebergFilesCannotBeIgnored) {
+ TFileRangeDesc range;
+ range.table_format_params.__set_table_format_type("iceberg");
+ range.__isset.table_format_params = true;
+ for (auto format : {TFileFormatType::FORMAT_PARQUET,
TFileFormatType::FORMAT_ORC,
+ TFileFormatType::FORMAT_JNI}) {
+ range.__set_format_type(format);
+ EXPECT_FALSE(can_ignore_not_found_file(range, true));
+ EXPECT_FALSE(can_ignore_not_found_file(range, false));
+ }
+}
+
+TEST(FileScanRangeUtilsTest, OtherFilesRespectIgnoreSetting) {
+ TFileRangeDesc range;
+ EXPECT_TRUE(can_ignore_not_found_file(range, true));
+ EXPECT_FALSE(can_ignore_not_found_file(range, false));
+ for (const auto* format : {"hive", "hudi", "paimon"}) {
+ range.table_format_params.__set_table_format_type(format);
+ range.__isset.table_format_params = true;
+ EXPECT_TRUE(can_ignore_not_found_file(range, true));
+ EXPECT_FALSE(can_ignore_not_found_file(range, false));
+ }
+}
+
+} // namespace doris
diff --git
a/regression-test/suites/external_table_p0/iceberg/test_iceberg_missing_data_file.groovy
b/regression-test/suites/external_table_p0/iceberg/test_iceberg_missing_data_file.groovy
new file mode 100644
index 00000000000..f71e1449cfb
--- /dev/null
+++
b/regression-test/suites/external_table_p0/iceberg/test_iceberg_missing_data_file.groovy
@@ -0,0 +1,89 @@
+// 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 com.amazonaws.auth.AWSStaticCredentialsProvider
+import com.amazonaws.auth.BasicAWSCredentials
+import com.amazonaws.client.builder.AwsClientBuilder.EndpointConfiguration
+import com.amazonaws.services.s3.AmazonS3ClientBuilder
+
+suite("test_iceberg_missing_data_file",
+
"p0,external,iceberg,external_docker,external_docker_iceberg,nonConcurrent") {
+ if
(!"true".equalsIgnoreCase(context.config.otherConfigs.get("enableIcebergTest")))
{
+ logger.info("disable iceberg test")
+ return
+ }
+ String host = context.config.otherConfigs.get("externalEnvIp")
+ String restPort = context.config.otherConfigs.get("iceberg_rest_uri_port")
+ String minioPort = context.config.otherConfigs.get("iceberg_minio_port")
+ String endpoint = "http://${host}:${minioPort}"
+
+ sql "drop catalog if exists test_iceberg_missing_data_file"
+ sql """create catalog test_iceberg_missing_data_file properties (
+ "type" = "iceberg",
+ "iceberg.catalog.type" = "rest",
+ "uri" = "http://${host}:${restPort}",
+ "s3.endpoint" = "${endpoint}",
+ "s3.access_key" = "admin",
+ "s3.secret_key" = "password",
+ "s3.region" = "us-east-1"
+ )"""
+ sql "switch test_iceberg_missing_data_file"
+ sql "create database if not exists missing_data_file_db"
+ sql "use missing_data_file_db"
+ sql "drop table if exists missing_data_file"
+ sql """create table missing_data_file (id int, payload string)
+ properties ("format-version" = "2", "write.format.default" =
"parquet")"""
+ sql "insert into missing_data_file values (1, 'first-file')"
+ sql "insert into missing_data_file values (2, 'second-file')"
+
+ // Inspect manifests only: reading table rows here could cache the object
that will be removed.
+ def files = sql "select file_path from missing_data_file\$files where
content = 0"
+ assertTrue(files.size() >= 2, "The snapshot must contain multiple data
files")
+ URI missingFile = new URI(files[0][0].toString())
+ // Hadoop-backed catalogs may expose the same S3 objects through s3a or
s3n URIs.
+ assertTrue(missingFile.scheme in ["s3", "s3a", "s3n"],
+ "Unexpected object storage URI: ${missingFile}")
+ String key = missingFile.path.substring(1)
+ def client = AmazonS3ClientBuilder.standard()
+ .withEndpointConfiguration(new EndpointConfiguration(endpoint,
"us-east-1"))
+ .withPathStyleAccessEnabled(true)
+ .withCredentials(new AWSStaticCredentialsProvider(
+ new BasicAWSCredentials("admin", "password")))
+ .build()
+ try {
+ // Remove only an object created by this suite, leaving the snapshot
metadata unchanged.
+ client.deleteObject(missingFile.host, key)
+ assertFalse(client.doesObjectExist(missingFile.host, key))
+ } finally {
+ client.shutdown()
+ }
+
+ sql "set enable_file_cache = false"
+ sql "set enable_sql_cache = false"
+ sql "set enable_query_cache = false"
+ for (boolean ignoreMissing : [true, false]) {
+ setBeConfigTemporary([ignore_not_found_file_in_external_table:
ignoreMissing]) {
+ for (boolean scannerV2 : [false, true]) {
+ sql "set enable_file_scanner_v2 = ${scannerV2}"
+ test {
+ sql "select id, payload from missing_data_file order by id"
+ exception "NOT_FOUND"
+ }
+ }
+ }
+ }
+}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]