This is an automated email from the ASF dual-hosted git repository.

morningman 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 b78c656c0cb [fix](iceberg) Commit every Iceberg file under the table's 
own location (#69392)
b78c656c0cb is described below

commit b78c656c0cbcc255b28509c5dc79a9d9d4b282f2
Author: Chenjunwei <[email protected]>
AuthorDate: Wed Nov 4 09:59:11 2026 +0800

    [fix](iceberg) Commit every Iceberg file under the table's own location 
(#69392)
    
    ### 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.
---
 be/src/exec/sink/viceberg_delete_sink.cpp          |  75 ++++-----
 be/src/exec/sink/viceberg_delete_sink.h            |  16 +-
 .../sink/writer/iceberg/iceberg_output_location.h  |  81 ++++++++++
 .../writer/iceberg/viceberg_delete_file_writer.cpp |  17 ++-
 .../writer/iceberg/viceberg_delete_file_writer.h   |  18 ++-
 .../writer/iceberg/viceberg_partition_writer.cpp   |   5 +-
 .../sink/writer/iceberg/viceberg_table_writer.cpp  |  48 +++---
 .../sink/writer/iceberg/viceberg_table_writer.h    |   5 +
 .../sink/writer/iceberg/vpartition_writer_base.h   |   7 +-
 be/test/exec/sink/viceberg_delete_sink_test.cpp    | 167 ++++++++++++++++++---
 be/test/exec/sink/viceberg_merge_sink_test.cpp     |  38 +++++
 .../iceberg/iceberg_output_location_test.cpp       |  56 +++++++
 .../iceberg/iceberg_partition_writer_test.cpp      |  27 +++-
 .../writer/iceberg/viceberg_table_writer_test.cpp  |  60 ++++++++
 .../format/transformer/vparquet_writer_test.cpp    |   4 +-
 .../iceberg/IcebergConnectorTransaction.java       |  40 +++++
 .../iceberg/IcebergWritePlanProvider.java          |  43 +++++-
 .../iceberg/IcebergConnectorTransactionTest.java   |  72 ++++++++-
 .../iceberg/IcebergWriteKnownBugsTest.java         |   8 +-
 .../iceberg/IcebergWritePlanProviderTest.java      |  66 ++++++++
 .../IcebergWriteTransactionCoverageTest.java       |   9 +-
 .../java/org/apache/doris/qe/QeProcessorImpl.java  |  14 +-
 .../doris/qe/QeProcessorImplReportAckTest.java     |  27 +++-
 gensrc/thrift/DataSinks.thrift                     |  17 +++
 .../dml/test_iceberg_update_delete_basic.out.org   |  77 ----------
 .../dml/test_iceberg_update_delete_basic.groovy    |  16 +-
 .../iceberg/write/ICEBERG_WRITE_P0_COVERAGE.md     |   5 +-
 .../write/test_iceberg_write_file_locations.groovy | 161 ++++++++++++++++++++
 28 files changed, 956 insertions(+), 223 deletions(-)

diff --git a/be/src/exec/sink/viceberg_delete_sink.cpp 
b/be/src/exec/sink/viceberg_delete_sink.cpp
index 55b732d6527..3cb11884898 100644
--- a/be/src/exec/sink/viceberg_delete_sink.cpp
+++ b/be/src/exec/sink/viceberg_delete_sink.cpp
@@ -148,14 +148,16 @@ Status VIcebergDeleteSink::init_properties(ObjectPool* 
pool) {
         _compress_type = delete_sink.compress_type;
     }
 
-    // Get output path and table location
-    if (delete_sink.__isset.output_path) {
-        _output_path = delete_sink.output_path;
-    }
-
-    if (delete_sink.__isset.table_location) {
-        _table_location = delete_sink.table_location;
+    // Every FE version sends both forms of the data location (see 
IcebergOutputLocation). Without
+    // the table's own form the commit could only record the address BE wrote 
the file at, e.g.
+    // s3://b/db/t/data/delete_pos_x.parquet for an oss:// table, which other 
engines resolve with
+    // the wrong file system.
+    if (delete_sink.output_path.empty() || delete_sink.table_location.empty()) 
{
+        return Status::InternalError(
+                "Iceberg delete sink must carry output_path and 
table_location, got '{}' and '{}'",
+                delete_sink.output_path, delete_sink.table_location);
     }
+    _output_location.emplace(delete_sink.output_path, 
delete_sink.table_location);
 
     // Get Hadoop configuration
     if (delete_sink.__isset.hadoop_config) {
@@ -222,7 +224,7 @@ Status VIcebergDeleteSink::open(RuntimeState* state, 
RuntimeProfile* profile) {
 
     LOG(INFO) << fmt::format(
             "VIcebergDeleteSink opened: delete_type={}, output_path={}, 
format_version={}",
-            to_string(_delete_type), _output_path, _format_version);
+            to_string(_delete_type), _t_sink.iceberg_delete_sink.output_path, 
_format_version);
 
     return Status::OK();
 }
@@ -481,18 +483,16 @@ Status VIcebergDeleteSink::_collect_position_deletes(
 
 Status VIcebergDeleteSink::_write_position_delete_files(
         const std::map<std::string, IcebergFileDeletion>& file_deletions) {
+    DORIS_CHECK(_output_location.has_value());
     constexpr size_t kBatchSize = 4096;
     for (const auto& [data_file_path, deletion] : file_deletions) {
         if (deletion.rows_to_delete.isEmpty()) {
             continue;
         }
-        // Generate unique delete file path
-        std::string delete_file_path = 
_generate_delete_file_path(data_file_path);
-
         // Create delete file writer
         auto writer = VIcebergDeleteFileWriterFactory::create_writer(
-                TFileContent::POSITION_DELETES, delete_file_path, 
_file_format_type,
-                _compress_type);
+                TFileContent::POSITION_DELETES, *_output_location,
+                _delete_file_name(data_file_path), _file_format_type, 
_compress_type);
 
         // Build column names for position delete
         std::vector<std::string> column_names = {"file_path", "pos"};
@@ -506,7 +506,7 @@ Status VIcebergDeleteSink::_write_position_delete_files(
                 writer->open(_state, _state->runtime_profile(), 
_position_delete_output_expr_ctxs,
                              column_names, _hadoop_conf, _file_type, 
_broker_addresses);
         if (writer->file_system() != nullptr) {
-            _created_files.emplace_back(writer->file_system(), 
delete_file_path);
+            _created_files.emplace_back(writer->file_system(), 
writer->write_path());
         }
         RETURN_IF_ERROR(open_status);
 
@@ -543,7 +543,7 @@ Status VIcebergDeleteSink::_write_position_delete_files(
         _delete_file_count++;
 
         VLOG(1) << fmt::format("Written position delete file: path={}, 
rows={}, referenced_file={}",
-                               delete_file_path, commit_data.row_count, 
data_file_path);
+                               writer->write_path(), commit_data.row_count, 
data_file_path);
     }
 
     return Status::OK();
@@ -643,6 +643,7 @@ std::string VIcebergDeleteSink::_get_file_extension() const 
{
 
 Status VIcebergDeleteSink::_write_deletion_vector_files(
         const std::map<std::string, IcebergFileDeletion>& file_deletions) {
+    DORIS_CHECK(_output_location.has_value());
     std::vector<DeletionVectorBlob> blobs;
     for (const auto& [data_file_path, deletion] : file_deletions) {
         if (deletion.rows_to_delete.isEmpty()) {
@@ -687,13 +688,15 @@ Status VIcebergDeleteSink::_write_deletion_vector_files(
         return Status::OK();
     }
 
-    std::string puffin_path = _generate_puffin_file_path();
+    const std::string puffin_file_name = _puffin_file_name();
     int64_t puffin_file_size = 0;
-    RETURN_IF_ERROR(_write_puffin_file(puffin_path, &blobs, 
&puffin_file_size));
+    
RETURN_IF_ERROR(_write_puffin_file(_output_location->write_path(puffin_file_name),
 &blobs,
+                                       &puffin_file_size));
+    const std::string commit_path = 
_output_location->commit_path(puffin_file_name);
 
     for (const auto& blob : blobs) {
         TIcebergCommitData commit_data;
-        commit_data.__set_file_path(puffin_path);
+        commit_data.__set_file_path(commit_path);
         commit_data.__set_row_count(blob.merged_count);
         commit_data.__set_affected_rows(blob.delete_count);
         commit_data.__set_file_size(puffin_file_size);
@@ -803,37 +806,15 @@ std::string VIcebergDeleteSink::_build_puffin_footer_json(
     return {buffer.GetString(), buffer.GetSize()};
 }
 
-std::string VIcebergDeleteSink::_generate_delete_file_path(
-        const std::string& referenced_data_file) {
-    // Generate unique delete file name using UUID
-    std::string uuid = generate_uuid_string();
-    std::string file_name;
-
-    std::string file_extension = _get_file_extension();
-    file_name =
-            fmt::format("delete_pos_{}_{}{}", uuid,
-                        std::hash<std::string> {}(referenced_data_file) % 
10000000, file_extension);
-
-    // Combine with output path or table location
-    std::string base_path = _output_path.empty() ? _table_location : 
_output_path;
-
-    // Ensure base path ends with /
-    if (!base_path.empty() && base_path.back() != '/') {
-        base_path += '/';
-    }
-
-    // Delete files are data files in Iceberg, write under data location
-    return fmt::format("{}{}", base_path, file_name);
+std::string VIcebergDeleteSink::_delete_file_name(const std::string& 
referenced_data_file) const {
+    // Delete files live in the data location itself, next to the partition 
directories.
+    return fmt::format("delete_pos_{}_{}{}", generate_uuid_string(),
+                       std::hash<std::string> {}(referenced_data_file) % 
10000000,
+                       _get_file_extension());
 }
 
-std::string VIcebergDeleteSink::_generate_puffin_file_path() {
-    std::string uuid = generate_uuid_string();
-    std::string file_name = fmt::format("delete_dv_{}.puffin", uuid);
-    std::string base_path = _output_path.empty() ? _table_location : 
_output_path;
-    if (!base_path.empty() && base_path.back() != '/') {
-        base_path += '/';
-    }
-    return fmt::format("{}{}", base_path, file_name);
+std::string VIcebergDeleteSink::_puffin_file_name() const {
+    return fmt::format("delete_dv_{}.puffin", generate_uuid_string());
 }
 
 } // namespace doris
diff --git a/be/src/exec/sink/viceberg_delete_sink.h 
b/be/src/exec/sink/viceberg_delete_sink.h
index 625134ba3a3..a4bb50d20f7 100644
--- a/be/src/exec/sink/viceberg_delete_sink.h
+++ b/be/src/exec/sink/viceberg_delete_sink.h
@@ -24,12 +24,14 @@
 #include <cstdint>
 #include <map>
 #include <memory>
+#include <optional>
 #include <string>
 #include <vector>
 
 #include "common/status.h"
 #include "core/block/block.h"
 #include "exec/sink/writer/async_result_writer.h"
+#include "exec/sink/writer/iceberg/iceberg_output_location.h"
 #include "exec/sink/writer/iceberg/viceberg_delete_file_writer.h"
 #include "exprs/vexpr_fwd.h"
 #include "roaring/roaring64map.hh"
@@ -114,12 +116,9 @@ private:
 
     std::string _build_puffin_footer_json(const 
std::vector<DeletionVectorBlob>& blobs);
 
-    std::string _generate_puffin_file_path();
-
-    /**
-     * Generate unique delete file path
-     */
-    std::string _generate_delete_file_path(const std::string& 
referenced_data_file = "");
+    // Unique file names, relative to the data location (_output_location).
+    std::string _puffin_file_name() const;
+    std::string _delete_file_name(const std::string& referenced_data_file) 
const;
 
     /**
      * Get $row_id column index from block
@@ -163,9 +162,8 @@ private:
     TFileFormatType::type _file_format_type = TFileFormatType::FORMAT_PARQUET;
     TFileCompressType::type _compress_type = TFileCompressType::SNAPPYBLOCK;
 
-    // Output directory for delete files
-    std::string _output_path;
-    std::string _table_location;
+    // The data location delete files are written to and committed under, set 
by init_properties().
+    std::optional<IcebergOutputLocation> _output_location;
 
     TFileType::type _file_type = TFileType::FILE_HDFS;
     std::vector<TNetworkAddress> _broker_addresses;
diff --git a/be/src/exec/sink/writer/iceberg/iceberg_output_location.h 
b/be/src/exec/sink/writer/iceberg/iceberg_output_location.h
new file mode 100644
index 00000000000..58982f73156
--- /dev/null
+++ b/be/src/exec/sink/writer/iceberg/iceberg_output_location.h
@@ -0,0 +1,81 @@
+// 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 <string>
+#include <string_view>
+#include <utility>
+
+#include "common/check.h"
+
+namespace doris {
+
+// The directory an Iceberg writer puts its files in, in both forms of the 
same location. BE
+// creates, writes and deletes a file under write_dir, the form BE addresses 
the storage with.
+// Iceberg metadata records the file under commit_dir, the table's own form, 
which every engine
+// reading the table resolves. FE sends both: TIcebergTableSink output_path / 
original_output_path
+// and TIcebergDeleteSink output_path / table_location. Example: an OSS table 
with data location
+// oss://b/db/t/data reaches BE as write_dir s3://b/db/t/data and commit_dir 
oss://b/db/t/data, so
+// delete_pos_1.parquet is written to s3://b/db/t/data/delete_pos_1.parquet 
and committed as
+// oss://b/db/t/data/delete_pos_1.parquet.
+//
+// Every file a writer reports takes both of its paths from one 
IcebergOutputLocation, so it
+// cannot commit the form it wrote: delete files used to be committed as 
s3://..., and other
+// engines resolved them with a file system the table is not on.
+class IcebergOutputLocation {
+public:
+    IcebergOutputLocation(std::string write_dir, std::string commit_dir)
+            : _write_dir(std::move(write_dir)), 
_commit_dir(std::move(commit_dir)) {
+        DORIS_CHECK(!_write_dir.empty());
+        DORIS_CHECK(!_commit_dir.empty());
+    }
+
+    // The same subdirectory in both forms, e.g. the partition directory 
"p=1/q=2".
+    IcebergOutputLocation subdirectory(std::string_view relative_dir) const {
+        DORIS_CHECK(!relative_dir.empty());
+        return {_join(_write_dir, relative_dir), _join(_commit_dir, 
relative_dir)};
+    }
+
+    // The path BE writes file_name at.
+    std::string write_path(std::string_view file_name) const {
+        return _join(_write_dir, file_name);
+    }
+
+    // The path Iceberg metadata records file_name at.
+    std::string commit_path(std::string_view file_name) const {
+        return _join(_commit_dir, file_name);
+    }
+
+private:
+    // FE takes write.data.path verbatim, so a directory may end with '/'. 
Both forms join with
+    // exactly one separator, as Iceberg's own location provider does: 
oss://b/t/data and
+    // oss://b/t/data/ both join "p=1" into oss://b/t/data/p=1.
+    static std::string _join(const std::string& dir, std::string_view name) {
+        std::string path = dir;
+        if (path.back() != '/') {
+            path.push_back('/');
+        }
+        path.append(name);
+        return path;
+    }
+
+    std::string _write_dir;
+    std::string _commit_dir;
+};
+
+} // namespace doris
diff --git a/be/src/exec/sink/writer/iceberg/viceberg_delete_file_writer.cpp 
b/be/src/exec/sink/writer/iceberg/viceberg_delete_file_writer.cpp
index 7f7f2c6f2f4..1233d09dae5 100644
--- a/be/src/exec/sink/writer/iceberg/viceberg_delete_file_writer.cpp
+++ b/be/src/exec/sink/writer/iceberg/viceberg_delete_file_writer.cpp
@@ -44,11 +44,13 @@ std::unique_ptr<iceberg::Schema> 
build_position_delete_schema() {
 }
 
 VIcebergDeleteFileWriter::VIcebergDeleteFileWriter(TFileContent::type 
delete_type,
-                                                   const std::string& 
output_path,
+                                                   const 
IcebergOutputLocation& location,
+                                                   const std::string& 
file_name,
                                                    TFileFormatType::type 
file_format,
                                                    TFileCompressType::type 
compress_type)
         : _delete_type(delete_type),
-          _output_path(output_path),
+          _write_path(location.write_path(file_name)),
+          _commit_path(location.commit_path(file_name)),
           _file_format(file_format),
           _compress_type(compress_type) {}
 
@@ -74,7 +76,7 @@ Status VIcebergDeleteFileWriter::open(RuntimeState* state, 
RuntimeProfile* profi
         fs_properties.broker_addresses = &broker_addresses;
     }
 
-    io::FileDescription file_description = {.path = _output_path, .fs_name {}};
+    io::FileDescription file_description = {.path = _write_path, .fs_name {}};
     _fs = DORIS_TRY(FileFactory::create_fs(fs_properties, file_description));
 
     io::FileWriterOptions file_writer_options = {.used_by_s3_committer = 
false};
@@ -131,7 +133,7 @@ Status VIcebergDeleteFileWriter::close(TIcebergCommitData& 
commit_data) {
     }
 
     // Fill commit data (use __set_ to mark optional fields as present)
-    commit_data.__set_file_path(_output_path);
+    commit_data.__set_file_path(_commit_path);
     commit_data.__set_row_count(_written_rows);
     commit_data.__set_affected_rows(_written_rows);
     commit_data.__set_file_size(_file_size);
@@ -152,9 +154,10 @@ Status VIcebergDeleteFileWriter::close(TIcebergCommitData& 
commit_data) {
 
 // Factory method
 std::unique_ptr<VIcebergDeleteFileWriter> 
VIcebergDeleteFileWriterFactory::create_writer(
-        TFileContent::type delete_type, const std::string& output_path,
-        TFileFormatType::type file_format, TFileCompressType::type 
compress_type) {
-    return std::make_unique<VIcebergDeleteFileWriter>(delete_type, 
output_path, file_format,
+        TFileContent::type delete_type, const IcebergOutputLocation& location,
+        const std::string& file_name, TFileFormatType::type file_format,
+        TFileCompressType::type compress_type) {
+    return std::make_unique<VIcebergDeleteFileWriter>(delete_type, location, 
file_name, file_format,
                                                       compress_type);
 }
 
diff --git a/be/src/exec/sink/writer/iceberg/viceberg_delete_file_writer.h 
b/be/src/exec/sink/writer/iceberg/viceberg_delete_file_writer.h
index 4ceb7080a3c..b216b3ba16f 100644
--- a/be/src/exec/sink/writer/iceberg/viceberg_delete_file_writer.h
+++ b/be/src/exec/sink/writer/iceberg/viceberg_delete_file_writer.h
@@ -23,6 +23,7 @@
 
 #include "common/status.h"
 #include "core/block/block.h"
+#include "exec/sink/writer/iceberg/iceberg_output_location.h"
 #include "exprs/vexpr_fwd.h"
 #include "gen_cpp/DataSinks_types.h"
 #include "io/fs/file_writer.h"
@@ -50,8 +51,10 @@ class VFileFormatTransformer;
  */
 class VIcebergDeleteFileWriter {
 public:
-    VIcebergDeleteFileWriter(TFileContent::type delete_type, const 
std::string& output_path,
-                             TFileFormatType::type file_format,
+    // Writes file_name under location; the commit data reports the file under 
the table's own form
+    // of that directory, not the form it was written at.
+    VIcebergDeleteFileWriter(TFileContent::type delete_type, const 
IcebergOutputLocation& location,
+                             const std::string& file_name, 
TFileFormatType::type file_format,
                              TFileCompressType::type compress_type);
 
     ~VIcebergDeleteFileWriter();
@@ -96,9 +99,13 @@ public:
 
     std::shared_ptr<io::FileSystem> file_system() const { return _fs; }
 
+    // The path the file is written at, which is what cleanup must delete.
+    const std::string& write_path() const { return _write_path; }
+
 private:
     TFileContent::type _delete_type;
-    std::string _output_path;
+    std::string _write_path;
+    std::string _commit_path;
     TFileFormatType::type _file_format;
     TFileCompressType::type _compress_type = TFileCompressType::SNAPPYBLOCK;
 
@@ -122,8 +129,9 @@ private:
 class VIcebergDeleteFileWriterFactory {
 public:
     static std::unique_ptr<VIcebergDeleteFileWriter> create_writer(
-            TFileContent::type delete_type, const std::string& output_path,
-            TFileFormatType::type file_format, TFileCompressType::type 
compress_type);
+            TFileContent::type delete_type, const IcebergOutputLocation& 
location,
+            const std::string& file_name, TFileFormatType::type file_format,
+            TFileCompressType::type compress_type);
 };
 
 } // namespace doris
diff --git a/be/src/exec/sink/writer/iceberg/viceberg_partition_writer.cpp 
b/be/src/exec/sink/writer/iceberg/viceberg_partition_writer.cpp
index 363776dcf17..249cd15dcf1 100644
--- a/be/src/exec/sink/writer/iceberg/viceberg_partition_writer.cpp
+++ b/be/src/exec/sink/writer/iceberg/viceberg_partition_writer.cpp
@@ -93,7 +93,7 @@ Status VIcebergPartitionWriter::open(RuntimeState* state, 
RuntimeProfile* profil
     if (!_write_info.broker_addresses.empty()) {
         fs_properties.broker_addresses = &(_write_info.broker_addresses);
     }
-    _path = fmt::format("{}/{}", _write_info.write_path, 
_get_target_file_name());
+    _path = _write_info.location.write_path(_get_target_file_name());
     io::FileDescription file_description = {.path = _path, .fs_name {}};
     _fs = DORIS_TRY(FileFactory::create_fs(fs_properties, file_description));
     io::FileWriterOptions file_writer_options = {.used_by_s3_committer = 
false};
@@ -210,8 +210,7 @@ Status 
VIcebergPartitionWriter::_build_iceberg_commit_data(TIcebergCommitData* c
     DCHECK(commit_data != nullptr);
     DCHECK(_file_format_transformer != nullptr);
 
-    commit_data->__set_file_path(
-            fmt::format("{}/{}", _write_info.original_write_path, 
_get_target_file_name()));
+    
commit_data->__set_file_path(_write_info.location.commit_path(_get_target_file_name()));
     commit_data->__set_row_count(_row_count);
     commit_data->__set_affected_rows(_row_count);
     commit_data->__set_file_size(_file_format_transformer->written_len());
diff --git a/be/src/exec/sink/writer/iceberg/viceberg_table_writer.cpp 
b/be/src/exec/sink/writer/iceberg/viceberg_table_writer.cpp
index 61cb105460d..d20eb8a005f 100644
--- a/be/src/exec/sink/writer/iceberg/viceberg_table_writer.cpp
+++ b/be/src/exec/sink/writer/iceberg/viceberg_table_writer.cpp
@@ -61,6 +61,18 @@ Status VIcebergTableWriter::open(RuntimeState* state, 
RuntimeProfile* profile) {
 
     RETURN_IF_ERROR(validate_external_file_report_ack(state->query_options()));
 
+    // Every FE version sends both forms of the data location (see 
IcebergOutputLocation). A plan
+    // without the table's own form leaves no path to commit the files under: 
the commit used to
+    // record a relative path such as /p=1/xxx.parquet that no engine can open.
+    const auto& iceberg_sink = _t_sink.iceberg_table_sink;
+    if (iceberg_sink.output_path.empty() || 
iceberg_sink.original_output_path.empty()) {
+        return Status::InternalError(
+                "Iceberg table sink must carry output_path and 
original_output_path, got '{}' "
+                "and '{}'",
+                iceberg_sink.output_path, iceberg_sink.original_output_path);
+    }
+    _output_location.emplace(iceberg_sink.output_path, 
iceberg_sink.original_output_path);
+
     // Get target file size from query options
     // If value is 0 or not set, use config::iceberg_sink_max_file_size
     _target_file_size_bytes = config::iceberg_sink_max_file_size;
@@ -822,47 +834,29 @@ std::vector<std::string> 
VIcebergTableWriter::_partition_values(
 
 std::shared_ptr<IPartitionWriterBase> 
VIcebergTableWriter::_create_partition_writer(
         Block* transformed_block, int position, const std::string* file_name, 
int file_name_index) {
+    DORIS_CHECK(_output_location.has_value());
     auto& iceberg_table_sink = _t_sink.iceberg_table_sink;
     std::vector<std::string> partition_values;
-    const std::string& output_path = iceberg_table_sink.output_path;
-    std::string write_path;
-    std::string original_write_path;
-    std::string target_path;
+    // Relative to the data location, e.g. "year=2023/month=12"; empty when 
the file goes to the
+    // data location itself.
+    std::string partition_path;
 
     // Case 1: Full static partition - use pre-computed static partition path 
and values
     if (_is_full_static_partition) {
         partition_values = _static_partition_value_list;
-        if (!_static_partition_path.empty()) {
-            original_write_path = fmt::format("{}/{}", 
iceberg_table_sink.original_output_path,
-                                              _static_partition_path);
-            target_path = fmt::format("{}/{}", output_path, 
_static_partition_path);
-            write_path = fmt::format("{}/{}", output_path, 
_static_partition_path);
-        } else {
-            original_write_path = iceberg_table_sink.original_output_path;
-            target_path = output_path;
-            write_path = output_path;
-        }
+        partition_path = _static_partition_path;
     } else if (transformed_block != nullptr) {
         // Case 2: Dynamic partition or Hybrid mode (partial static + partial 
dynamic)
         // _partition_to_path and _partition_values already handle hybrid mode 
internally
         IcebergPartitionData partition_data = 
_get_partition_data(transformed_block, position);
-        std::string partition_path = _partition_to_path(partition_data);
+        partition_path = _partition_to_path(partition_data);
         partition_values = _partition_values(partition_data);
-        original_write_path =
-                fmt::format("{}/{}", iceberg_table_sink.original_output_path, 
partition_path);
-        target_path = fmt::format("{}/{}", output_path, partition_path);
-        write_path = fmt::format("{}/{}", output_path, partition_path);
-    } else {
-        // Case 3: Non-partitioned table
-        original_write_path = iceberg_table_sink.original_output_path;
-        target_path = output_path;
-        write_path = output_path;
     }
+    // Case 3: Non-partitioned table, written to the data location itself
 
     IPartitionWriterBase::WriteInfo write_info = {
-            .write_path = std::move(write_path),
-            .original_write_path = std::move(original_write_path),
-            .target_path = std::move(target_path),
+            .location = partition_path.empty() ? *_output_location
+                                               : 
_output_location->subdirectory(partition_path),
             .file_type = iceberg_table_sink.file_type,
             .broker_addresses = {}};
     if (iceberg_table_sink.__isset.broker_addresses) {
diff --git a/be/src/exec/sink/writer/iceberg/viceberg_table_writer.h 
b/be/src/exec/sink/writer/iceberg/viceberg_table_writer.h
index 36eb5c08aa0..5c9a8dddb9f 100644
--- a/be/src/exec/sink/writer/iceberg/viceberg_table_writer.h
+++ b/be/src/exec/sink/writer/iceberg/viceberg_table_writer.h
@@ -20,10 +20,13 @@
 #include <gen_cpp/DataSinks_types.h>
 #include <gtest/gtest_prod.h>
 
+#include <optional>
+
 #include "common/atomic_shared_ptr.h"
 #include "common/status.h"
 #include "core/block/block.h"
 #include "core/column/column.h"
+#include "exec/sink/writer/iceberg/iceberg_output_location.h"
 #include "exec/sink/writer/iceberg/partition_data.h"
 #include "exec/sink/writer/iceberg/partition_transformers.h"
 #include "exprs/vexpr_fwd.h"
@@ -164,6 +167,8 @@ private:
     TDataSink _t_sink;
     RuntimeState* _state = nullptr;
     RuntimeProfile* _operator_profile = nullptr;
+    // The table's data location, set by open(); partition writers write under 
its subdirectories.
+    std::optional<IcebergOutputLocation> _output_location;
 
     // Target file size in bytes for controlling when to split files
     int64_t _target_file_size_bytes = 0;
diff --git a/be/src/exec/sink/writer/iceberg/vpartition_writer_base.h 
b/be/src/exec/sink/writer/iceberg/vpartition_writer_base.h
index dc70f646795..0c1980ca252 100644
--- a/be/src/exec/sink/writer/iceberg/vpartition_writer_base.h
+++ b/be/src/exec/sink/writer/iceberg/vpartition_writer_base.h
@@ -20,6 +20,7 @@
 #include <string>
 
 #include "common/status.h"
+#include "exec/sink/writer/iceberg/iceberg_output_location.h"
 #include "runtime/descriptors.h"
 namespace doris {
 
@@ -34,9 +35,9 @@ class RuntimeProfile;
 class IPartitionWriterBase {
 public:
     struct WriteInfo {
-        std::string write_path;
-        std::string original_write_path;
-        std::string target_path;
+        // The directory of the partition this writer writes, in the form BE 
writes the file in
+        // and the form the commit records it in.
+        IcebergOutputLocation location;
         TFileType::type file_type;
         std::vector<TNetworkAddress> broker_addresses;
     };
diff --git a/be/test/exec/sink/viceberg_delete_sink_test.cpp 
b/be/test/exec/sink/viceberg_delete_sink_test.cpp
index e2897125e49..2eaa762350b 100644
--- a/be/test/exec/sink/viceberg_delete_sink_test.cpp
+++ b/be/test/exec/sink/viceberg_delete_sink_test.cpp
@@ -17,7 +17,10 @@
 
 #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>
@@ -36,7 +39,9 @@
 #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 {
@@ -471,26 +476,57 @@ TEST_F(VIcebergDeleteSinkTest, 
TestBuildPositionDeleteBlock) {
     ASSERT_EQ(40, pos_column->get_element(3));
 }
 
-TEST_F(VIcebergDeleteSinkTest, TestGenerateDeleteFilePath) {
+TEST_F(VIcebergDeleteSinkTest, TestDeleteFileNames) {
     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());
+    ASSERT_TRUE(sink->init_properties(&pool).ok());
 
-    std::string data_file_path = "data/file1.parquet";
-    std::string delete_file_path = 
sink->_generate_delete_file_path(data_file_path);
+    // 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('/'));
+}
 
-    // Verify the path format
-    ASSERT_FALSE(delete_file_path.empty());
-    const auto& delete_sink = _t_data_sink.iceberg_delete_sink;
-    std::string expected_base =
-            delete_sink.__isset.output_path ? delete_sink.output_path : 
delete_sink.table_location;
-    if (!expected_base.empty() && expected_base.back() != '/') {
-        expected_base += '/';
-    }
-    ASSERT_TRUE(delete_file_path.rfind(expected_base, 0) == 0);
-    ASSERT_NE(std::string::npos, delete_file_path.find("delete_pos_"));
+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) {
@@ -533,12 +569,88 @@ TEST_F(VIcebergDeleteSinkTest, 
TestValidateDeletionVectorContentSize) {
     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));
 
-    TDataSink t_data_sink = build_local_delete_sink(temp_dir.string(), 3);
+    const std::string table_location =
+            
"abfss://[email protected]/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;
@@ -546,23 +658,38 @@ TEST_F(VIcebergDeleteSinkTest, 
TestWriteDeletionVectorsToSingleSharedPuffin) {
 
     std::map<std::string, IcebergFileDeletion> file_deletions;
     auto [file1_it, file1_inserted] =
-            file_deletions.emplace("file1.parquet", IcebergFileDeletion(1, 
"[\"p=1\"]"));
+            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("file2.parquet", IcebergFileDeletion(2, 
"[\"p=2\"]"));
+            file_deletions.emplace(second_data_file, IcebergFileDeletion(2, 
"[\"p=2\"]"));
     ASSERT_TRUE(file2_inserted);
     file2_it->second.rows_to_delete.add((uint32_t)30);
 
-    ASSERT_TRUE(sink->_write_deletion_vector_files(file_deletions).ok());
+    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);
 
@@ -574,7 +701,7 @@ TEST_F(VIcebergDeleteSinkTest, 
TestWriteDeletionVectorsToSingleSharedPuffin) {
     }
     ASSERT_EQ(1, puffin_file_count);
 
-    std::ifstream input(first_commit.file_path, std::ios::binary);
+    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>());
diff --git a/be/test/exec/sink/viceberg_merge_sink_test.cpp 
b/be/test/exec/sink/viceberg_merge_sink_test.cpp
index 991f7bc2650..a262e06d8f2 100644
--- a/be/test/exec/sink/viceberg_merge_sink_test.cpp
+++ b/be/test/exec/sink/viceberg_merge_sink_test.cpp
@@ -211,6 +211,44 @@ TEST_F(VIcebergMergeSinkTest, 
TestUpdateProducesDeleteAndInsert) {
     ASSERT_TRUE(sink->close(Status::OK()).ok());
 }
 
+TEST_F(VIcebergMergeSinkTest, 
TestInnerSinksWriteAndCommitUnderBothFormsOfTheDataLocation) {
+    ObjectPool pool;
+    IcebergWriteMockRuntimeState state;
+
+    DataTypes types {std::make_shared<DataTypeInt8>(),
+                     std::make_shared<DataTypeStruct>(DataTypes 
{std::make_shared<DataTypeString>(),
+                                                                 
std::make_shared<DataTypeInt64>()},
+                                                      Strings {"file_path", 
"row_position"}),
+                     std::make_shared<DataTypeInt32>(), 
std::make_shared<DataTypeString>()};
+    MockRowDescriptor row_desc(types, &pool);
+    auto output_exprs = build_output_exprs(&pool, &state, row_desc);
+    // UPDATE / MERGE write data files and delete files through one normalized 
location, and both
+    // kinds must be committed under the table's own form of it.
+    TDataSink t_sink = build_sink();
+    t_sink.iceberg_merge_sink.__set_output_path("s3://b/db/t/data");
+    t_sink.iceberg_merge_sink.__set_original_output_path("oss://b/db/t/data");
+    t_sink.iceberg_merge_sink.__set_table_location("oss://b/db/t/data");
+
+    auto sink = std::make_shared<VIcebergMergeSink>(t_sink, output_exprs, 
nullptr, nullptr);
+    sink->set_skip_io(true);
+    ASSERT_TRUE(sink->init_properties(&pool, row_desc).ok());
+    RuntimeProfile profile("iceberg_merge_sink_locations");
+    ASSERT_TRUE(sink->open(&state, &profile).ok());
+
+    ASSERT_TRUE(sink->_table_writer->_output_location.has_value());
+    EXPECT_EQ("s3://b/db/t/data/f.parquet",
+              sink->_table_writer->_output_location->write_path("f.parquet"));
+    EXPECT_EQ("oss://b/db/t/data/f.parquet",
+              sink->_table_writer->_output_location->commit_path("f.parquet"));
+    ASSERT_TRUE(sink->_delete_writer->_output_location.has_value());
+    EXPECT_EQ("s3://b/db/t/data/f.parquet",
+              sink->_delete_writer->_output_location->write_path("f.parquet"));
+    EXPECT_EQ("oss://b/db/t/data/f.parquet",
+              
sink->_delete_writer->_output_location->commit_path("f.parquet"));
+
+    ASSERT_TRUE(sink->close(Status::OK()).ok());
+}
+
 TEST_F(VIcebergMergeSinkTest, TestDeleteOnlySkipsVariantDataWriter) {
     ObjectPool pool;
     // Opening the retained delete writer still requires the coordinator 
ownership handshake.
diff --git a/be/test/exec/sink/writer/iceberg/iceberg_output_location_test.cpp 
b/be/test/exec/sink/writer/iceberg/iceberg_output_location_test.cpp
new file mode 100644
index 00000000000..1abd5d71481
--- /dev/null
+++ b/be/test/exec/sink/writer/iceberg/iceberg_output_location_test.cpp
@@ -0,0 +1,56 @@
+// 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/writer/iceberg/iceberg_output_location.h"
+
+#include <gtest/gtest.h>
+
+namespace doris {
+
+TEST(IcebergOutputLocationTest, 
WritesUnderTheNormalizedFormAndCommitsUnderTheTableForm) {
+    IcebergOutputLocation location("s3://b/db/t/data", "oss://b/db/t/data");
+
+    EXPECT_EQ("s3://b/db/t/data/delete_pos_1.parquet", 
location.write_path("delete_pos_1.parquet"));
+    EXPECT_EQ("oss://b/db/t/data/delete_pos_1.parquet",
+              location.commit_path("delete_pos_1.parquet"));
+}
+
+TEST(IcebergOutputLocationTest, 
JoinsDirectoriesWithAndWithoutTrailingSlashOnce) {
+    // write.data.path is taken verbatim, so either form may end with '/'.
+    IcebergOutputLocation location("s3://b/db/t/data/", "oss://b/db/t/data");
+
+    EXPECT_EQ("s3://b/db/t/data/f.parquet", location.write_path("f.parquet"));
+    EXPECT_EQ("oss://b/db/t/data/f.parquet", 
location.commit_path("f.parquet"));
+
+    IcebergOutputLocation partition = location.subdirectory("p=1/q=2");
+    EXPECT_EQ("s3://b/db/t/data/p=1/q=2/f.parquet", 
partition.write_path("f.parquet"));
+    EXPECT_EQ("oss://b/db/t/data/p=1/q=2/f.parquet", 
partition.commit_path("f.parquet"));
+}
+
+TEST(IcebergOutputLocationTest, 
KeepsTheTableFormWhenNormalizationRewritesTheAuthority) {
+    // FE rewrites more than the scheme for Azure: the account moves out of 
the authority.
+    IcebergOutputLocation location(
+            "s3://container/warehouse/t/data",
+            "abfss://[email protected]/warehouse/t/data");
+
+    IcebergOutputLocation partition = location.subdirectory("dt=2024-01-01");
+    EXPECT_EQ("s3://container/warehouse/t/data/dt=2024-01-01/f.orc", 
partition.write_path("f.orc"));
+    
EXPECT_EQ("abfss://[email protected]/warehouse/t/data/dt=2024-01-01/f.orc",
+              partition.commit_path("f.orc"));
+}
+
+} // namespace doris
diff --git a/be/test/exec/sink/writer/iceberg/iceberg_partition_writer_test.cpp 
b/be/test/exec/sink/writer/iceberg/iceberg_partition_writer_test.cpp
index 590e1953c67..ba3f13b6086 100644
--- a/be/test/exec/sink/writer/iceberg/iceberg_partition_writer_test.cpp
+++ b/be/test/exec/sink/writer/iceberg/iceberg_partition_writer_test.cpp
@@ -82,9 +82,12 @@ protected:
             const TDataSink& sink, const VExprContextSPtrs& output_exprs,
             const iceberg::Schema& schema, const std::string* schema_json,
             const std::map<std::string, std::string>& hadoop_conf,
-            TFileFormatType::type format = TFileFormatType::FORMAT_ORC) {
-        IPartitionWriterBase::WriteInfo write_info;
-        write_info.file_type = TFileType::FILE_LOCAL;
+            TFileFormatType::type format = TFileFormatType::FORMAT_ORC,
+            IcebergOutputLocation location = IcebergOutputLocation(
+                    "/tmp/iceberg_partition_writer_test", 
"/tmp/iceberg_partition_writer_test")) {
+        IPartitionWriterBase::WriteInfo write_info {.location = 
std::move(location),
+                                                    .file_type = 
TFileType::FILE_LOCAL,
+                                                    .broker_addresses = {}};
         return std::make_unique<VIcebergPartitionWriter>(
                 sink, std::vector<std::string> {}, output_exprs, schema, 
schema_json,
                 std::vector<std::string> {}, std::move(write_info), "data", 0, 
format,
@@ -128,6 +131,24 @@ TEST_F(VIcebergPartitionWriterTest, 
OrcSkipsFooterCollectionWhenMetricsAreDisabl
     EXPECT_FALSE(commit_data.__isset.column_stats);
 }
 
+TEST_F(VIcebergPartitionWriterTest, 
CommitDataRecordsTheTableFormOfTheLocation) {
+    // The file is written through FE's normalized form; the commit must keep 
the table's own.
+    VExprContextSPtrs output_exprs;
+    iceberg::Schema schema(std::vector<iceberg::NestedField> {});
+    std::string schema_json;
+    std::map<std::string, std::string> hadoop_conf;
+    auto writer =
+            make_writer(make_table_sink(false), output_exprs, schema, 
&schema_json, hadoop_conf,
+                        TFileFormatType::FORMAT_ORC,
+                        IcebergOutputLocation("s3://b/db/t/data/p=1", 
"oss://b/db/t/data/p=1"));
+    install_fake_transformer(writer.get(), output_exprs);
+
+    TIcebergCommitData commit_data;
+    ASSERT_TRUE(build_commit_data(writer.get(), &commit_data).ok());
+    EXPECT_EQ("oss://b/db/t/data/p=1/data-0.zlib.orc", commit_data.file_path);
+    EXPECT_EQ(TFileContent::DATA, commit_data.file_content);
+}
+
 TEST_F(VIcebergPartitionWriterTest, 
ParquetKeepsSplitOffsetsWhenColumnMetricsAreDisabled) {
     TimezoneUtils::load_timezones_to_cache();
     auto* env = ExecEnv::GetInstance();
diff --git a/be/test/exec/sink/writer/iceberg/viceberg_table_writer_test.cpp 
b/be/test/exec/sink/writer/iceberg/viceberg_table_writer_test.cpp
index 2f136adcdae..41a126a9cbf 100644
--- a/be/test/exec/sink/writer/iceberg/viceberg_table_writer_test.cpp
+++ b/be/test/exec/sink/writer/iceberg/viceberg_table_writer_test.cpp
@@ -32,6 +32,7 @@
 #include "core/data_type/data_type_timestamptz.h"
 #include "core/data_type/data_type_varbinary.h"
 #include "exec/sink/writer/iceberg/partition_transformers.h"
+#include "exec/sink/writer/iceberg/viceberg_partition_writer.h"
 #include "exec/sink/writer/iceberg/vpartition_writer_base.h"
 #include "exprs/vexpr_context.h"
 #include "exprs/vslot_ref.h"
@@ -327,6 +328,65 @@ TEST(VIcebergTableWriterTest, 
BinaryIdentityStaticAndDynamicRouting) {
     EXPECT_EQ(std::vector<std::string>({"0xdead", "7"}), 
writer._partition_values(hybrid));
 }
 
+TEST(VIcebergTableWriterTest, 
OpenRejectsASinkWithoutTheTableFormOfTheDataLocation) {
+    // Without original_output_path the commit used to record relative paths 
such as
+    // /p=1/xxx.parquet; now the write fails before any file is created.
+    TIcebergTableSink sink;
+    sink.__set_output_path("s3://b/db/t/data");
+    TDataSink data_sink;
+    data_sink.__set_iceberg_table_sink(sink);
+    VIcebergTableWriter writer(data_sink, {});
+    RuntimeState state;
+    TQueryOptions query_options;
+    query_options.__set_supports_external_file_report_ack(true);
+    state.set_query_options(query_options);
+    RuntimeProfile profile("test");
+
+    Status status = writer.open(&state, &profile);
+
+    EXPECT_TRUE(status.is<ErrorCode::INTERNAL_ERROR>()) << status;
+    EXPECT_NE(std::string::npos, 
status.to_string().find("original_output_path"));
+    EXPECT_FALSE(writer._output_location.has_value());
+}
+
+TEST(VIcebergTableWriterTest, 
PartitionWritersWriteAndCommitUnderBothFormsOfTheDataLocation) {
+    // FE normalizes the location BE writes to (oss:// becomes s3://); the 
commit must keep the
+    // table's own form in the data location and in every partition directory 
under it.
+    std::vector<iceberg::NestedField> columns;
+    columns.emplace_back(false, 1, "p", 
std::make_unique<iceberg::IntegerType>(), std::nullopt);
+    TIcebergTableSink sink;
+    sink.__set_output_path("s3://b/db/t/data");
+    sink.__set_original_output_path("oss://b/db/t/data/");
+    sink.__set_file_format(TFileFormatType::FORMAT_PARQUET);
+    sink.__set_compression_type(TFileCompressType::SNAPPYBLOCK);
+    TDataSink data_sink;
+    data_sink.__set_iceberg_table_sink(sink);
+    VIcebergTableWriter writer(data_sink, {});
+    writer._schema = std::make_shared<iceberg::Schema>(std::move(columns));
+    // What open() derives from the two location fields.
+    writer._output_location.emplace(sink.output_path, 
sink.original_output_path);
+
+    const std::string file_name = "f";
+    auto expect_paths = [&](const std::string& partition_dir) {
+        auto writer_base = writer._create_partition_writer(nullptr, -1, 
&file_name, 0);
+        auto* partition_writer = 
dynamic_cast<VIcebergPartitionWriter*>(writer_base.get());
+        ASSERT_NE(nullptr, partition_writer);
+        const auto& location = partition_writer->_write_info.location;
+        const std::string target_file = 
partition_writer->_get_target_file_name();
+        EXPECT_EQ("s3://b/db/t/data/" + partition_dir + target_file,
+                  location.write_path(target_file));
+        EXPECT_EQ("oss://b/db/t/data/" + partition_dir + target_file,
+                  location.commit_path(target_file));
+    };
+    // A non-partitioned table writes into the data location itself.
+    expect_paths("");
+    // A partition writes into its directory under the data location.
+    writer._is_full_static_partition = true;
+    writer._static_partition_path = "p=1";
+    writer._static_partition_value_list = {"1"};
+    expect_paths("p=1/");
+}
+
 TEST(VIcebergTableWriterTest, RejectMissingPartitionSource) {
     std::vector<iceberg::NestedField> columns;
     columns.emplace_back(false, 3, "id", 
std::make_unique<iceberg::IntegerType>(), std::nullopt);
diff --git a/be/test/format/transformer/vparquet_writer_test.cpp 
b/be/test/format/transformer/vparquet_writer_test.cpp
index 6591ca24f57..1c53c4b6916 100644
--- a/be/test/format/transformer/vparquet_writer_test.cpp
+++ b/be/test/format/transformer/vparquet_writer_test.cpp
@@ -594,9 +594,7 @@ TEST_F(VParquetWriterTest, 
IcebergNanosecondsRoundTripWithoutCoercion) {
     const auto name = "iceberg_ns_" + UniqueId::gen_uid().to_string();
     _file_path = "./" + name + "-0.parquet";
     VIcebergPartitionWriter writer(sink, {}, exprs, *schema, &json, {"ts"},
-                                   {.write_path = ".",
-                                    .original_write_path = ".",
-                                    .target_path = ".",
+                                   {.location = IcebergOutputLocation(".", 
"."),
                                     .file_type = TFileType::FILE_LOCAL,
                                     .broker_addresses = {}},
                                    name, 0, TFileFormatType::FORMAT_PARQUET,
diff --git 
a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergConnectorTransaction.java
 
b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergConnectorTransaction.java
index ad17c6d353e..b6ca61f312f 100644
--- 
a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergConnectorTransaction.java
+++ 
b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergConnectorTransaction.java
@@ -144,6 +144,10 @@ public class IcebergConnectorTransaction implements 
ConnectorTransaction, Rewrit
     private final IcebergCatalogResourceTracker resourceTracker;
     private final String catalogFlavor;
     private final List<TIcebergCommitData> commitDataList = new ArrayList<>();
+    // The raw data locations the sinks of this write commit their files 
under, registered by planWrite for
+    // every sink it builds (several for a rewrite_data_files whose groups 
each plan one). Guarded by this,
+    // like commitDataList.
+    private final Set<String> commitLocations = new HashSet<>();
 
     // The single SDK transaction / table, opened lazily by beginWrite (the 
write plan binds the target
     // table only when the sink is planned). volatile: addCommitData / commit 
may run on different threads.
@@ -626,10 +630,46 @@ public class IcebergConnectorTransaction implements 
ConnectorTransaction, Rewrit
             throw new DorisConnectorException("failed to deserialize Iceberg 
commit data", e);
         }
         synchronized (this) {
+            checkCommitLocation(data);
             commitDataList.add(data);
         }
     }
 
+    /**
+     * Registers a raw data location a sink of this write carries ({@code 
TIcebergTableSink.original_output_path}
+     * or {@code TIcebergDeleteSink.table_location}): BE writes the sink's 
files under the normalized form of it
+     * and must report them under it. Called by planWrite for every sink it 
builds.
+     */
+    void addCommitLocation(String location) {
+        if (location == null || location.isEmpty()) {
+            throw new DorisConnectorException("An iceberg sink must carry the 
raw location it commits files under");
+        }
+        synchronized (this) {
+            commitLocations.add(location);
+        }
+    }
+
+    /**
+     * Rejects a fragment whose file is not recorded under a location a sink 
of this write carries. BE writes
+     * through the normalized location (s3://b/db/t/data for an oss:// table) 
and must report the table's own
+     * form (oss://b/db/t/data/x.parquet): Iceberg metadata, other engines and 
{@link #rollback} resolve the
+     * reported path with the table's file system, so the s3:// form names a 
file no other engine can open, and
+     * nothing failed when delete files were committed that way. Throwing here 
makes FE reject the report: the
+     * statement fails and BE deletes the files under the paths it wrote them 
at.
+     */
+    private void checkCommitLocation(TIcebergCommitData data) {
+        String path = data.getFilePath();
+        if (path != null) {
+            for (String location : commitLocations) {
+                if (path.startsWith(location.endsWith("/") ? location : 
location + "/")) {
+                    return;
+                }
+            }
+        }
+        throw new DorisConnectorException("Iceberg commit data reports file " 
+ path
+                + " outside the locations its sinks commit files under: " + 
commitLocations);
+    }
+
     /**
      * O5-2: stashes the engine-extracted target-only write constraint 
(neutral form). It is converted to an
      * iceberg {@link Expression} lazily at commit time ({@link 
#buildWriteConstraintExpression}) — the table
diff --git 
a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergWritePlanProvider.java
 
b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergWritePlanProvider.java
index 80a5e3e429c..53794216318 100644
--- 
a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergWritePlanProvider.java
+++ 
b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergWritePlanProvider.java
@@ -236,21 +236,32 @@ public class IcebergWritePlanProvider implements 
ConnectorWritePlanProvider {
         Map<String, List<TIcebergDeleteFileDesc>> rewritableDeletes =
                 IcebergStatementScope.rewritableDeleteSupply(session);
 
+        TDataSink dataSink = buildDataSink(writeContext.getWriteOperation(), 
table, tableHandle, handle,
+                rewritableDeletes, schemaContext);
+        // BE reports every file of the sink under the raw location the sink 
carries, whichever writer
+        // produced it; the transaction rejects a reported file anywhere else.
+        commitLocationsOf(dataSink).forEach(transaction::addCommitLocation);
+        return new ConnectorSinkPlan(dataSink);
+    }
+
+    private TDataSink buildDataSink(WriteOperation writeOperation, Table 
table, IcebergTableHandle tableHandle,
+            ConnectorWriteHandle handle, Map<String, 
List<TIcebergDeleteFileDesc>> rewritableDeletes,
+            IcebergWriteSchemaContext schemaContext) {
         // Dispatch on the write operation to the matching BE sink dialect 
(each is a distinct TDataSinkType,
         // byte-identical to the legacy fe-core planner sink). OVERWRITE 
shares TIcebergTableSink with INSERT
         // (the overwrite flag is read from the handle); UPDATE shares 
TIcebergMergeSink with MERGE.
-        switch (writeContext.getWriteOperation()) {
+        switch (writeOperation) {
             case INSERT:
             case OVERWRITE: {
                 TDataSink dataSink = new 
TDataSink(TDataSinkType.ICEBERG_TABLE_SINK);
                 dataSink.setIcebergTableSink(buildSink(table, tableHandle, 
handle, schemaContext));
-                return new ConnectorSinkPlan(dataSink);
+                return dataSink;
             }
             case DELETE: {
                 TDataSink dataSink = new 
TDataSink(TDataSinkType.ICEBERG_DELETE_SINK);
                 dataSink.setIcebergDeleteSink(
                         buildDeleteSink(table, tableHandle, rewritableDeletes, 
schemaContext));
-                return new ConnectorSinkPlan(dataSink);
+                return dataSink;
             }
             case UPDATE:
             case MERGE: {
@@ -260,7 +271,7 @@ public class IcebergWritePlanProvider implements 
ConnectorWritePlanProvider {
                         handle.getBoundTargetColumns().stream()
                                 .anyMatch(column -> 
containsVariant(column.getType())),
                         schemaContext));
-                return new ConnectorSinkPlan(dataSink);
+                return dataSink;
             }
             case REWRITE: {
                 // Compaction rewrite (ALTER TABLE ... EXECUTE 
rewrite_data_files): same TIcebergTableSink
@@ -269,11 +280,29 @@ public class IcebergWritePlanProvider implements 
ConnectorWritePlanProvider {
                 // is already evicted above.
                 TDataSink dataSink = new 
TDataSink(TDataSinkType.ICEBERG_TABLE_SINK);
                 dataSink.setIcebergTableSink(buildRewriteSink(table, 
tableHandle, handle));
-                return new ConnectorSinkPlan(dataSink);
+                return dataSink;
             }
             default:
-                throw new DorisConnectorException(
-                        "Unsupported iceberg write operation: " + 
writeContext.getWriteOperation());
+                throw new DorisConnectorException("Unsupported iceberg write 
operation: " + writeOperation);
+        }
+    }
+
+    /**
+     * The raw data locations BE commits the files of {@code sink} under, read 
from the field each dialect
+     * carries them in: data files under {@code original_output_path}, delete 
files and deletion vectors under
+     * {@code table_location}. The merge sink writes both kinds and carries 
both fields.
+     */
+    private static List<String> commitLocationsOf(TDataSink sink) {
+        switch (sink.getType()) {
+            case ICEBERG_TABLE_SINK:
+                return 
Collections.singletonList(sink.getIcebergTableSink().getOriginalOutputPath());
+            case ICEBERG_DELETE_SINK:
+                return 
Collections.singletonList(sink.getIcebergDeleteSink().getTableLocation());
+            case ICEBERG_MERGE_SINK:
+                return 
Arrays.asList(sink.getIcebergMergeSink().getOriginalOutputPath(),
+                        sink.getIcebergMergeSink().getTableLocation());
+            default:
+                throw new DorisConnectorException("Unsupported iceberg sink 
type: " + sink.getType());
         }
     }
 
diff --git 
a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergConnectorTransactionTest.java
 
b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergConnectorTransactionTest.java
index ba5ce1cc586..771588ecca5 100644
--- 
a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergConnectorTransactionTest.java
+++ 
b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergConnectorTransactionTest.java
@@ -131,8 +131,14 @@ public class IcebergConnectorTransactionTest {
         return ops;
     }
 
+    // Stands in for the raw location planWrite registers for the sink it 
builds; every fragment fed to a
+    // transaction from txnFor lives under it.
+    private static final String COMMIT_LOCATION = "s3://b";
+
     private static IcebergConnectorTransaction 
txnFor(RecordingIcebergCatalogOps ops, RecordingConnectorContext ctx) {
-        return new IcebergConnectorTransaction(42L, ops, ctx);
+        IcebergConnectorTransaction txn = new IcebergConnectorTransaction(42L, 
ops, ctx);
+        txn.addCommitLocation(COMMIT_LOCATION);
+        return txn;
     }
 
     // The overwrite tests below predate the static default 
(iceberg_partition_overwrite_mode defaults to
@@ -249,6 +255,7 @@ public class IcebergConnectorTransactionTest {
 
     private static TIcebergCommitData dataItem(long affectedRows, long 
rowCount, TFileContent content) {
         TIcebergCommitData d = new TIcebergCommitData();
+        d.setFilePath(COMMIT_LOCATION + "/db/t/f.parquet");
         d.setRowCount(rowCount);
         if (affectedRows >= 0) {
             d.setAffectedRows(affectedRows);
@@ -396,6 +403,63 @@ public class IcebergConnectorTransactionTest {
                 () -> txn.addCommitData(new byte[] {(byte) 0xDE, (byte) 0xAD, 
(byte) 0xBE, (byte) 0xEF}));
     }
 
+    // ─────────────────── addCommitData: files must be reported under a 
location a sink carries ───────────────────
+
+    private static IcebergConnectorTransaction txnCommittingUnder(String... 
locations) {
+        IcebergConnectorTransaction txn =
+                new IcebergConnectorTransaction(42L, opsReturning(null), new 
RecordingConnectorContext());
+        for (String location : locations) {
+            txn.addCommitLocation(location);
+        }
+        return txn;
+    }
+
+    @Test
+    public void addCommitDataAcceptsFilesUnderEveryRegisteredLocation() {
+        // One location without and one with a trailing '/': write.data.path 
is taken verbatim.
+        IcebergConnectorTransaction txn = 
txnCommittingUnder("oss://b/db/t/data", "oss://b/db/t2/data/");
+        
txn.addCommitData(commitBytes(dataFileItem("oss://b/db/t/data/id=1/f.parquet", 
1L, 64L)));
+        txn.addCommitData(commitBytes(
+                positionDeleteItem("oss://b/db/t2/data/delete_pos_1.parquet", 
1L, "oss://b/db/t2/data/f.parquet")));
+        Assertions.assertEquals(2, txn.getCommitDataList().size());
+    }
+
+    @Test
+    public void addCommitDataRejectsTheNormalizedAddressBeWroteTheFileAt() {
+        // BE writes an oss:// table through s3://; reporting that form 
records a file no other engine opens.
+        IcebergConnectorTransaction txn = 
txnCommittingUnder("oss://b/db/t/data");
+        DorisConnectorException ex = 
Assertions.assertThrows(DorisConnectorException.class,
+                () -> txn.addCommitData(commitBytes(positionDeleteItem(
+                        "s3://b/db/t/data/delete_pos_1.parquet", 1L, 
"oss://b/db/t/data/f.parquet"))));
+        
Assertions.assertTrue(ex.getMessage().contains("s3://b/db/t/data/delete_pos_1.parquet"),
 ex.getMessage());
+        Assertions.assertTrue(ex.getMessage().contains("oss://b/db/t/data"), 
ex.getMessage());
+        Assertions.assertTrue(txn.getCommitDataList().isEmpty(), "a rejected 
fragment must not be accumulated");
+    }
+
+    @Test
+    public void 
addCommitDataRejectsASiblingDirectorySharingTheLocationPrefix() {
+        IcebergConnectorTransaction txn = 
txnCommittingUnder("oss://b/db/t/data");
+        Assertions.assertThrows(DorisConnectorException.class,
+                () -> 
txn.addCommitData(commitBytes(dataFileItem("oss://b/db/t/data_old/f.parquet", 
1L, 64L))));
+    }
+
+    @Test
+    public void 
addCommitDataRejectsAFragmentWithoutAPathOrBeforeAnySinkWasPlanned() {
+        IcebergConnectorTransaction planned = 
txnCommittingUnder("oss://b/db/t/data");
+        Assertions.assertThrows(DorisConnectorException.class,
+                () -> planned.addCommitData(commitBytes(dataItem(1L, 1L, 
TFileContent.DATA).setFilePath(null))));
+        IcebergConnectorTransaction unplanned = txnCommittingUnder();
+        Assertions.assertThrows(DorisConnectorException.class,
+                () -> 
unplanned.addCommitData(commitBytes(dataFileItem("oss://b/db/t/data/f.parquet", 
1L, 64L))));
+    }
+
+    @Test
+    public void addCommitLocationRejectsAnEmptyLocation() {
+        IcebergConnectorTransaction txn = txnCommittingUnder();
+        Assertions.assertThrows(DorisConnectorException.class, () -> 
txn.addCommitLocation(""));
+        Assertions.assertThrows(DorisConnectorException.class, () -> 
txn.addCommitLocation(null));
+    }
+
     // ─────────────────── getUpdateCnt: data/delete split, affectedRows 
priority (T03, regression) ───────────────────
 
     @Test
@@ -1180,7 +1244,7 @@ public class IcebergConnectorTransactionTest {
         long snapshotId = table.currentSnapshot().snapshotId();
         IcebergConnectorTransaction txn = txnFor(opsReturning(table), new 
RecordingConnectorContext());
         txn.beginWrite(SESSION, "db1", "t1", overwriteStaticCtx(table, 
Collections.singletonMap("ts", "2024-01-02")));
-        
txn.addCommitData(commitBytes(dataFileItem("s3://bucket/replacement.parquet", 
1L, 100L,
+        
txn.addCommitData(commitBytes(dataFileItem("s3://b/replacement.parquet", 1L, 
100L,
                 Collections.singletonList("2024-01-02 00:00:00.000000000"))));
         // DATE metrics are days, so silently committing a nanos equality 
would retain the old file.
         DorisConnectorException ex = 
Assertions.assertThrows(DorisConnectorException.class, txn::commit);
@@ -1206,7 +1270,7 @@ public class IcebergConnectorTransactionTest {
         }
         IcebergConnectorTransaction txn = txnFor(opsReturning(table), new 
RecordingConnectorContext());
         txn.beginWrite(SESSION, "db1", "t1", overwriteStaticCtx(table, 
Collections.singletonMap("ts", "2024-01-01")));
-        
txn.addCommitData(commitBytes(dataFileItem("s3://bucket/replacement.parquet", 
1L, 1024L,
+        
txn.addCommitData(commitBytes(dataFileItem("s3://b/replacement.parquet", 1L, 
1024L,
                 Collections.singletonList("2024-01-01 00:00:00.000000000"))));
         txn.commit();
         List<DataFile> remaining = currentDataFiles(catalog.loadTable(id));
@@ -1233,7 +1297,7 @@ public class IcebergConnectorTransactionTest {
         IcebergConnectorTransaction txn = txnFor(opsReturning(table), new 
RecordingConnectorContext());
         txn.beginWrite(SESSION, "db1", "t1",
                 overwriteStaticCtx(table, Collections.singletonMap("ts", 
"2024-01-01 00:00:00+08:00")));
-        
txn.addCommitData(commitBytes(dataFileItem("s3://bucket/replacement.parquet", 
1L, 1024L,
+        
txn.addCommitData(commitBytes(dataFileItem("s3://b/replacement.parquet", 1L, 
1024L,
                 Collections.singletonList("2023-12-31 16:00:00.000000000"))));
         txn.commit();
         List<DataFile> remaining = currentDataFiles(catalog.loadTable(id));
diff --git 
a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergWriteKnownBugsTest.java
 
b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergWriteKnownBugsTest.java
index f18a79a4e95..a1ae89bc1ef 100644
--- 
a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergWriteKnownBugsTest.java
+++ 
b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergWriteKnownBugsTest.java
@@ -45,6 +45,9 @@ public class IcebergWriteKnownBugsTest {
     private static final String SEED = "s3://bucket/events/seed.parquet";
     private static final String ADDED = "s3://bucket/events/added.parquet";
     private static final String DELETED = "s3://bucket/events/deleted.parquet";
+    // Stands in for the raw location planWrite registers for the sink it 
builds; every reported file
+    // above lives under it.
+    private static final String COMMIT_LOCATION = "s3://bucket/events";
 
     @Test
     @Disabled("Known issue: DELETE ignores the standard snapshot isolation 
property.")
@@ -113,7 +116,10 @@ public class IcebergWriteKnownBugsTest {
     private static IcebergConnectorTransaction transaction(Table table) {
         RecordingIcebergCatalogOps operations = new 
RecordingIcebergCatalogOps();
         operations.table = table;
-        return new IcebergConnectorTransaction(9, operations, new 
RecordingConnectorContext());
+        IcebergConnectorTransaction transaction =
+                new IcebergConnectorTransaction(9, operations, new 
RecordingConnectorContext());
+        transaction.addCommitLocation(COMMIT_LOCATION);
+        return transaction;
     }
 
     private static TIcebergCommitData fragment(String path, long count, 
TFileContent content) {
diff --git 
a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergWritePlanProviderTest.java
 
b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergWritePlanProviderTest.java
index b0eb9bee169..26ae3e6fd06 100644
--- 
a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergWritePlanProviderTest.java
+++ 
b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergWritePlanProviderTest.java
@@ -44,6 +44,7 @@ import org.apache.doris.thrift.TFileCompressType;
 import org.apache.doris.thrift.TFileContent;
 import org.apache.doris.thrift.TFileFormatType;
 import org.apache.doris.thrift.TFileType;
+import org.apache.doris.thrift.TIcebergCommitData;
 import org.apache.doris.thrift.TIcebergDeleteFileDesc;
 import org.apache.doris.thrift.TIcebergDeleteSink;
 import org.apache.doris.thrift.TIcebergMergeSink;
@@ -83,6 +84,9 @@ import org.apache.iceberg.io.OutputFile;
 import org.apache.iceberg.types.Type;
 import org.apache.iceberg.types.Types;
 import org.apache.iceberg.util.DateTimeUtil;
+import org.apache.thrift.TException;
+import org.apache.thrift.TSerializer;
+import org.apache.thrift.protocol.TBinaryProtocol;
 import org.junit.jupiter.api.Assertions;
 import org.junit.jupiter.api.Test;
 
@@ -2491,6 +2495,68 @@ public class IcebergWritePlanProviderTest {
         Assertions.assertTrue(update.isWritesDataFiles());
     }
 
+    // BE writes position-delete and deletion-vector files under output_path 
and commits them under
+    // table_location (VIcebergDeleteSink::_commit_path_of), so both must name 
the same directory, the
+    // data location, differing only in its normalized vs raw form. If 
table_location became the table
+    // root, BE would record delete files without the data directory, i.e. 
files that do not exist,
+    // and nothing would fail. See TIcebergDeleteSink.table_location in 
DataSinks.thrift.
+    @Test
+    public void 
planWriteDeleteAndMergeSinksKeepTableLocationInTheOutputDirectory() {
+        Table table = partitionedSortedTable(freshCatalog());
+        TIcebergDeleteSink deleteSink = planDeleteSink(table, 
contextWithStorage(),
+                new WriteHandle(new IcebergTableHandle("db1", 
"t1")).writeOperation(WriteOperation.DELETE));
+        TIcebergMergeSink mergeSink = planMergeSink(table, 
contextWithStorage(),
+                new WriteHandle(new IcebergTableHandle("db1", 
"t1")).writeOperation(WriteOperation.UPDATE));
+
+        Assertions.assertEquals(pathAfterScheme(deleteSink.getOutputPath()),
+                pathAfterScheme(deleteSink.getTableLocation()),
+                "delete table_location must be the raw form of output_path's 
directory");
+        Assertions.assertEquals(pathAfterScheme(mergeSink.getOutputPath()),
+                pathAfterScheme(mergeSink.getTableLocation()),
+                "merge table_location must be the raw form of output_path's 
directory");
+    }
+
+    // "s3://bucket/wh/db1/t1/data" and "oss://bucket/wh/db1/t1/data" -> 
"bucket/wh/db1/t1/data".
+    private static String pathAfterScheme(String location) {
+        return location.substring(location.indexOf("://") + 3);
+    }
+
+    // planWrite registers the raw location each sink dialect carries with the 
transaction, so a file BE
+    // reports under the normalized address it wrote (the s3:// form of this 
oss:// table, as delete files once
+    // were) fails the statement instead of being recorded where other engines 
cannot resolve it.
+    @Test
+    public void planWriteRegistersTheCommitLocationOfEverySinkDialect() {
+        for (WriteOperation operation : Arrays.asList(WriteOperation.INSERT, 
WriteOperation.REWRITE,
+                WriteOperation.DELETE, WriteOperation.UPDATE)) {
+            Table table = partitionedSortedTable(freshCatalog());
+            RecordingConnectorContext ctx = contextWithStorage();
+            RecordingIcebergCatalogOps ops = new RecordingIcebergCatalogOps();
+            ops.table = table;
+            IcebergConnectorTransaction txn = new 
IcebergConnectorTransaction(42L, ops, ctx);
+            providerFor(table, ctx).planWrite(new WriteSession(txn),
+                    new WriteHandle(new IcebergTableHandle("db1", 
"t1")).writeOperation(operation));
+
+            
txn.addCommitData(commitFragment("oss://bucket/wh/db1/t1/data/id=1/f.parquet"));
+            DorisConnectorException rejected = 
Assertions.assertThrows(DorisConnectorException.class,
+                    () -> 
txn.addCommitData(commitFragment("s3://bucket/wh/db1/t1/data/id=1/f.parquet")),
+                    operation + " must reject a file reported under the 
normalized location");
+            
Assertions.assertTrue(rejected.getMessage().contains("s3://bucket/wh/db1/t1/data/id=1/f.parquet"),
+                    rejected.getMessage());
+            Assertions.assertEquals(1, txn.getCommitDataList().size(), 
operation.toString());
+        }
+    }
+
+    private static byte[] commitFragment(String filePath) {
+        TIcebergCommitData data = new TIcebergCommitData();
+        data.setFilePath(filePath);
+        data.setFileContent(TFileContent.DATA);
+        try {
+            return new TSerializer(new 
TBinaryProtocol.Factory()).serialize(data);
+        } catch (TException e) {
+            throw new AssertionError(e);
+        }
+    }
+
     @Test
     public void planWriteBuildsMergeSinkWithTableDerivedFields() {
         Table table = partitionedSortedTable(freshCatalog());
diff --git 
a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergWriteTransactionCoverageTest.java
 
b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergWriteTransactionCoverageTest.java
index 83412d30c0c..b29e8e3848d 100644
--- 
a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergWriteTransactionCoverageTest.java
+++ 
b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergWriteTransactionCoverageTest.java
@@ -58,6 +58,9 @@ public class IcebergWriteTransactionCoverageTest {
     private static final String SEED = "s3://bucket/events/seed.parquet";
     private static final String ADDED = "s3://bucket/events/added.parquet";
     private static final String DELETED = "s3://bucket/events/deleted.parquet";
+    // Stands in for the raw location planWrite registers for the sink it 
builds; every reported file
+    // above lives under it.
+    private static final String COMMIT_LOCATION = "s3://bucket/events";
 
     @Test
     public void appendV1PreservesHistoricalSnapshot() throws IOException {
@@ -252,6 +255,7 @@ public class IcebergWriteTransactionCoverageTest {
         operations.throwOnLoadTable = true;
         IcebergConnectorTransaction transaction = new 
IcebergConnectorTransaction(
                 7, operations, new RecordingConnectorContext());
+        transaction.addCommitLocation(COMMIT_LOCATION);
         Assertions.assertThrows(DorisConnectorException.class,
                 () -> transaction.beginWrite(session(null), "db", "events", 
context(WriteOperation.INSERT)));
         Assertions.assertNull(transaction.getTransaction());
@@ -321,7 +325,10 @@ public class IcebergWriteTransactionCoverageTest {
     private static IcebergConnectorTransaction transaction(Table table) {
         RecordingIcebergCatalogOps operations = new 
RecordingIcebergCatalogOps();
         operations.table = table;
-        return new IcebergConnectorTransaction(7, operations, new 
RecordingConnectorContext());
+        IcebergConnectorTransaction transaction =
+                new IcebergConnectorTransaction(7, operations, new 
RecordingConnectorContext());
+        transaction.addCommitLocation(COMMIT_LOCATION);
+        return transaction;
     }
 
     private static IcebergWriteContext context(WriteOperation operation) {
diff --git a/fe/fe-core/src/main/java/org/apache/doris/qe/QeProcessorImpl.java 
b/fe/fe-core/src/main/java/org/apache/doris/qe/QeProcessorImpl.java
index babfb4b2fcb..06f13007bcb 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/qe/QeProcessorImpl.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/qe/QeProcessorImpl.java
@@ -324,13 +324,15 @@ public final class QeProcessorImpl implements QeProcessor 
{
         try {
             boolean accepted = 
info.getCoord().updateFragmentExecStatus(params);
             if (hasExternalCommitData && !accepted) {
-                return rejectedExternalFileReport(result, "FE has not accepted 
the external-file report");
+                return rejectExternalFileReport(info.getCoord(), result,
+                        "FE has not accepted the external-file report");
             }
         } catch (Exception e) {
             LOG.warn("Exception during handle report, response: {}, query: {}, 
instance: {}", result.toString(),
                     DebugUtil.printId(params.query_id), 
DebugUtil.printId(params.fragment_instance_id), e);
             return hasExternalCommitData
-                    ? rejectedExternalFileReport(result, "FE did not accept 
the external-file report")
+                    ? rejectExternalFileReport(info.getCoord(), result,
+                            "FE did not accept the external-file report: " + 
e.getMessage())
                     : result;
         }
         result.setStatus(new TStatus(TStatusCode.OK));
@@ -354,6 +356,14 @@ public final class QeProcessorImpl implements QeProcessor {
                 + params.getFragmentId() + ":" + params.getBackendId();
     }
 
+    // A rejected final report is terminal: BE drops its files and never 
resends it, so the statement
+    // must fail now instead of waiting for a fragment that can no longer 
finish.
+    private static TReportExecStatusResult rejectExternalFileReport(
+            CoordInterface coord, TReportExecStatusResult result, String 
message) {
+        coord.cancel(new Status(TStatusCode.INTERNAL_ERROR, message));
+        return rejectedExternalFileReport(result, message);
+    }
+
     private static TReportExecStatusResult rejectedExternalFileReport(
             TReportExecStatusResult result, String message) {
         TStatus status = new TStatus(TStatusCode.INTERNAL_ERROR);
diff --git 
a/fe/fe-core/src/test/java/org/apache/doris/qe/QeProcessorImplReportAckTest.java
 
b/fe/fe-core/src/test/java/org/apache/doris/qe/QeProcessorImplReportAckTest.java
index c4abc116445..103fffa297f 100644
--- 
a/fe/fe-core/src/test/java/org/apache/doris/qe/QeProcessorImplReportAckTest.java
+++ 
b/fe/fe-core/src/test/java/org/apache/doris/qe/QeProcessorImplReportAckTest.java
@@ -17,6 +17,7 @@
 
 package org.apache.doris.qe;
 
+import org.apache.doris.common.Status;
 import org.apache.doris.common.profile.ExecutionProfile;
 import org.apache.doris.planner.PlanFragmentId;
 import org.apache.doris.system.Backend;
@@ -34,6 +35,7 @@ import com.google.common.cache.Cache;
 import org.junit.jupiter.api.AfterEach;
 import org.junit.jupiter.api.Assertions;
 import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
 import org.mockito.Mockito;
 
 import java.lang.reflect.Field;
@@ -82,17 +84,34 @@ class QeProcessorImplReportAckTest {
 
         Assertions.assertEquals(TStatusCode.INTERNAL_ERROR, 
result.getStatus().getStatusCode());
         Assertions.assertFalse(result.isExternalFileCommitDataAccepted());
+        // BE drops the files of a rejected final report, so the statement 
must fail instead of waiting.
+        Status cancelReason = verifyCancelled(coordinator);
+        Assertions.assertEquals(TStatusCode.INTERNAL_ERROR, 
cancelReason.getErrorCode());
+        Assertions.assertTrue(cancelReason.getErrorMsg().contains("injected 
acceptance failure"));
     }
 
     @Test
     void rejectsExternalReportWhenHandlerDoesNotAcceptIt() throws Exception {
         TUniqueId queryId = new TUniqueId(12345, 4);
-        register(queryId);
+        Coordinator coordinator = register(queryId);
 
         TReportExecStatusResult result = report(params(queryId));
 
         Assertions.assertEquals(TStatusCode.INTERNAL_ERROR, 
result.getStatus().getStatusCode());
         Assertions.assertFalse(result.isExternalFileCommitDataAccepted());
+        Assertions.assertEquals(TStatusCode.INTERNAL_ERROR, 
verifyCancelled(coordinator).getErrorCode());
+    }
+
+    @Test
+    void acceptedExternalReportDoesNotCancelCoordinator() throws Exception {
+        TUniqueId queryId = new TUniqueId(12345, 8);
+        Coordinator coordinator = register(queryId);
+        
Mockito.when(coordinator.updateFragmentExecStatus(Mockito.any())).thenReturn(true);
+
+        TReportExecStatusResult result = report(params(queryId));
+
+        Assertions.assertTrue(result.isExternalFileCommitDataAccepted());
+        Mockito.verify(coordinator, Mockito.never()).cancel(Mockito.any());
     }
 
     @Test
@@ -186,6 +205,12 @@ class QeProcessorImplReportAckTest {
         return coordinator;
     }
 
+    private static Status verifyCancelled(Coordinator coordinator) {
+        ArgumentCaptor<Status> reason = ArgumentCaptor.forClass(Status.class);
+        Mockito.verify(coordinator, Mockito.times(1)).cancel(reason.capture());
+        return reason.getValue();
+    }
+
     private static TReportExecStatusParams params(TUniqueId queryId) {
         return new TReportExecStatusParams()
                 .setQueryId(queryId)
diff --git a/gensrc/thrift/DataSinks.thrift b/gensrc/thrift/DataSinks.thrift
index dd0dbabc51a..c4f303c118b 100644
--- a/gensrc/thrift/DataSinks.thrift
+++ b/gensrc/thrift/DataSinks.thrift
@@ -439,6 +439,9 @@ struct TIcebergColumnStats {
 }
 
 struct TIcebergCommitData {
+    // The path Iceberg metadata records for the file: under the table's own 
form of the location
+    // the sink carries (TIcebergTableSink.original_output_path, 
TIcebergDeleteSink.table_location),
+    // never under the normalized output_path BE wrote it at. FE rejects a 
report outside it.
     1: optional string file_path
     // File-level record count used to construct Iceberg data/delete file 
metadata.
     2: optional i64 row_count
@@ -493,6 +496,12 @@ struct TIcebergTableSink {
     9: optional map<string, string> hadoop_config
     10: optional bool overwrite
     11: optional Types.TFileType file_type
+    // The raw (un-normalized) data location: the same directory as 
output_path, in the table's
+    // own form. BE writes data files under output_path and commits them under
+    // original_output_path, e.g. written to s3://b/db/t/data/p=1/x.parquet, 
committed as
+    // oss://b/db/t/data/p=1/x.parquet. "Same directory" is not "same text 
after the scheme":
+    // normalization can also rewrite the authority, e.g. 
abfss://[email protected]/t/data
+    // becomes s3://c/t/data.
     12: optional string original_output_path
     13: optional PlanNodes.TFileCompressType compression_type
     14: optional list<Types.TNetworkAddress> broker_addresses;
@@ -530,6 +539,11 @@ struct TIcebergDeleteSink {
     5: optional PlanNodes.TFileFormatType file_format
     6: optional PlanNodes.TFileCompressType compress_type
     7: optional string output_path
+    // The raw (un-normalized) data location: the same directory as 
output_path, in the table's
+    // own form. BE writes delete files under output_path and commits them 
under table_location,
+    // e.g. written to s3://b/db/t/data/delete_x.parquet, committed as 
oss://b/db/t/data/delete_x.parquet.
+    // It must not be the table root, or committed paths would miss the data 
directory. See
+    // TIcebergTableSink.original_output_path for what "same directory" means.
     8: optional string table_location
     9: optional map<string, string> hadoop_config
     10: optional Types.TFileType file_type
@@ -554,6 +568,8 @@ struct TIcebergMergeSink {
     7: optional PlanNodes.TFileFormatType file_format
     8: optional PlanNodes.TFileCompressType compression_type
     9: optional string output_path
+    // Same contract as TIcebergTableSink.original_output_path, for the data 
files; FE sets it to
+    // the same value as table_location, which the delete files are committed 
under.
     10: optional string original_output_path
     11: optional map<string, string> hadoop_config
     12: optional Types.TFileType file_type
@@ -573,6 +589,7 @@ struct TIcebergMergeSink {
 
     // delete side (position delete only)
     20: optional TFileContent delete_type
+    // Same contract as TIcebergDeleteSink.table_location: the raw form of 
output_path's directory.
     21: optional string table_location
     22: optional i32 partition_spec_id_for_delete
     23: optional string partition_data_json_for_delete
diff --git 
a/regression-test/data/external_table_p0/iceberg/dml/test_iceberg_update_delete_basic.out.org
 
b/regression-test/data/external_table_p0/iceberg/dml/test_iceberg_update_delete_basic.out.org
deleted file mode 100644
index c42c6138a81..00000000000
--- 
a/regression-test/data/external_table_p0/iceberg/dml/test_iceberg_update_delete_basic.out.org
+++ /dev/null
@@ -1,77 +0,0 @@
--- This file is automatically generated. You should know what you did if you 
want to edit this
--- !parquet_q01 --
-1
-
--- !order_parquet_q02 --
-1      Updated 25
-2      Bob     30
-3      Charlie 35
-
--- !parquet_q03 --
-1
-
--- !order_parquet_q04 --
-3      Charlie 35
-1      Updated 25
-
--- !order_parquet_q05 --
-1      
s3://warehouse/wh/test_update_delete_basic_db/test_update_delete_basic_tbl_parquet/data/delete_pos_5066988_0553b2d5.zstd.parquet
        PARQUET 0       1       2078    \N      \N      \N      \N      \N      
\N      \N      \N      \N      \N      \N      \N      \N      \N      
{"age":{"column_size":null, "value_count":null, "null_value_count":null, 
"nan_value_count":null, "lower_bound":null, "upper_bound":null}, 
"id":{"column_size":null, "value_count":null, "null_value_count":null, 
"nan_value_count":null, "lower_bound":null, "upper_bound":null}, 
"name":{"column_size":null,  [...]
-1      
s3://warehouse/wh/test_update_delete_basic_db/test_update_delete_basic_tbl_parquet/data/delete_pos_5066988_3638addc.zstd.parquet
        PARQUET 0       1       2078    \N      \N      \N      \N      \N      
\N      \N      \N      \N      \N      \N      \N      \N      \N      
{"age":{"column_size":null, "value_count":null, "null_value_count":null, 
"nan_value_count":null, "lower_bound":null, "upper_bound":null}, 
"id":{"column_size":null, "value_count":null, "null_value_count":null, 
"nan_value_count":null, "lower_bound":null, "upper_bound":null}, 
"name":{"column_size":null,  [...]
-
--- !parquet_q06 --
-1
-
--- !order_parquet_q07 --
-12     Cat     22      2024-01-03
-11     Ben     21      2024-01-02
-10     UpdatedP        20      2024-01-01
-
--- !parquet_q08 --
-1
-
--- !order_parquet_q09 --
-12     Cat     22      2024-01-03
-10     UpdatedP        20      2024-01-01
-
--- !order_parquet_q10 --
-1      
s3://warehouse/wh/test_update_delete_basic_db/test_update_delete_basic_tbl_par_parquet/data/delete_pos_5092880_cd626510.zstd.parquet
    PARQUET 0       {"dt_day":"2024-01-01"} 1       2226    \N      \N      \N  
    \N      \N      \N      \N      \N      \N      \N      \N      \N      \N  
    \N      {"age":{"column_size":null, "value_count":null, 
"null_value_count":null, "nan_value_count":null, "lower_bound":null, 
"upper_bound":null}, "dt":{"column_size":null, "value_count":null, 
"null_value_count":null, "nan_value_count":null, "lower_bound":null, 
"upper_bound":null},  [...]
-1      
s3://warehouse/wh/test_update_delete_basic_db/test_update_delete_basic_tbl_par_parquet/data/delete_pos_7425526_6dd1173c.zstd.parquet
    PARQUET 0       {"dt_day":"2024-01-02"} 1       2225    \N      \N      \N  
    \N      \N      \N      \N      \N      \N      \N      \N      \N      \N  
    \N      {"age":{"column_size":null, "value_count":null, 
"null_value_count":null, "nan_value_count":null, "lower_bound":null, 
"upper_bound":null}, "dt":{"column_size":null, "value_count":null, 
"null_value_count":null, "nan_value_count":null, "lower_bound":null, 
"upper_bound":null},  [...]
-
--- !orc_q01 --
-1
-
--- !order_orc_q02 --
-1      Updated 25
-2      Bob     30
-3      Charlie 35
-
--- !orc_q03 --
-1
-
--- !order_orc_q04 --
-3      Charlie 35
-1      Updated 25
-
--- !order_orc_q05 --
-1      
s3://warehouse/wh/test_update_delete_basic_db/test_update_delete_basic_tbl_orc/data/delete_pos_4451312_c1ffb646.zlib.orc
        ORC     0       1       926     \N      \N      \N      \N      \N      
\N      \N      \N      \N      \N      \N      \N      \N      \N      
{"age":{"column_size":null, "value_count":null, "null_value_count":null, 
"nan_value_count":null, "lower_bound":null, "upper_bound":null}, 
"id":{"column_size":null, "value_count":null, "null_value_count":null, 
"nan_value_count":null, "lower_bound":null, "upper_bound":null}, 
"name":{"column_size":null, "value_count" [...]
-1      
s3://warehouse/wh/test_update_delete_basic_db/test_update_delete_basic_tbl_orc/data/delete_pos_4451312_c5f91315.zlib.orc
        ORC     0       1       924     \N      \N      \N      \N      \N      
\N      \N      \N      \N      \N      \N      \N      \N      \N      
{"age":{"column_size":null, "value_count":null, "null_value_count":null, 
"nan_value_count":null, "lower_bound":null, "upper_bound":null}, 
"id":{"column_size":null, "value_count":null, "null_value_count":null, 
"nan_value_count":null, "lower_bound":null, "upper_bound":null}, 
"name":{"column_size":null, "value_count" [...]
-
--- !orc_q06 --
-1
-
--- !order_orc_q07 --
-11     Ben     21      2024-01-02
-12     Cat     22      2024-01-03
-10     UpdatedP        20      2024-01-01
-
--- !orc_q08 --
-1
-
--- !order_orc_q09 --
-12     Cat     22      2024-01-03
-10     UpdatedP        20      2024-01-01
-
--- !order_orc_q10 --
-1      
s3://warehouse/wh/test_update_delete_basic_db/test_update_delete_basic_tbl_par_orc/data/delete_pos_7724320_d7587f50.zlib.orc
    ORC     0       {"dt_day":"2024-01-01"} 1       986     \N      \N      \N  
    \N      \N      \N      \N      \N      \N      \N      \N      \N      \N  
    \N      {"age":{"column_size":null, "value_count":null, 
"null_value_count":null, "nan_value_count":null, "lower_bound":null, 
"upper_bound":null}, "dt":{"column_size":null, "value_count":null, 
"null_value_count":null, "nan_value_count":null, "lower_bound":null, 
"upper_bound":null}, "id":{"column [...]
-1      
s3://warehouse/wh/test_update_delete_basic_db/test_update_delete_basic_tbl_par_orc/data/delete_pos_817404_2d2aa46c.zlib.orc
     ORC     0       {"dt_day":"2024-01-02"} 1       990     \N      \N      \N 
     \N      \N      \N      \N      \N      \N      \N      \N      \N      \N 
     \N      {"age":{"column_size":null, "value_count":null, 
"null_value_count":null, "nan_value_count":null, "lower_bound":null, 
"upper_bound":null}, "dt":{"column_size":null, "value_count":null, 
"null_value_count":null, "nan_value_count":null, "lower_bound":null, 
"upper_bound":null}, "id":{"column_ [...]
-
diff --git 
a/regression-test/suites/external_table_p0/iceberg/dml/test_iceberg_update_delete_basic.groovy
 
b/regression-test/suites/external_table_p0/iceberg/dml/test_iceberg_update_delete_basic.groovy
index 21384862153..9ef8c30b1c8 100644
--- 
a/regression-test/suites/external_table_p0/iceberg/dml/test_iceberg_update_delete_basic.groovy
+++ 
b/regression-test/suites/external_table_p0/iceberg/dml/test_iceberg_update_delete_basic.groovy
@@ -47,6 +47,15 @@ suite("test_iceberg_update_delete_basic", 
"p0,external,iceberg,external_docker,e
     sql """create database if not exists ${dbName}"""
     sql """use ${dbName}"""
 
+    // Delete files must be recorded under the table's own data location 
(s3a:// in this fixture), not
+    // under the s3:// address BE writes them at.
+    def dataLocationOf = { String table ->
+        String ddl = sql("""show create table ${table}""")[0][1]
+        def matcher = ddl =~ /LOCATION '([^']+)'/
+        assert matcher.find() : "no LOCATION in ${ddl}"
+        return matcher.group(1) + "/data/"
+    }
+
     def formats = ["parquet", "orc"]
     for (String format : formats) {
         logger.info("Run update/delete test with format ${format}")
@@ -94,10 +103,11 @@ suite("test_iceberg_update_delete_basic", 
"p0,external,iceberg,external_docker,e
             FROM ${catalogName}.${dbName}.${formatTableName}\$delete_files
         """
         assert deleteFiles.size() > 0 : "Delete files should be created for 
${formatTableName}"
+        String dataLocation = dataLocationOf(formatTableName)
         for (def row : deleteFiles) {
             String filePath = row[0].toString()
             String fileFormat = row[1].toString()
-            assert filePath.contains("/data/delete_pos_")
+            assert filePath.startsWith(dataLocation + "delete_pos_") : 
"${filePath} is not under ${dataLocation}"
             assert filePath.endsWith(format == "parquet" ? ".parquet" : ".orc")
             assert fileFormat.equalsIgnoreCase(format)
         }
@@ -142,10 +152,12 @@ suite("test_iceberg_update_delete_basic", 
"p0,external,iceberg,external_docker,e
             FROM 
${catalogName}.${dbName}.${formatTableNamePartition}\$delete_files
         """
         assert partitionDeleteFiles.size() > 0 : "Delete files should be 
created for ${formatTableNamePartition}"
+        String partitionDataLocation = dataLocationOf(formatTableNamePartition)
         for (def row : partitionDeleteFiles) {
             String filePath = row[0].toString()
             String fileFormat = row[1].toString()
-            assert filePath.contains("/data/delete_pos_")
+            assert filePath.startsWith(partitionDataLocation + "delete_pos_") :
+                    "${filePath} is not under ${partitionDataLocation}"
             assert filePath.endsWith(format == "parquet" ? ".parquet" : ".orc")
             assert fileFormat.equalsIgnoreCase(format)
         }
diff --git 
a/regression-test/suites/external_table_p0/iceberg/write/ICEBERG_WRITE_P0_COVERAGE.md
 
b/regression-test/suites/external_table_p0/iceberg/write/ICEBERG_WRITE_P0_COVERAGE.md
index 06a7281bfb9..f740652d960 100644
--- 
a/regression-test/suites/external_table_p0/iceberg/write/ICEBERG_WRITE_P0_COVERAGE.md
+++ 
b/regression-test/suites/external_table_p0/iceberg/write/ICEBERG_WRITE_P0_COVERAGE.md
@@ -53,6 +53,7 @@ Coverage status:
 | R16 | CTAS behaves inconsistently for complex types, NULL, partition 
transforms, file formats and failure cleanup | Black box: DDL and writer 
committed together | Schema, file format, atomicity | P0 |
 | R17 | Combining sort order, distribution mode, multiple file flushes and 
concurrent commits causes misordering, lost rows or duplicate commits | White 
box: exchange, sort writer, optimistic commit | Distributed correctness, 
stability | P0 |
 | R18 | STRING identity/bucket/truncate computes wrong physical partition 
values for empty strings, Chinese text, emoji, combining characters and NULL | 
Black box: UTF-8 transform metadata | Partition routing, pruning | P0 |
+| R19 | Written data files, position deletes or deletion vectors are recorded 
in metadata under the normalized address BE actually wrote them at (such as 
s3://) instead of the table's own location (such as s3a:// or oss://) | White 
box: sink write path versus commit path | Whether other engines and maintenance 
operations can open the files | P0 |
 
 ## Combined coverage
 
@@ -87,6 +88,7 @@ Coverage status:
 | Sort and distribution properties | Multi-column sort order, NULL ordering, 
none/hash/range distribution, forced multi-file flush | Covered (verified) | 
`test_iceberg_write_order_distribution_properties` |
 | Concurrent writes | Serializability invariant of conflicting MERGEs on the 
same row, non-conflicting distributed appends | Covered (verified) | 
`test_iceberg_write_concurrent_merge_invariants` |
 | Distributed execution | Multi-bucket source tables, multi-partition Iceberg 
sinks, multiple BE writers, no catalog/database shared between suites | Covered 
(verified) | All suites added by this work |
+| File path recording | Every file written by INSERT, partition OVERWRITE, 
branch INSERT, DELETE, UPDATE, MERGE and rewrite_data_files (v2 position 
deletes, v3 deletion vectors) is recorded under the table's data location | 
Covered (regular regression) | `test_iceberg_write_file_locations` |
 | Spark cross-check | Fifteen positive suites compare row by row after the 
last write; the STRING transform suite also compares physical partition 
metadata before and after evolution | Covered (verified) | The fifteen positive 
suites; physical metadata in `test_iceberg_write_string_transform_metadata` |
 
 ## Design of the added cases
@@ -112,9 +114,10 @@ Coverage status:
 | W17 | Verify sort order, distribution mode and multi-file flush | R03, R11, 
R17 | Correctness, stability | Multi-BE Doris | NULL sort keys, multi-column 
ascending/descending order, none/hash/range, low target file size | The plan 
contains the declared sort; the total row count across files is correct; the 
three distribution modes agree |
 | W18 | Verify commit invariants of concurrent MERGE and append | R11, R13, 
R17 | Concurrency, atomicity | Multi-BE Doris | A readiness barrier makes two 
independent sessions ready to dispatch at the same time; updates of the same 
row and non-conflicting appends | Commits on the same row are serializable with 
cardinality one; only Iceberg validation/commit conflicts are accepted; 
non-conflicting writes are neither lost nor duplicated |
 | W19 | Verify type safety of the MERGE source projection entering the 
truncate transform | R12, R18 | Regular regression, stability | Iceberg v2 MOR 
| Matched updates and unmatched inserts on a required STRING truncate column | 
MERGE accepts non-NULL values in the nullable physical projection and produces 
correct physical partitions |
+| W20 | Verify that every write operation records its file paths under the 
table's own location | R19 | Correctness, compatibility | The REST fixture's 
warehouse is s3a://, which BE normalizes to s3:// | INSERT, partition 
OVERWRITE, branch INSERT, DELETE, UPDATE, MERGE and rewrite_data_files in turn 
on v2 and v3 tables | Every file in `$all_files` starts with the LOCATION from 
`SHOW CREATE TABLE` plus `/data/`; new write operations are added to this case 
too |
 
 ## P0 coverage check
 
-Each of R01-R18 maps to at least one P0 regression. After the last successful 
write, all fifteen positive suites cross-check the logical result of the same 
table between Spark and Doris; transforms, rollover, CTAS properties and 
failure atomicity additionally have physical metadata or state oracles. W08, 
W10 and W19 run as regular regressions; W07, which may still write unreadable 
required-null files, keeps its isolation switch so that it does not damage a 
shared cluster.
+Each of R01-R19 maps to at least one P0 regression. After the last successful 
write, all fifteen positive suites cross-check the logical result of the same 
table between Spark and Doris; transforms, rollover, CTAS properties and 
failure atomicity additionally have physical metadata or state oracles. W08, 
W10 and W19 run as regular regressions; W07, which may still write unreadable 
required-null files, keeps its isolation switch so that it does not damage a 
shared cluster.
 
 This matrix has no uncovered items. COW row-level DML, branch row-level DML, 
tag writes and Avro writes are explicit current capability boundaries, each 
pinned by an expected-rejection case that fixes the error semantics and failure 
atomicity; only the known required-null defect keeps using an isolated negative 
regression.
diff --git 
a/regression-test/suites/external_table_p0/iceberg/write/test_iceberg_write_file_locations.groovy
 
b/regression-test/suites/external_table_p0/iceberg/write/test_iceberg_write_file_locations.groovy
new file mode 100644
index 00000000000..b8cc5203d6c
--- /dev/null
+++ 
b/regression-test/suites/external_table_p0/iceberg/write/test_iceberg_write_file_locations.groovy
@@ -0,0 +1,161 @@
+// 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.
+
+// Every file Doris writes into an Iceberg table must be recorded under the 
table's own location, not
+// under the address BE wrote it at. The REST fixture's warehouse is 
s3a://warehouse/wh/, which FE
+// normalizes to s3:// for BE, so a writer that records the s3:// address is 
caught here: delete files
+// and deletion vectors were once committed that way. Each write operation 
Doris supports runs below;
+// a new one belongs here too.
+suite("test_iceberg_write_file_locations", 
"p0,external,iceberg,external_docker,external_docker_iceberg") {
+    String enabled = context.config.otherConfigs.get("enableIcebergTest")
+    if (enabled == null || !enabled.equalsIgnoreCase("true")) {
+        logger.info("disable iceberg test")
+        return
+    }
+
+    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 catalogName = "test_iceberg_write_file_locations"
+    String dbName = "iceberg_write_file_locations_db"
+
+    sql """drop catalog if exists ${catalogName}"""
+    sql """
+        create catalog ${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 """drop database if exists ${dbName} force"""
+    sql """create database ${dbName}"""
+    sql """use ${dbName}"""
+
+    // The table's data location, taken from its metadata rather than from any 
file Doris wrote.
+    def dataLocationOf = { String table ->
+        String ddl = sql("""show create table ${table}""")[0][1]
+        def matcher = ddl =~ /LOCATION '([^']+)'/
+        assertTrue(matcher.find(), "no LOCATION in ${ddl}")
+        return matcher.group(1) + "/data/"
+    }
+
+    // Every file of every snapshot, branches included: data files, position 
deletes and deletion vectors.
+    def assertFilesUnderDataLocation = { String table, String operation ->
+        String dataLocation = dataLocationOf(table)
+        def files = sql """select content, file_format, file_path from 
${table}\$all_files"""
+        assertTrue(files.size() > 0, "no files in ${table} after ${operation}")
+        for (def file : files) {
+            String path = file[2].toString()
+            assertTrue(path.startsWith(dataLocation),
+                    "${operation} recorded ${file[1]} file ${path} (content 
${file[0]}) outside ${dataLocation}")
+        }
+    }
+
+    def countFiles = { String table, String predicate ->
+        return (sql """select count(*) from ${table}\$all_files where 
${predicate}""")[0][0] as long
+    }
+
+    // Format v2: data files and position delete files.
+    sql """drop table if exists file_locations_v2"""
+    sql """
+        create table file_locations_v2 (
+            id int,
+            region string,
+            payload string
+        )
+        partition by list (region) ()
+        properties (
+            "format-version" = "2",
+            "write.delete.mode" = "merge-on-read",
+            "write.update.mode" = "merge-on-read",
+            "write.merge.mode" = "merge-on-read"
+        )
+    """
+    sql """insert into file_locations_v2 values (1, 'A', 'a1'), (2, 'B', 
'b1'), (3, 'C', 'c1')"""
+    assertFilesUnderDataLocation("file_locations_v2", "INSERT")
+    sql """insert into file_locations_v2 values (4, 'A', 'a2'), (5, 'B', 
'b2')"""
+    assertFilesUnderDataLocation("file_locations_v2", "second INSERT")
+    sql """insert overwrite table file_locations_v2 partition (region = 'C') 
values (6, 'c2')"""
+    assertFilesUnderDataLocation("file_locations_v2", "INSERT OVERWRITE 
PARTITION")
+    sql """alter table file_locations_v2 create branch location_branch"""
+    sql """insert into file_locations_v2@branch(location_branch) values (7, 
'A', 'branch')"""
+    assertFilesUnderDataLocation("file_locations_v2", "branch INSERT")
+    sql """delete from file_locations_v2 where id = 1"""
+    assertFilesUnderDataLocation("file_locations_v2", "DELETE")
+    sql """update file_locations_v2 set payload = 'b1-updated' where id = 2"""
+    assertFilesUnderDataLocation("file_locations_v2", "UPDATE")
+    sql """
+        merge into file_locations_v2 t
+        using (
+            select 4 as id, 'A' as region, 'a2-merged' as payload
+            union all
+            select 8, 'D', 'd1'
+        ) s
+        on t.id = s.id
+        when matched then update set payload = s.payload
+        when not matched then insert (id, region, payload) values (s.id, 
s.region, s.payload)
+    """
+    assertFilesUnderDataLocation("file_locations_v2", "MERGE")
+    sql """
+        alter table ${catalogName}.${dbName}.file_locations_v2
+        execute rewrite_data_files("target-file-size-bytes" = "10485760", 
"min-input-files" = "2")
+    """
+    assertFilesUnderDataLocation("file_locations_v2", "rewrite_data_files")
+    assertTrue(countFiles("file_locations_v2", "content = 1") > 0, "DELETE / 
UPDATE / MERGE wrote no delete files")
+
+    // Format v3: row-level operations write deletion vectors into Puffin 
files.
+    sql """drop table if exists file_locations_v3"""
+    sql """
+        create table file_locations_v3 (
+            id int,
+            region string,
+            payload string
+        )
+        partition by list (region) ()
+        properties (
+            "format-version" = "3",
+            "write.delete.mode" = "merge-on-read",
+            "write.update.mode" = "merge-on-read",
+            "write.merge.mode" = "merge-on-read"
+        )
+    """
+    sql """insert into file_locations_v3 values (1, 'A', 'a1'), (2, 'B', 
'b1'), (3, 'C', 'c1')"""
+    assertFilesUnderDataLocation("file_locations_v3", "INSERT")
+    sql """delete from file_locations_v3 where id = 1"""
+    assertFilesUnderDataLocation("file_locations_v3", "DELETE")
+    sql """update file_locations_v3 set payload = 'b1-updated' where id = 2"""
+    assertFilesUnderDataLocation("file_locations_v3", "UPDATE")
+    sql """
+        merge into file_locations_v3 t
+        using (
+            select 3 as id, 'C' as region, 'c1-merged' as payload
+            union all
+            select 4, 'D', 'd1'
+        ) s
+        on t.id = s.id
+        when matched then update set payload = s.payload
+        when not matched then insert (id, region, payload) values (s.id, 
s.region, s.payload)
+    """
+    assertFilesUnderDataLocation("file_locations_v3", "MERGE")
+    assertTrue(countFiles("file_locations_v3", "upper(file_format) = 
'PUFFIN'") > 0,
+            "DELETE / UPDATE / MERGE wrote no deletion vectors")
+}


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to