mirror of
https://github.com/apache/doris.git
synced 2026-11-04 07:31:17 +00:00
### What problem does this PR solve? Issue Number: None Related PR: #69082 Problem Summary: **In short.** DELETE, UPDATE and MERGE on an Iceberg table recorded the delete files they write (v2 position delete files and v3 deletion vector files) under the internal `s3://` address BE writes them at, instead of the table's own location (`oss://`, `s3a://`, `abfss://`, ...). Doris could not always read such a table back, and other engines and maintenance procedures resolved those files with the wrong file system. This PR commits delete files under the table's location. It also makes every Iceberg file Doris writes take both of its paths from one place in BE and pass one check in FE, so the next writer cannot repeat the mistake without failing. #### Background Every Iceberg sink FE sends carries two forms of the table's data location: - a normalized one BE can address the storage with (`output_path`): FE rewrites `oss://`, `cos://`, `cosn://`, `obs://`, `bos://`, `gs://`, `s3a://`, `s3n://` and Azure `abfs[s]://` / `wasb[s]://` locations (except OneLake) to `s3://`; - the raw one Iceberg metadata must record: `original_output_path` on `TIcebergTableSink`, `table_location` on `TIcebergDeleteSink`, both on `TIcebergMergeSink`. BE writes each file under the first form and reports it in `TIcebergCommitData.file_path`, which FE writes into the manifests as it is. So the reported path must be in the second form. #### The problem, and what it cost Reproduce on an Iceberg table located at `oss://bucket/...`: ```sql CREATE TABLE t (id INT, v STRING) PROPERTIES ('format-version' = '2'); INSERT INTO t VALUES (1, 'a'), (2, 'b'), (3, 'c'); DELETE FROM t WHERE id = 1; SELECT file_path FROM t$delete_files; -- s3://bucket/.../data/delete_pos_xxx.parquet SELECT file_path FROM t$data_files; -- oss://bucket/.../data/... ``` - Whether Doris can still read the table depends on the catalog and storage binding. In a real Azure ADLS Hadoop catalog test with explicit SharedKey, Doris SELECT failed with `No storage properties found for schema: s3`, and Spark failed with `UnsupportedFileSystemException: No FileSystem for scheme "s3"`. - Other engines and maintenance operations such as `expire_snapshots` / `remove_orphan_files` may resolve the wrong file system from the recorded scheme, depending on the engine and FileIO. Doris's own `remove_orphan_files` refuses to run on such a table (`Cannot determine whether listed and reachable file locations are equivalent`). - Cause: data files were written under `write_path` and committed under `original_write_path` (`VIcebergPartitionWriter`), but `VIcebergDeleteSink` had no such split; it reported the `output_path`-based path it wrote. - Why it went unnoticed: the two writers mapped the paths in different ways, `VIcebergDeleteFileWriter` reported its write path by default, and FE accepted any path. CI did run on such a location: the Iceberg REST fixture's warehouse is `s3a://`, which FE normalizes to `s3://`. But the DML suites only checked that delete file paths contain `/data/delete_pos_`, and a stray `test_iceberg_update_delete_basic.out.org` in the repository records the wrong `s3://warehouse/wh/...` paths. #### How this PR fixes it 1. `[fix](be) Commit Iceberg delete files under the table's own location`, `[test](be) Verify Iceberg delete file commit paths` and `[test](be) Initialize the Arrow memory pool in the Iceberg position delete write test`: `VIcebergDeleteSink` writes under `output_path` and commits position delete files and deletion vectors under `table_location`. An FE unit test pins that both sinks carrying `table_location` name the same directory as `output_path`, and `DataSinks.thrift` documents the contract. The position delete write test sets up the Arrow memory pool the Parquet writer allocates from, so it also passes when run alone (CI review bot finding on this PR). 2. `[refactor](be) Take both paths of every Iceberg file from one output location`: - The new `IcebergOutputLocation` (header-only) holds one directory in both forms and gives the write path and the commit path of a file name, and of a partition subdirectory. - The data writer (`VIcebergTableWriter` / `VIcebergPartitionWriter`), the delete file writer and the Puffin path all take their paths from it. `VIcebergDeleteFileWriter` now reports the commit path itself instead of its write path. - A table or delete sink without both location fields is rejected in `open()` / `init_properties()` instead of committing a write path or a relative path. The FE of every branch (3.1, 4.0, 4.1, 4.2, master) sets both fields on every Iceberg sink. - Directories join file names with exactly one `/`. 3. `[improvement](fe) Reject Iceberg commit data reported outside the sink's location`: - `planWrite` registers on the transaction the raw location(s) each sink it builds carries, read back from the built sink. - `IcebergConnectorTransaction.addCommitData`, where every report from every writer enters FE, rejects a file that is not under a registered location, or has no path. FE then answers the report as not accepted, and BE deletes the files under the paths it wrote them at and cancels the query, so a wrong path fails the statement and leaves no orphan files. - Thrift comments state the contract, including that "the same directory" can differ in more than the scheme (Azure moves the account out of the authority). Comments only, no protocol change. 4. `[test](regression) Check that every Iceberg write records its files under the table location`: - The new `test_iceberg_write_file_locations` runs INSERT, INSERT OVERWRITE PARTITION, branch INSERT, DELETE, UPDATE, MERGE and `rewrite_data_files` on a v2 table, and INSERT, DELETE, UPDATE and MERGE on a v3 table. After each operation every file in `$all_files` must start with the table's LOCATION plus `/data/`. A new write operation belongs in this suite too. - `test_iceberg_update_delete_basic` checks the prefix of its delete files as well, and the stray `.out.org` is removed. | Scenario | Before | After | | --- | --- | --- | | DELETE / UPDATE / MERGE on a v2 table at `oss://`, `s3a://`, `abfss://`, ... | position delete files recorded as `s3://...` | recorded under the table's location | | The same on a v3 table | deletion vector (Puffin) files recorded as `s3://...` | recorded under the table's location | | A BE writer reports a file outside the location its sink carries | committed as reported | the statement fails; BE deletes the file | | A sink plan without `original_output_path` / `table_location` (no supported FE sends one) | data files committed as `/p=1/x.parquet`, delete files as `s3://...` | rejected before any file is written | | `write.data.path` ending with `/` | data files at `.../data//p=1/x.parquet` | `.../data/p=1/x.parquet`, as Iceberg's own location provider places them | Delete files that an older version already committed with `s3://` keep that address until they are rewritten or expired. Upgrade BE before FE, as usual. An upgraded FE rejects the `s3://` delete file paths that a BE without this fix reports, so until that BE is upgraded, DELETE / UPDATE / MERGE on a table whose location FE normalizes fails instead of committing the wrong path. #### Classes, and how they call each other - `IcebergWritePlanProvider.planWrite` (FE) builds the sink and registers the raw location(s) it carries with the transaction (`commitLocationsOf`). - `IcebergConnectorTransaction.addCommitData` (FE) accepts a reported file only under a registered location; `IcebergWriterHelper` then turns the fragments into `DataFile` / `DeleteFile`. - `IcebergOutputLocation` (BE, new) is one directory in its write form and its commit form. - `VIcebergTableWriter` builds the data location in `open()` and hands each `VIcebergPartitionWriter` its partition directory (`WriteInfo.location`). `VIcebergDeleteSink` writes position delete files through `VIcebergDeleteFileWriter` and deletion vectors into one Puffin file, both from its location. `VIcebergMergeSink` builds one of each. ``` FE planWrite ── sink: output_path + raw location ──────────────► BE │ addCommitLocation(raw location) IcebergOutputLocation(output_path, raw location) ▼ ├─ VIcebergTableWriter ─► VIcebergPartitionWriter data files IcebergConnectorTransaction ├─ VIcebergDeleteSink ──► VIcebergDeleteFileWriter position deletes ▲ addCommitData: file_path under a registered │ └─► Puffin writer deletion vectors │ location, else reject the report └─ VIcebergMergeSink ──► both of the above └──────────── TIcebergCommitData.file_path = commit path ◄───────┘ ``` Not changed: Thrift fields (comments only), the scan side, and how FE normalizes locations.
750 lines
32 KiB
C++
750 lines
32 KiB
C++
// 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/sink/viceberg_delete_sink.h"
|
|
|
|
#include <arrow/api.h>
|
|
#include <arrow/io/api.h>
|
|
#include <gtest/gtest.h>
|
|
#include <parquet/arrow/reader.h>
|
|
#include <rapidjson/document.h>
|
|
|
|
#include <filesystem>
|
|
#include <fstream>
|
|
|
|
#include "common/consts.h"
|
|
#include "common/object_pool.h"
|
|
#include "core/block/block.h"
|
|
#include "core/column/column_string.h"
|
|
#include "core/column/column_struct.h"
|
|
#include "core/column/column_vector.h"
|
|
#include "core/data_type/data_type_number.h"
|
|
#include "core/data_type/data_type_string.h"
|
|
#include "core/data_type/data_type_struct.h"
|
|
#include "exec/common/endian.h"
|
|
#include "format/table/deletion_vector.h"
|
|
#include "gen_cpp/DataSinks_types.h"
|
|
#include "gen_cpp/Types_types.h"
|
|
#include "runtime/exec_env.h"
|
|
#include "runtime/runtime_state.h"
|
|
#include "util/defer_op.h"
|
|
#include "util/uid_util.h"
|
|
|
|
namespace doris {
|
|
|
|
class VIcebergDeleteSinkTest : public testing::Test {
|
|
protected:
|
|
void SetUp() override {
|
|
// Create a basic TDataSink for testing
|
|
_t_data_sink.__set_type(TDataSinkType::ICEBERG_DELETE_SINK);
|
|
|
|
TIcebergDeleteSink delete_sink;
|
|
delete_sink.__set_db_name("test_db");
|
|
delete_sink.__set_tb_name("test_table");
|
|
delete_sink.__set_delete_type(TFileContent::POSITION_DELETES);
|
|
delete_sink.__set_file_format(TFileFormatType::FORMAT_PARQUET);
|
|
delete_sink.__set_compress_type(TFileCompressType::SNAPPYBLOCK);
|
|
delete_sink.__set_output_path("/tmp/iceberg/test");
|
|
delete_sink.__set_table_location("/tmp/iceberg/test_table");
|
|
|
|
std::map<std::string, std::string> hadoop_conf;
|
|
hadoop_conf["fs.defaultFS"] = "hdfs://localhost:9000";
|
|
delete_sink.__set_hadoop_config(hadoop_conf);
|
|
|
|
_t_data_sink.__set_iceberg_delete_sink(delete_sink);
|
|
}
|
|
|
|
TDataSink build_local_delete_sink(const std::string& output_path, int32_t format_version) {
|
|
TDataSink t_data_sink;
|
|
t_data_sink.__set_type(TDataSinkType::ICEBERG_DELETE_SINK);
|
|
|
|
TIcebergDeleteSink delete_sink;
|
|
delete_sink.__set_db_name("test_db");
|
|
delete_sink.__set_tb_name("test_table");
|
|
delete_sink.__set_delete_type(TFileContent::POSITION_DELETES);
|
|
delete_sink.__set_file_format(TFileFormatType::FORMAT_PARQUET);
|
|
delete_sink.__set_compress_type(TFileCompressType::SNAPPYBLOCK);
|
|
delete_sink.__set_output_path(output_path);
|
|
delete_sink.__set_table_location(output_path);
|
|
delete_sink.__set_file_type(TFileType::FILE_LOCAL);
|
|
delete_sink.__set_format_version(format_version);
|
|
|
|
t_data_sink.__set_iceberg_delete_sink(delete_sink);
|
|
return t_data_sink;
|
|
}
|
|
|
|
TDataSink _t_data_sink;
|
|
};
|
|
|
|
TEST_F(VIcebergDeleteSinkTest, TestInitProperties) {
|
|
VExprContextSPtrs output_exprs;
|
|
auto sink = std::make_shared<VIcebergDeleteSink>(_t_data_sink, output_exprs, nullptr, nullptr);
|
|
|
|
ObjectPool pool;
|
|
Status status = sink->init_properties(&pool);
|
|
ASSERT_TRUE(status.ok());
|
|
}
|
|
|
|
TEST_F(VIcebergDeleteSinkTest, RejectsCoordinatorWithoutExternalFileReportAck) {
|
|
VExprContextSPtrs output_exprs;
|
|
auto sink = std::make_shared<VIcebergDeleteSink>(_t_data_sink, output_exprs, nullptr, nullptr);
|
|
RuntimeState state;
|
|
RuntimeProfile profile("test");
|
|
|
|
Status status = sink->open(&state, &profile);
|
|
|
|
EXPECT_TRUE(status.is<ErrorCode::NOT_IMPLEMENTED_ERROR>());
|
|
EXPECT_NE(std::string::npos, status.to_string().find("acknowledges external-file reports"));
|
|
}
|
|
|
|
TEST_F(VIcebergDeleteSinkTest, TestGetRowIdColumnIndex) {
|
|
VExprContextSPtrs output_exprs;
|
|
auto sink = std::make_shared<VIcebergDeleteSink>(_t_data_sink, output_exprs, nullptr, nullptr);
|
|
|
|
// Create a block with $row_id column
|
|
Block block;
|
|
|
|
// Add a regular column
|
|
auto col1 = ColumnInt32::create();
|
|
col1->insert_value(1);
|
|
block.insert(ColumnWithTypeAndName(std::move(col1), std::make_shared<DataTypeInt32>(), "id"));
|
|
|
|
// Add __DORIS_ICEBERG_ROWID_COL__ column (as struct)
|
|
auto file_path_col = ColumnString::create();
|
|
file_path_col->insert_data("file1.parquet", 13);
|
|
|
|
auto row_pos_col = ColumnInt64::create();
|
|
row_pos_col->insert_value(100);
|
|
|
|
Columns struct_cols;
|
|
struct_cols.push_back(std::move(file_path_col));
|
|
struct_cols.push_back(std::move(row_pos_col));
|
|
|
|
auto struct_col = ColumnStruct::create(std::move(struct_cols));
|
|
|
|
DataTypes struct_types;
|
|
struct_types.push_back(std::make_shared<DataTypeString>());
|
|
struct_types.push_back(std::make_shared<DataTypeInt64>());
|
|
|
|
Strings field_names = {"file_path", "row_position"};
|
|
auto struct_type = std::make_shared<DataTypeStruct>(struct_types, field_names);
|
|
|
|
block.insert(ColumnWithTypeAndName(std::move(struct_col), struct_type,
|
|
doris::BeConsts::ICEBERG_ROWID_COL));
|
|
|
|
// Test finding the column
|
|
int idx = sink->_get_row_id_column_index(block);
|
|
ASSERT_EQ(1, idx);
|
|
ASSERT_EQ(doris::BeConsts::ICEBERG_ROWID_COL, block.get_by_position(idx).name);
|
|
}
|
|
|
|
TEST_F(VIcebergDeleteSinkTest, TestGetRowIdColumnIndexWithIcebergRowId) {
|
|
VExprContextSPtrs output_exprs;
|
|
auto sink = std::make_shared<VIcebergDeleteSink>(_t_data_sink, output_exprs, nullptr, nullptr);
|
|
|
|
Block block;
|
|
|
|
auto file_path_col = ColumnString::create();
|
|
file_path_col->insert_data("file1.parquet", 13);
|
|
|
|
auto row_pos_col = ColumnInt64::create();
|
|
row_pos_col->insert_value(100);
|
|
|
|
Columns struct_cols;
|
|
struct_cols.push_back(std::move(file_path_col));
|
|
struct_cols.push_back(std::move(row_pos_col));
|
|
|
|
auto struct_col = ColumnStruct::create(std::move(struct_cols));
|
|
|
|
DataTypes struct_types;
|
|
struct_types.push_back(std::make_shared<DataTypeString>());
|
|
struct_types.push_back(std::make_shared<DataTypeInt64>());
|
|
|
|
Strings field_names = {"file_path", "row_position"};
|
|
auto struct_type = std::make_shared<DataTypeStruct>(struct_types, field_names);
|
|
|
|
block.insert(ColumnWithTypeAndName(std::move(struct_col), struct_type,
|
|
doris::BeConsts::ICEBERG_ROWID_COL));
|
|
|
|
int idx = sink->_get_row_id_column_index(block);
|
|
ASSERT_EQ(0, idx);
|
|
ASSERT_EQ(doris::BeConsts::ICEBERG_ROWID_COL, block.get_by_position(idx).name);
|
|
}
|
|
|
|
TEST_F(VIcebergDeleteSinkTest, TestCollectPositionDeletes) {
|
|
VExprContextSPtrs output_exprs;
|
|
auto sink = std::make_shared<VIcebergDeleteSink>(_t_data_sink, output_exprs, nullptr, nullptr);
|
|
|
|
// Create a block with multiple $row_id entries
|
|
Block block;
|
|
|
|
auto file_path_col = ColumnString::create();
|
|
file_path_col->insert_data("file1.parquet", 13);
|
|
file_path_col->insert_data("file2.parquet", 13);
|
|
file_path_col->insert_data("file1.parquet", 13);
|
|
|
|
auto row_pos_col = ColumnInt64::create();
|
|
row_pos_col->insert_value(100);
|
|
row_pos_col->insert_value(200);
|
|
row_pos_col->insert_value(150);
|
|
|
|
auto spec_id_col = ColumnInt32::create();
|
|
spec_id_col->insert_value(1);
|
|
spec_id_col->insert_value(2);
|
|
spec_id_col->insert_value(1);
|
|
|
|
auto partition_data_col = ColumnString::create();
|
|
partition_data_col->insert_data("p=1", 3);
|
|
partition_data_col->insert_data("p=2", 3);
|
|
partition_data_col->insert_data("p=1", 3);
|
|
|
|
Columns struct_cols;
|
|
struct_cols.push_back(std::move(file_path_col));
|
|
struct_cols.push_back(std::move(row_pos_col));
|
|
struct_cols.push_back(std::move(spec_id_col));
|
|
struct_cols.push_back(std::move(partition_data_col));
|
|
|
|
auto struct_col = ColumnStruct::create(std::move(struct_cols));
|
|
|
|
DataTypes struct_types;
|
|
struct_types.push_back(std::make_shared<DataTypeString>());
|
|
struct_types.push_back(std::make_shared<DataTypeInt64>());
|
|
struct_types.push_back(std::make_shared<DataTypeInt32>());
|
|
struct_types.push_back(std::make_shared<DataTypeString>());
|
|
|
|
Strings field_names = {"file_path", "row_position", "partition_spec_id", "partition_data"};
|
|
auto struct_type = std::make_shared<DataTypeStruct>(struct_types, field_names);
|
|
|
|
block.insert(ColumnWithTypeAndName(std::move(struct_col), struct_type,
|
|
doris::BeConsts::ICEBERG_ROWID_COL));
|
|
|
|
// Extract and group
|
|
std::map<std::string, IcebergFileDeletion> file_deletions;
|
|
Status status = sink->_collect_position_deletes(block, file_deletions);
|
|
ASSERT_TRUE(status.ok());
|
|
|
|
// Verify grouping
|
|
ASSERT_EQ(2, file_deletions.size());
|
|
|
|
ASSERT_TRUE(file_deletions.count("file1.parquet") > 0);
|
|
const auto& file1 = file_deletions.at("file1.parquet");
|
|
ASSERT_EQ(1, file1.partition_spec_id);
|
|
ASSERT_EQ("p=1", file1.partition_data_json);
|
|
ASSERT_EQ(2, file1.rows_to_delete.cardinality());
|
|
ASSERT_TRUE(file1.rows_to_delete.contains(static_cast<uint64_t>(100)));
|
|
ASSERT_TRUE(file1.rows_to_delete.contains(static_cast<uint64_t>(150)));
|
|
|
|
ASSERT_TRUE(file_deletions.count("file2.parquet") > 0);
|
|
const auto& file2 = file_deletions.at("file2.parquet");
|
|
ASSERT_EQ(2, file2.partition_spec_id);
|
|
ASSERT_EQ("p=2", file2.partition_data_json);
|
|
ASSERT_EQ(1, file2.rows_to_delete.cardinality());
|
|
ASSERT_TRUE(file2.rows_to_delete.contains(static_cast<uint64_t>(200)));
|
|
}
|
|
|
|
TEST_F(VIcebergDeleteSinkTest, TestCollectPositionDeletesByFieldNames) {
|
|
VExprContextSPtrs output_exprs;
|
|
auto sink = std::make_shared<VIcebergDeleteSink>(_t_data_sink, output_exprs, nullptr, nullptr);
|
|
|
|
Block block;
|
|
|
|
auto row_pos_col = ColumnInt64::create();
|
|
row_pos_col->insert_value(100);
|
|
|
|
auto file_path_col = ColumnString::create();
|
|
file_path_col->insert_data("file1.parquet", 13);
|
|
|
|
auto partition_data_col = ColumnString::create();
|
|
partition_data_col->insert_data("[\"p=1\"]", 7);
|
|
|
|
auto spec_id_col = ColumnInt32::create();
|
|
spec_id_col->insert_value(3);
|
|
|
|
Columns struct_cols;
|
|
struct_cols.push_back(std::move(row_pos_col));
|
|
struct_cols.push_back(std::move(file_path_col));
|
|
struct_cols.push_back(std::move(partition_data_col));
|
|
struct_cols.push_back(std::move(spec_id_col));
|
|
|
|
DataTypes struct_types;
|
|
struct_types.push_back(std::make_shared<DataTypeInt64>());
|
|
struct_types.push_back(std::make_shared<DataTypeString>());
|
|
struct_types.push_back(std::make_shared<DataTypeString>());
|
|
struct_types.push_back(std::make_shared<DataTypeInt32>());
|
|
|
|
// Standard field names are accepted even if order changes.
|
|
Strings field_names = {"row_position", "file_path", "partition_data", "partition_spec_id"};
|
|
auto struct_type = std::make_shared<DataTypeStruct>(struct_types, field_names);
|
|
|
|
auto struct_col = ColumnStruct::create(std::move(struct_cols));
|
|
block.insert(ColumnWithTypeAndName(std::move(struct_col), struct_type,
|
|
doris::BeConsts::ICEBERG_ROWID_COL));
|
|
|
|
std::map<std::string, IcebergFileDeletion> file_deletions;
|
|
Status status = sink->_collect_position_deletes(block, file_deletions);
|
|
ASSERT_TRUE(status.ok());
|
|
|
|
ASSERT_EQ(1, file_deletions.size());
|
|
const auto& file = file_deletions.at("file1.parquet");
|
|
ASSERT_EQ(3, file.partition_spec_id);
|
|
ASSERT_EQ("[\"p=1\"]", file.partition_data_json);
|
|
ASSERT_EQ(1, file.rows_to_delete.cardinality());
|
|
ASSERT_TRUE(file.rows_to_delete.contains(static_cast<uint64_t>(100)));
|
|
}
|
|
|
|
TEST_F(VIcebergDeleteSinkTest, TestCollectPositionDeletesRejectNonStandardFieldNames) {
|
|
VExprContextSPtrs output_exprs;
|
|
auto sink = std::make_shared<VIcebergDeleteSink>(_t_data_sink, output_exprs, nullptr, nullptr);
|
|
|
|
Block block;
|
|
|
|
auto row_pos_col = ColumnInt64::create();
|
|
row_pos_col->insert_value(100);
|
|
|
|
auto file_path_col = ColumnString::create();
|
|
file_path_col->insert_data("file1.parquet", 13);
|
|
|
|
auto partition_data_col = ColumnString::create();
|
|
partition_data_col->insert_data("[\"p=1\"]", 7);
|
|
|
|
auto spec_id_col = ColumnInt32::create();
|
|
spec_id_col->insert_value(3);
|
|
|
|
Columns struct_cols;
|
|
struct_cols.push_back(std::move(row_pos_col));
|
|
struct_cols.push_back(std::move(file_path_col));
|
|
struct_cols.push_back(std::move(partition_data_col));
|
|
struct_cols.push_back(std::move(spec_id_col));
|
|
|
|
DataTypes struct_types;
|
|
struct_types.push_back(std::make_shared<DataTypeInt64>());
|
|
struct_types.push_back(std::make_shared<DataTypeString>());
|
|
struct_types.push_back(std::make_shared<DataTypeString>());
|
|
struct_types.push_back(std::make_shared<DataTypeInt32>());
|
|
|
|
Strings field_names = {"pos", "file_path", "partition_data_json", "spec_id"};
|
|
auto struct_type = std::make_shared<DataTypeStruct>(struct_types, field_names);
|
|
|
|
auto struct_col = ColumnStruct::create(std::move(struct_cols));
|
|
block.insert(ColumnWithTypeAndName(std::move(struct_col), struct_type,
|
|
doris::BeConsts::ICEBERG_ROWID_COL));
|
|
|
|
std::map<std::string, IcebergFileDeletion> file_deletions;
|
|
Status status = sink->_collect_position_deletes(block, file_deletions);
|
|
ASSERT_FALSE(status.ok());
|
|
}
|
|
|
|
TEST_F(VIcebergDeleteSinkTest, TestCollectPositionDeletesFallbackPartitionInfo) {
|
|
TDataSink t_data_sink;
|
|
t_data_sink.__set_type(TDataSinkType::ICEBERG_DELETE_SINK);
|
|
|
|
TIcebergDeleteSink delete_sink;
|
|
delete_sink.__set_db_name("test_db");
|
|
delete_sink.__set_tb_name("test_table");
|
|
delete_sink.__set_delete_type(TFileContent::POSITION_DELETES);
|
|
delete_sink.__set_file_format(TFileFormatType::FORMAT_PARQUET);
|
|
delete_sink.__set_compress_type(TFileCompressType::SNAPPYBLOCK);
|
|
delete_sink.__set_output_path("/tmp/iceberg/test");
|
|
delete_sink.__set_table_location("/tmp/iceberg/test_table");
|
|
delete_sink.__set_partition_spec_id(11);
|
|
delete_sink.__set_partition_data_json("[\"11\"]");
|
|
|
|
t_data_sink.__set_iceberg_delete_sink(delete_sink);
|
|
|
|
VExprContextSPtrs output_exprs;
|
|
auto sink = std::make_shared<VIcebergDeleteSink>(t_data_sink, output_exprs, nullptr, nullptr);
|
|
ObjectPool pool;
|
|
ASSERT_TRUE(sink->init_properties(&pool).ok());
|
|
|
|
Block block;
|
|
|
|
auto file_path_col = ColumnString::create();
|
|
file_path_col->insert_data("file1.parquet", 13);
|
|
|
|
auto row_pos_col = ColumnInt64::create();
|
|
row_pos_col->insert_value(100);
|
|
|
|
Columns struct_cols;
|
|
struct_cols.push_back(std::move(file_path_col));
|
|
struct_cols.push_back(std::move(row_pos_col));
|
|
|
|
auto struct_col = ColumnStruct::create(std::move(struct_cols));
|
|
|
|
DataTypes struct_types;
|
|
struct_types.push_back(std::make_shared<DataTypeString>());
|
|
struct_types.push_back(std::make_shared<DataTypeInt64>());
|
|
|
|
Strings field_names = {"file_path", "row_position"};
|
|
auto struct_type = std::make_shared<DataTypeStruct>(struct_types, field_names);
|
|
|
|
block.insert(ColumnWithTypeAndName(std::move(struct_col), struct_type,
|
|
doris::BeConsts::ICEBERG_ROWID_COL));
|
|
|
|
std::map<std::string, IcebergFileDeletion> file_deletions;
|
|
Status status = sink->_collect_position_deletes(block, file_deletions);
|
|
ASSERT_TRUE(status.ok());
|
|
|
|
ASSERT_EQ(1, file_deletions.size());
|
|
const auto& file = file_deletions.at("file1.parquet");
|
|
ASSERT_EQ(11, file.partition_spec_id);
|
|
ASSERT_EQ("[\"11\"]", file.partition_data_json);
|
|
ASSERT_EQ(1, file.rows_to_delete.cardinality());
|
|
ASSERT_TRUE(file.rows_to_delete.contains(static_cast<uint64_t>(100)));
|
|
}
|
|
|
|
TEST_F(VIcebergDeleteSinkTest, TestCollectPositionDeletesInvalidRowPosition) {
|
|
VExprContextSPtrs output_exprs;
|
|
auto sink = std::make_shared<VIcebergDeleteSink>(_t_data_sink, output_exprs, nullptr, nullptr);
|
|
|
|
Block block;
|
|
|
|
auto file_path_col = ColumnString::create();
|
|
file_path_col->insert_data("file1.parquet", 13);
|
|
|
|
auto row_pos_col = ColumnInt64::create();
|
|
row_pos_col->insert_value(-1);
|
|
|
|
Columns struct_cols;
|
|
struct_cols.push_back(std::move(file_path_col));
|
|
struct_cols.push_back(std::move(row_pos_col));
|
|
|
|
auto struct_col = ColumnStruct::create(std::move(struct_cols));
|
|
|
|
DataTypes struct_types;
|
|
struct_types.push_back(std::make_shared<DataTypeString>());
|
|
struct_types.push_back(std::make_shared<DataTypeInt64>());
|
|
|
|
Strings field_names = {"file_path", "row_position"};
|
|
auto struct_type = std::make_shared<DataTypeStruct>(struct_types, field_names);
|
|
|
|
block.insert(ColumnWithTypeAndName(std::move(struct_col), struct_type,
|
|
doris::BeConsts::ICEBERG_ROWID_COL));
|
|
|
|
std::map<std::string, IcebergFileDeletion> file_deletions;
|
|
Status status = sink->_collect_position_deletes(block, file_deletions);
|
|
ASSERT_FALSE(status.ok());
|
|
}
|
|
|
|
TEST_F(VIcebergDeleteSinkTest, TestBuildPositionDeleteBlock) {
|
|
VExprContextSPtrs output_exprs;
|
|
auto sink = std::make_shared<VIcebergDeleteSink>(_t_data_sink, output_exprs, nullptr, nullptr);
|
|
|
|
std::string file_path = "test_file.parquet";
|
|
std::vector<int64_t> positions = {10, 20, 30, 40};
|
|
|
|
Block output_block;
|
|
Status status = sink->_build_position_delete_block(file_path, positions, output_block);
|
|
ASSERT_TRUE(status.ok());
|
|
|
|
// Verify block structure
|
|
ASSERT_EQ(2, output_block.columns());
|
|
ASSERT_EQ(4, output_block.rows());
|
|
|
|
// Verify column names
|
|
ASSERT_EQ("file_path", output_block.get_by_position(0).name);
|
|
ASSERT_EQ("pos", output_block.get_by_position(1).name);
|
|
|
|
// Verify file_path column
|
|
auto file_path_column =
|
|
check_and_get_column<ColumnString>(output_block.get_by_position(0).column.get());
|
|
ASSERT_NE(nullptr, file_path_column);
|
|
for (size_t i = 0; i < 4; i++) {
|
|
ASSERT_EQ(file_path, file_path_column->get_data_at(i).to_string());
|
|
}
|
|
|
|
// Verify pos column
|
|
auto pos_column =
|
|
check_and_get_column<ColumnInt64>(output_block.get_by_position(1).column.get());
|
|
ASSERT_NE(nullptr, pos_column);
|
|
ASSERT_EQ(10, pos_column->get_element(0));
|
|
ASSERT_EQ(20, pos_column->get_element(1));
|
|
ASSERT_EQ(30, pos_column->get_element(2));
|
|
ASSERT_EQ(40, pos_column->get_element(3));
|
|
}
|
|
|
|
TEST_F(VIcebergDeleteSinkTest, TestDeleteFileNames) {
|
|
VExprContextSPtrs output_exprs;
|
|
auto sink = std::make_shared<VIcebergDeleteSink>(_t_data_sink, output_exprs, nullptr, nullptr);
|
|
ObjectPool pool;
|
|
ASSERT_TRUE(sink->init_properties(&pool).ok());
|
|
|
|
// Names are relative to the data location; the location supplies both forms of the directory.
|
|
std::string delete_file_name = sink->_delete_file_name("data/file1.parquet");
|
|
EXPECT_TRUE(delete_file_name.starts_with("delete_pos_")) << delete_file_name;
|
|
EXPECT_TRUE(delete_file_name.ends_with(".snappy.parquet")) << delete_file_name;
|
|
EXPECT_EQ(std::string::npos, delete_file_name.find('/'));
|
|
EXPECT_NE(delete_file_name, sink->_delete_file_name("data/file1.parquet"));
|
|
|
|
std::string puffin_file_name = sink->_puffin_file_name();
|
|
EXPECT_TRUE(puffin_file_name.starts_with("delete_dv_")) << puffin_file_name;
|
|
EXPECT_TRUE(puffin_file_name.ends_with(".puffin")) << puffin_file_name;
|
|
EXPECT_EQ(std::string::npos, puffin_file_name.find('/'));
|
|
}
|
|
|
|
TEST_F(VIcebergDeleteSinkTest, TestInitPropertiesTakesBothFormsOfTheDataLocation) {
|
|
// FE writes through the normalized output_path but the table records its own location.
|
|
TDataSink t_data_sink = _t_data_sink;
|
|
t_data_sink.iceberg_delete_sink.__set_output_path("s3://b/db/t/data");
|
|
t_data_sink.iceberg_delete_sink.__set_table_location("oss://b/db/t/data/");
|
|
VExprContextSPtrs output_exprs;
|
|
auto sink = std::make_shared<VIcebergDeleteSink>(t_data_sink, output_exprs, nullptr, nullptr);
|
|
ObjectPool pool;
|
|
ASSERT_TRUE(sink->init_properties(&pool).ok());
|
|
ASSERT_TRUE(sink->_output_location.has_value());
|
|
EXPECT_EQ("s3://b/db/t/data/delete_dv_1.puffin",
|
|
sink->_output_location->write_path("delete_dv_1.puffin"));
|
|
EXPECT_EQ("oss://b/db/t/data/delete_dv_1.puffin",
|
|
sink->_output_location->commit_path("delete_dv_1.puffin"));
|
|
|
|
// Without the table's own form the commit could only record the s3:// address BE wrote.
|
|
t_data_sink.iceberg_delete_sink.__isset.table_location = false;
|
|
t_data_sink.iceberg_delete_sink.table_location.clear();
|
|
auto no_table_location =
|
|
std::make_shared<VIcebergDeleteSink>(t_data_sink, output_exprs, nullptr, nullptr);
|
|
Status status = no_table_location->init_properties(&pool);
|
|
EXPECT_TRUE(status.is<ErrorCode::INTERNAL_ERROR>()) << status;
|
|
EXPECT_NE(std::string::npos, status.to_string().find("table_location"));
|
|
|
|
t_data_sink.iceberg_delete_sink.__set_table_location("oss://b/db/t/data/");
|
|
t_data_sink.iceberg_delete_sink.__isset.output_path = false;
|
|
t_data_sink.iceberg_delete_sink.output_path.clear();
|
|
auto no_output_path =
|
|
std::make_shared<VIcebergDeleteSink>(t_data_sink, output_exprs, nullptr, nullptr);
|
|
status = no_output_path->init_properties(&pool);
|
|
EXPECT_TRUE(status.is<ErrorCode::INTERNAL_ERROR>()) << status;
|
|
EXPECT_NE(std::string::npos, status.to_string().find("output_path"));
|
|
}
|
|
|
|
TEST_F(VIcebergDeleteSinkTest, TestUnsupportedDeleteType) {
|
|
// Create a TDataSink for an unsupported delete type
|
|
TDataSink t_eq_delete_sink;
|
|
t_eq_delete_sink.__set_type(TDataSinkType::ICEBERG_DELETE_SINK);
|
|
|
|
TIcebergDeleteSink delete_sink;
|
|
delete_sink.__set_db_name("test_db");
|
|
delete_sink.__set_tb_name("test_table");
|
|
delete_sink.__set_delete_type(TFileContent::EQUALITY_DELETES);
|
|
delete_sink.__set_file_format(TFileFormatType::FORMAT_PARQUET);
|
|
delete_sink.__set_compress_type(TFileCompressType::SNAPPYBLOCK);
|
|
delete_sink.__set_output_path("/tmp/iceberg/test");
|
|
|
|
std::map<std::string, std::string> hadoop_conf;
|
|
delete_sink.__set_hadoop_config(hadoop_conf);
|
|
|
|
t_eq_delete_sink.__set_iceberg_delete_sink(delete_sink);
|
|
|
|
VExprContextSPtrs output_exprs;
|
|
auto sink =
|
|
std::make_shared<VIcebergDeleteSink>(t_eq_delete_sink, output_exprs, nullptr, nullptr);
|
|
|
|
ObjectPool pool;
|
|
Status status = sink->init_properties(&pool);
|
|
ASSERT_FALSE(status.ok());
|
|
}
|
|
|
|
TEST_F(VIcebergDeleteSinkTest, TestValidateDeletionVectorContentSize) {
|
|
constexpr size_t max_bitmap_size = static_cast<size_t>(MAX_ICEBERG_DELETION_VECTOR_BYTES) -
|
|
ICEBERG_DELETION_VECTOR_BLOB_OVERHEAD_BYTES;
|
|
int64_t content_size = 0;
|
|
ASSERT_TRUE(
|
|
calculate_iceberg_deletion_vector_content_size(max_bitmap_size, &content_size).ok());
|
|
ASSERT_EQ(MAX_ICEBERG_DELETION_VECTOR_BYTES, content_size);
|
|
|
|
const auto unsupported_status =
|
|
calculate_iceberg_deletion_vector_content_size(max_bitmap_size + 1, &content_size);
|
|
ASSERT_TRUE(unsupported_status.is<ErrorCode::NOT_IMPLEMENTED_ERROR>()) << unsupported_status;
|
|
}
|
|
|
|
TEST_F(VIcebergDeleteSinkTest, TestWritePositionDeletesCommitsTableLocation) {
|
|
// The Parquet writer's dictionary encoder allocates from ExecEnv's Arrow pool, which this
|
|
// fixture does not otherwise initialize.
|
|
auto* env = ExecEnv::GetInstance();
|
|
auto* previous_pool = env->_arrow_memory_pool;
|
|
env->_arrow_memory_pool = arrow::default_memory_pool();
|
|
Defer restore_pool([&] { env->_arrow_memory_pool = previous_pool; });
|
|
|
|
std::filesystem::path temp_dir = std::filesystem::temp_directory_path() /
|
|
("iceberg_delete_sink_test_" + generate_uuid_string());
|
|
ASSERT_TRUE(std::filesystem::create_directories(temp_dir));
|
|
|
|
// Write locally, but record the table's original URI in the commit metadata.
|
|
const std::string table_location = "oss://bucket/warehouse/table/data/";
|
|
const std::string data_file_path = table_location + "file1.parquet";
|
|
TDataSink t_data_sink = build_local_delete_sink(temp_dir.string(), 2);
|
|
t_data_sink.iceberg_delete_sink.__set_table_location(table_location);
|
|
RuntimeState state;
|
|
VExprContextSPtrs output_exprs;
|
|
auto sink = std::make_shared<VIcebergDeleteSink>(t_data_sink, output_exprs, nullptr, nullptr);
|
|
ObjectPool pool;
|
|
ASSERT_TRUE(sink->init_properties(&pool).ok());
|
|
sink->_state = &state;
|
|
|
|
std::map<std::string, IcebergFileDeletion> file_deletions;
|
|
auto [it, inserted] =
|
|
file_deletions.emplace(data_file_path, IcebergFileDeletion(1, "[\"p=1\"]"));
|
|
ASSERT_TRUE(inserted);
|
|
it->second.rows_to_delete.add(uint32_t {10});
|
|
it->second.rows_to_delete.add(uint32_t {20});
|
|
|
|
const auto status = sink->_write_position_delete_files(file_deletions);
|
|
ASSERT_TRUE(status.ok()) << status;
|
|
ASSERT_EQ(1, sink->_commit_data_list.size());
|
|
ASSERT_EQ(1, sink->_created_files.size());
|
|
ASSERT_EQ(1, sink->_delete_file_count);
|
|
|
|
const std::filesystem::path write_path = sink->_created_files[0].second;
|
|
ASSERT_EQ(temp_dir, write_path.parent_path());
|
|
ASSERT_TRUE(std::filesystem::exists(write_path));
|
|
const auto& commit_data = sink->_commit_data_list[0];
|
|
EXPECT_EQ(table_location + write_path.filename().string(), commit_data.file_path);
|
|
EXPECT_EQ(data_file_path, commit_data.referenced_data_file_path);
|
|
EXPECT_EQ(TFileContent::POSITION_DELETES, commit_data.file_content);
|
|
EXPECT_EQ(2, commit_data.row_count);
|
|
EXPECT_EQ(2, commit_data.affected_rows);
|
|
EXPECT_EQ(1, commit_data.partition_spec_id);
|
|
EXPECT_EQ("[\"p=1\"]", commit_data.partition_data_json);
|
|
EXPECT_EQ(std::filesystem::file_size(write_path), commit_data.file_size);
|
|
|
|
// The referenced data-file URI inside the position-delete file must stay unchanged too.
|
|
auto input = arrow::io::ReadableFile::Open(write_path.string());
|
|
ASSERT_TRUE(input.ok()) << input.status();
|
|
auto reader = ::parquet::arrow::OpenFile(*input, arrow::default_memory_pool());
|
|
ASSERT_TRUE(reader.ok()) << reader.status();
|
|
auto table = (*reader)->ReadTable();
|
|
ASSERT_TRUE(table.ok()) << table.status();
|
|
ASSERT_EQ(2, (*table)->num_rows());
|
|
ASSERT_EQ(2, (*table)->num_columns());
|
|
for (int64_t row = 0; row < 2; ++row) {
|
|
auto file_path = (*table)->column(0)->GetScalar(row);
|
|
ASSERT_TRUE(file_path.ok()) << file_path.status();
|
|
EXPECT_EQ(data_file_path, (*file_path)->ToString());
|
|
auto position = (*table)->column(1)->GetScalar(row);
|
|
ASSERT_TRUE(position.ok()) << position.status();
|
|
EXPECT_EQ(std::to_string((row + 1) * 10), (*position)->ToString());
|
|
}
|
|
|
|
std::filesystem::remove_all(temp_dir);
|
|
}
|
|
|
|
TEST_F(VIcebergDeleteSinkTest, TestWriteDeletionVectorsToSingleSharedPuffin) {
|
|
std::filesystem::path temp_dir = std::filesystem::temp_directory_path() /
|
|
("iceberg_delete_sink_test_" + generate_uuid_string());
|
|
ASSERT_TRUE(std::filesystem::create_directories(temp_dir));
|
|
|
|
const std::string table_location =
|
|
"abfss://container@account.dfs.core.windows.net/warehouse/table/data";
|
|
const std::string first_data_file = table_location + "/file1.parquet";
|
|
const std::string second_data_file = table_location + "/file2.parquet";
|
|
TDataSink t_data_sink = build_local_delete_sink(temp_dir.string() + "/", 3);
|
|
t_data_sink.iceberg_delete_sink.__set_table_location(table_location);
|
|
VExprContextSPtrs output_exprs;
|
|
auto sink = std::make_shared<VIcebergDeleteSink>(t_data_sink, output_exprs, nullptr, nullptr);
|
|
ObjectPool pool;
|
|
ASSERT_TRUE(sink->init_properties(&pool).ok());
|
|
|
|
std::map<std::string, IcebergFileDeletion> file_deletions;
|
|
auto [file1_it, file1_inserted] =
|
|
file_deletions.emplace(first_data_file, IcebergFileDeletion(1, "[\"p=1\"]"));
|
|
ASSERT_TRUE(file1_inserted);
|
|
file1_it->second.rows_to_delete.add((uint32_t)10);
|
|
file1_it->second.rows_to_delete.add((uint32_t)20);
|
|
|
|
auto [file2_it, file2_inserted] =
|
|
file_deletions.emplace(second_data_file, IcebergFileDeletion(2, "[\"p=2\"]"));
|
|
ASSERT_TRUE(file2_inserted);
|
|
file2_it->second.rows_to_delete.add((uint32_t)30);
|
|
|
|
const auto status = sink->_write_deletion_vector_files(file_deletions);
|
|
ASSERT_TRUE(status.ok()) << status;
|
|
ASSERT_EQ(2, sink->_commit_data_list.size());
|
|
ASSERT_EQ(2, sink->_delete_file_count);
|
|
ASSERT_EQ(1, sink->_created_files.size());
|
|
|
|
const std::filesystem::path write_path = sink->_created_files[0].second;
|
|
ASSERT_EQ(temp_dir, write_path.parent_path());
|
|
ASSERT_TRUE(std::filesystem::exists(write_path));
|
|
|
|
const auto& first_commit = sink->_commit_data_list[0];
|
|
const auto& second_commit = sink->_commit_data_list[1];
|
|
EXPECT_EQ(table_location + "/" + write_path.filename().string(), first_commit.file_path);
|
|
ASSERT_EQ(first_commit.file_path, second_commit.file_path);
|
|
EXPECT_EQ(first_data_file, first_commit.referenced_data_file_path);
|
|
EXPECT_EQ(second_data_file, second_commit.referenced_data_file_path);
|
|
EXPECT_EQ(TFileContent::DELETION_VECTOR, first_commit.file_content);
|
|
EXPECT_EQ(TFileContent::DELETION_VECTOR, second_commit.file_content);
|
|
EXPECT_EQ(1, first_commit.partition_spec_id);
|
|
EXPECT_EQ(2, second_commit.partition_spec_id);
|
|
EXPECT_EQ("[\"p=1\"]", first_commit.partition_data_json);
|
|
EXPECT_EQ("[\"p=2\"]", second_commit.partition_data_json);
|
|
ASSERT_EQ(first_commit.file_size, second_commit.file_size);
|
|
ASSERT_LT(first_commit.content_offset, second_commit.content_offset);
|
|
|
|
size_t puffin_file_count = 0;
|
|
for (const auto& entry : std::filesystem::directory_iterator(temp_dir)) {
|
|
if (entry.path().extension() == ".puffin") {
|
|
++puffin_file_count;
|
|
}
|
|
}
|
|
ASSERT_EQ(1, puffin_file_count);
|
|
|
|
std::ifstream input(write_path, std::ios::binary);
|
|
ASSERT_TRUE(input.good());
|
|
std::string file_bytes((std::istreambuf_iterator<char>(input)),
|
|
std::istreambuf_iterator<char>());
|
|
ASSERT_EQ(static_cast<size_t>(first_commit.file_size), file_bytes.size());
|
|
ASSERT_GE(file_bytes.size(), 16);
|
|
ASSERT_EQ("PFA1", std::string(file_bytes.data(), 4));
|
|
ASSERT_EQ("PFA1", std::string(file_bytes.data() + file_bytes.size() - 4, 4));
|
|
|
|
uint32_t footer_size = LittleEndian::Load32(file_bytes.data() + file_bytes.size() - 12);
|
|
size_t footer_start = file_bytes.size() - 12 - footer_size;
|
|
std::string footer_json = file_bytes.substr(footer_start, footer_size);
|
|
|
|
rapidjson::Document footer_doc;
|
|
footer_doc.Parse(footer_json.c_str(), footer_json.size());
|
|
ASSERT_FALSE(footer_doc.HasParseError());
|
|
ASSERT_TRUE(footer_doc.HasMember("blobs"));
|
|
ASSERT_TRUE(footer_doc["blobs"].IsArray());
|
|
ASSERT_EQ(2u, footer_doc["blobs"].Size());
|
|
|
|
std::map<std::string, const TIcebergCommitData*> commit_data_by_file;
|
|
commit_data_by_file.emplace(first_commit.referenced_data_file_path, &first_commit);
|
|
commit_data_by_file.emplace(second_commit.referenced_data_file_path, &second_commit);
|
|
|
|
for (const auto& blob : footer_doc["blobs"].GetArray()) {
|
|
ASSERT_TRUE(blob.IsObject());
|
|
ASSERT_TRUE(blob.HasMember("properties"));
|
|
ASSERT_TRUE(blob["properties"].IsObject());
|
|
const auto& properties = blob["properties"];
|
|
ASSERT_TRUE(properties.HasMember("referenced-data-file"));
|
|
ASSERT_TRUE(properties.HasMember("cardinality"));
|
|
|
|
std::string referenced_data_file = properties["referenced-data-file"].GetString();
|
|
auto commit_it = commit_data_by_file.find(referenced_data_file);
|
|
ASSERT_NE(commit_data_by_file.end(), commit_it);
|
|
|
|
const auto* commit_data = commit_it->second;
|
|
ASSERT_EQ(commit_data->content_offset, blob["offset"].GetInt64());
|
|
ASSERT_EQ(commit_data->content_size_in_bytes, blob["length"].GetInt64());
|
|
ASSERT_EQ(std::to_string(commit_data->row_count), properties["cardinality"].GetString());
|
|
}
|
|
|
|
std::filesystem::remove_all(temp_dir);
|
|
}
|
|
|
|
} // namespace doris
|