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 be7243a4894 [improvement](be) Make Iceberg data file writes 
synchronous (#69355)
be7243a4894 is described below

commit be7243a4894c0641216fb7a1555631f91511e5fb
Author: Mingyu Chen (Rayner) <[email protected]>
AuthorDate: Sun Oct 25 08:06:45 2026 +0000

    [improvement](be) Make Iceberg data file writes synchronous (#69355)
    
    ### What problem does this PR solve?
    
    Issue Number: None
    
    Related PR: #67087
    
    Problem Summary:
    
    **In short.** This ports branch-4.1 #67087 to master. The normal and the
    spill Iceberg table sinks stop being async writer sinks: they own the
    Iceberg table writer and open, write and close it on the pipeline task,
    scheduled on the blocking scheduler, the same way the Paimon sink
    already works on master. Master's per-partition memory reservation and
    spill revocation stay as they are.
    
    **Background.** On master both Iceberg data file sinks
    (`IcebergTableSinkLocalState`, and `SpillIcebergTableSinkLocalState` for
    tables with a sort order) are `AsyncWriterSink`s. Every block is moved
    into a queue, and a thread taken from the fragment manager's thread pool
    opens the files, writes every block and closes the writer for the whole
    statement, while the pipeline task only enqueues. To keep memory
    admission correct with that hand-off, master moves each pipeline
    reservation into the queue together with its block, holds the EOS
    reservation in the writer thread until `close()` finishes, and makes the
    spill sink admit a block only after the previous one has been fully
    processed, because the reservation it computes samples sorter state that
    the writer thread updates.
    
    **The problem, and what it cost.**
    
    | | Master today | After this PR |
    |---|---|---|
    | Who writes the files | A fragment-pool thread per sink instance, for
    the whole statement | The pipeline task, on the blocking scheduler |
    | Blocks in flight per instance | Up to 3 blocks parked in the async
    queue | None, each block is written in `sink()` |
    | Spill sink pipelining | None: each block waits until the writer thread
    has processed the previous one | Same work, without the thread hop |
    | Memory reservation | Transferred through the queue; the EOS
    reservation is held by the writer thread through `finish()`/`close()` |
    The EOS `sink()` call itself closes the writer under its reservation |
    | Final spill merge output block | `batch_size()` rows, which for wide
    rows is far larger than the spill buffer the merge reservation charges
    for it | At most one spill batch of rows (≈ spill buffer size) |
    
    **How this PR fixes it.**
    
    - `VIcebergTableWriter` no longer derives from `AsyncResultWriter`; it
    keeps the output expressions and operator profile itself. `open`,
    `write`, `close` and `finish_deferred_file_cleanup` are virtual so unit
    tests can replace the writer.
    - `IcebergTableSinkLocalState` and `SpillIcebergTableSinkLocalState`
    derive from `PipelineXSinkLocalState<FakeSharedState>`, are blockable,
    create the writer in `init()`, clone the output expressions and open it
    in `open()`, and write each non-empty block in `sink()`. Their `close()`
    folds a cancellation seen before or during the writer close into the
    final status, and calls `finish_deferred_file_cleanup()` with that
    status, so a statement cancelled after the files were closed deletes
    them instead of handing them to the final report.
    - Both sinks close the writer in the EOS `sink()` call (idempotently
    with `close()`, which closes it only when the task ends before EOS),
    because the pipeline task holds the sink reservation only while `sink()`
    runs. For the normal sink, closing flushes what every partition file
    still buffers, such as the last half-filled Parquet row group; its EOS
    call reserves the sink default
    (`minimum_operator_memory_required_bytes()`), the amount master's async
    path held until the writer closed. For the spill sink, the final sort
    and spill merge run there; its EOS reservation already includes the
    merge workspace of the largest partition
    (`iceberg_spill_merge_workspace`) and now also covers the work that runs
    inside `sink()`. Master's per-partition reservation, cold-writer
    reservation and largest-partition-first revocation are kept. A mutex
    guards the spill sink's writer pointer so a memory callback that arrives
    after `close()` sees the released writer.
    - The final spill merge of `VIcebergSortWriter` emits `min(batch_size,
    spill batch rows)` rows per block, matching the per-block size the merge
    reservation assumes.
    - `AsyncResultWriter::wait_for_processing_before_next_sink()` and the
    waiting mode of the async queue admission only existed for the Iceberg
    spill sink and are removed. The other async writers (Hive, MaxCompute,
    JDBC, TVF, OLAP, result file) always used the plain bounded queue, whose
    behavior does not change.
    - DELETE and MERGE sinks stay asynchronous. The MERGE sink keeps using
    `VIcebergTableWriter` as its nested data file writer.
    
    **Results.**
    
    - Unit tests: all 329 tests of the Iceberg suites (`*Iceberg*`,
    including the 18 cases of the new `IcebergTableSinkOperatorTest`),
    `FullSorterTest`, `SorterReserveMemoryTest` and `AsyncResultWriterTest`
    pass. Four mutations (the final merge batch back at `batch_size()` rows,
    no writer close in the spill sink's EOS `sink()`, and two of the
    cancellation checks around the writer close removed) make 7 of the
    original 13 cases fail. Three mutations of the normal sink (no writer
    close in its EOS `sink()`, a second writer close in `close()`, no
    cancellation check at EOS) make 5, 5 and 1 of its cases fail.
    - Regression test: the new `test_iceberg_write_sorted_spill` writes
    60000 rows into a REST catalog table sorted by `id desc nulls last` and
    partitioned by `region`, with forced spilling and
    `spill_min_revocable_mem = 1`. On a local cluster the BE paused the
    INSERT three times to revoke `SPILL_ICEBERG_TABLE_SINK_OPERATOR`; each
    partition was still committed as one data file of 20000 rows, and Spark
    reads back 60000 rows, the expected sum, and no row out of order inside
    any file (`lag(id) over (partition by _file order by _pos)`). All 43
    suites of `external_table_p0/iceberg/write` pass on the same cluster.
    - Manual test: on a local `file://` Iceberg catalog the same
    forced-spill INSERT wrote 12 spill files (4 per partition) before the
    final merge, every data file read back in descending `id` order, and
    UPDATE / DELETE / MERGE on the table returned the expected rows.
    
    <details>
    <summary>Classes and call graph</summary>
    
    ```
    PipelineTask::execute (blocking scheduler, sink is_blockable() == true)
     ├─ IcebergTableSinkOperatorX::sink_impl
     │    └─ IcebergTableSinkLocalState::sink
     │         ├─ VIcebergTableWriter::write
     │         └─ eos: _close_writer ──> VIcebergTableWriter::close
     │                                     (flushes the files every partition 
writer still buffers)
     ├─ SpillIcebergTableSinkOperatorX::get_reserve_mem_size (master model, 
unchanged)
     ├─ SpillIcebergTableSinkOperatorX::sink_impl
     │    └─ SpillIcebergTableSinkLocalState::sink
     │         ├─ VIcebergTableWriter::write ──> VIcebergSortWriter::write (per 
partition)
     │         └─ eos: _close_writer ──> VIcebergTableWriter::close ──> 
VIcebergSortWriter::close
     │                                     (final sort / spill merge, capped 
output batch)
     └─ PipelineTask::close ──> *LocalState::close
            └─ cancellation check ─ writer close (if not yet) ─ cancellation 
check
               ─ VIcebergTableWriter::finish_deferred_file_cleanup(final status)
    
    RevokableTask (while the task is paused)
     └─ SpillIcebergTableSinkLocalState::revoke_memory
          └─ under _writer_mutex: active_writers() snapshot ──> largest 
VIcebergSortWriter::trigger_spill
    ```
    
    </details>
    
    Co-authored-by: daidai <[email protected]>
---
 .../exec/operator/iceberg_table_sink_operator.cpp  |  82 ++-
 be/src/exec/operator/iceberg_table_sink_operator.h |  33 +-
 be/src/exec/operator/operator.cpp                  |   2 -
 .../operator/spill_iceberg_table_sink_operator.cpp | 107 +++-
 .../operator/spill_iceberg_table_sink_operator.h   |  25 +-
 be/src/exec/sink/viceberg_merge_sink.cpp           |   3 +-
 be/src/exec/sink/writer/async_result_writer.cpp    |  16 -
 be/src/exec/sink/writer/async_result_writer.h      |   8 +-
 .../sink/writer/async_writer_queue_admission.h     |  18 -
 .../sink/writer/iceberg/viceberg_sort_writer.cpp   |   8 +-
 .../sink/writer/iceberg/viceberg_sort_writer.h     |  14 +-
 .../sink/writer/iceberg/viceberg_table_writer.cpp  |   6 +-
 .../sink/writer/iceberg/viceberg_table_writer.h    |  24 +-
 .../operator/iceberg_table_sink_operator_test.cpp  | 591 +++++++++++++++++++++
 .../spill_iceberg_table_sink_operator_test.cpp     |  19 -
 .../external_table_writer_partition_limit_test.cpp |   2 +-
 .../writer/iceberg/iceberg_table_writer_test.cpp   |  10 +-
 .../writer/iceberg/viceberg_table_writer_test.cpp  |  18 +-
 be/test/exec/sort/full_sort_test.cpp               |   4 +-
 .../write/test_iceberg_write_sorted_spill.out      |  16 +
 .../write/test_iceberg_write_sorted_spill.groovy   | 114 ++++
 21 files changed, 977 insertions(+), 143 deletions(-)

diff --git a/be/src/exec/operator/iceberg_table_sink_operator.cpp 
b/be/src/exec/operator/iceberg_table_sink_operator.cpp
index 8d2074e3a49..777aa1569b0 100644
--- a/be/src/exec/operator/iceberg_table_sink_operator.cpp
+++ b/be/src/exec/operator/iceberg_table_sink_operator.cpp
@@ -24,9 +24,87 @@ Status IcebergTableSinkLocalState::init(RuntimeState* state, 
LocalSinkStateInfo&
     RETURN_IF_ERROR(Base::init(state, info));
     SCOPED_TIMER(exec_time_counter());
     SCOPED_TIMER(_init_timer);
-    auto& p = _parent->cast<Parent>();
-    RETURN_IF_ERROR(_writer->init_properties(p._pool, p._row_desc));
+    _writer = std::make_unique<VIcebergTableWriter>(info.tsink, 
_output_vexpr_ctxs);
+    _writer->defer_file_cleanup_until_outer_close();
+    auto& parent = _parent->cast<Parent>();
+    RETURN_IF_ERROR(_writer->init_properties(parent._pool, parent._row_desc));
     return Status::OK();
 }
 
+Status IcebergTableSinkLocalState::open(RuntimeState* state) {
+    SCOPED_TIMER(exec_time_counter());
+    SCOPED_TIMER(_open_timer);
+    RETURN_IF_ERROR(Base::open(state));
+
+    auto& parent = _parent->cast<Parent>();
+    _output_vexpr_ctxs.resize(parent._output_vexpr_ctxs.size());
+    for (size_t i = 0; i < _output_vexpr_ctxs.size(); ++i) {
+        RETURN_IF_ERROR(parent._output_vexpr_ctxs[i]->clone(state, 
_output_vexpr_ctxs[i]));
+    }
+    return _writer->open(state, operator_profile());
+}
+
+Status IcebergTableSinkLocalState::sink(RuntimeState* state, Block* block, 
bool eos) {
+    if (block->rows() > 0) {
+        DCHECK(_writer);
+        RETURN_IF_ERROR(_writer->write(state, *block));
+    }
+    if (!eos) {
+        return Status::OK();
+    }
+
+    // The pipeline task holds the sink reservation only while sink() runs. 
Closing the writer
+    // flushes what each partition file still buffers, e.g. the last 
half-filled Parquet row group,
+    // whose final pages grow the page and compression buffers. Done in 
close(), that flush would
+    // run after the EOS reservation was released, so close the writer here.
+    Status close_status = Status::OK();
+    if (state->is_cancelled()) {
+        close_status = state->cancel_reason();
+    }
+    return _close_writer(close_status);
+}
+
+Status IcebergTableSinkLocalState::_close_writer(Status close_status) {
+    DCHECK(_writer);
+    if (!_writer_closed) {
+        _writer_close_status = _writer->close(close_status);
+        _writer_closed = true;
+    }
+    if (close_status.ok() && !_writer_close_status.ok()) {
+        close_status = _writer_close_status;
+    }
+    return close_status;
+}
+
+Status IcebergTableSinkLocalState::close(RuntimeState* state, Status 
exec_status) {
+    if (_closed) {
+        return Status::OK();
+    }
+
+    SCOPED_TIMER(exec_time_counter());
+    SCOPED_TIMER(_close_timer);
+
+    DCHECK(_writer);
+    Status final_status = exec_status;
+    // Observe cancellation before close so a known-cancelled write is not 
finalized as successful.
+    if (final_status.ok() && state->is_cancelled()) {
+        final_status = state->cancel_reason();
+    }
+    // A task that reached EOS already closed the writer in sink(); an error 
or cancellation before
+    // EOS closes it here with that status.
+    final_status = _close_writer(final_status);
+    // close() may block on sort/merge/file I/O, so cancellation can arrive 
while it is running.
+    if (final_status.ok() && state->is_cancelled()) {
+        final_status = state->cancel_reason();
+    }
+    _writer->finish_deferred_file_cleanup(final_status);
+    _writer.reset();
+
+    Status base_status = Base::close(state, final_status);
+    if (final_status.ok() && !base_status.ok()) {
+        final_status = base_status;
+    }
+    return final_status;
+}
+
 } // namespace doris
diff --git a/be/src/exec/operator/iceberg_table_sink_operator.h 
b/be/src/exec/operator/iceberg_table_sink_operator.h
index a91950af7a7..ab6cb1dc508 100644
--- a/be/src/exec/operator/iceberg_table_sink_operator.h
+++ b/be/src/exec/operator/iceberg_table_sink_operator.h
@@ -17,6 +17,8 @@
 
 #pragma once
 
+#include <memory>
+
 #include "exec/operator/operator.h"
 #include "exec/sink/writer/iceberg/viceberg_table_writer.h"
 
@@ -24,21 +26,30 @@ namespace doris {
 
 class IcebergTableSinkOperatorX;
 
-class IcebergTableSinkLocalState final
-        : public AsyncWriterSink<VIcebergTableWriter, 
IcebergTableSinkOperatorX> {
+class IcebergTableSinkLocalState final : public 
PipelineXSinkLocalState<FakeSharedState> {
 public:
-    using Base = AsyncWriterSink<VIcebergTableWriter, 
IcebergTableSinkOperatorX>;
+    using Base = PipelineXSinkLocalState<FakeSharedState>;
     using Parent = IcebergTableSinkOperatorX;
     ENABLE_FACTORY_CREATOR(IcebergTableSinkLocalState);
     IcebergTableSinkLocalState(DataSinkOperatorXBase* parent, RuntimeState* 
state)
             : Base(parent, state) {};
     Status init(RuntimeState* state, LocalSinkStateInfo& info) override;
-    Status open(RuntimeState* state) override {
-        SCOPED_TIMER(exec_time_counter());
-        SCOPED_TIMER(_open_timer);
-        return Base::open(state);
-    }
+    Status open(RuntimeState* state) override;
+    Status sink(RuntimeState* state, Block* block, bool eos);
+    Status close(RuntimeState* state, Status exec_status) override;
+
+    // Writes run synchronously, so open, write and close must use the 
blocking scheduler.
+    [[nodiscard]] bool is_blockable() const override { return true; }
+
+private:
+    Status _close_writer(Status close_status);
     friend class IcebergTableSinkOperatorX;
+    friend class IcebergTableSinkOperatorTest;
+
+    VExprContextSPtrs _output_vexpr_ctxs;
+    std::unique_ptr<VIcebergTableWriter> _writer;
+    bool _writer_closed = false;
+    Status _writer_close_status;
 };
 
 class IcebergTableSinkOperatorX final : public 
DataSinkOperatorX<IcebergTableSinkLocalState> {
@@ -73,16 +84,10 @@ public:
 
 private:
     friend class IcebergTableSinkLocalState;
-    template <typename Writer, typename Parent>
-        requires(std::is_base_of_v<AsyncResultWriter, Writer>)
-    friend class AsyncWriterSink;
     const RowDescriptor& _row_desc;
     VExprContextSPtrs _output_vexpr_ctxs;
     const std::vector<TExpr>& _t_output_expr;
     ObjectPool* _pool = nullptr;
 };
 
-/// Instantiated once in operator.cpp; suppresses per-TU implicit 
instantiation.
-extern template class AsyncWriterSink<VIcebergTableWriter, 
IcebergTableSinkOperatorX>;
-
 } // namespace doris
diff --git a/be/src/exec/operator/operator.cpp 
b/be/src/exec/operator/operator.cpp
index 1213acce8e2..a3f97fb873b 100644
--- a/be/src/exec/operator/operator.cpp
+++ b/be/src/exec/operator/operator.cpp
@@ -971,8 +971,6 @@ template class AsyncWriterSink<doris::VJdbcTableWriter, 
JdbcTableSinkOperatorX>;
 template class AsyncWriterSink<doris::VTabletWriter, OlapTableSinkOperatorX>;
 template class AsyncWriterSink<doris::VTabletWriterV2, 
OlapTableSinkV2OperatorX>;
 template class AsyncWriterSink<doris::VHiveTableWriter, 
HiveTableSinkOperatorX>;
-template class AsyncWriterSink<doris::VIcebergTableWriter, 
IcebergTableSinkOperatorX>;
-template class AsyncWriterSink<doris::VIcebergTableWriter, 
SpillIcebergTableSinkOperatorX>;
 template class AsyncWriterSink<doris::VIcebergDeleteSink, 
IcebergDeleteSinkOperatorX>;
 template class AsyncWriterSink<doris::VIcebergMergeSink, 
IcebergMergeSinkOperatorX>;
 template class AsyncWriterSink<doris::VMCTableWriter, MCTableSinkOperatorX>;
diff --git a/be/src/exec/operator/spill_iceberg_table_sink_operator.cpp 
b/be/src/exec/operator/spill_iceberg_table_sink_operator.cpp
index 18a5b95f643..87fae242e70 100644
--- a/be/src/exec/operator/spill_iceberg_table_sink_operator.cpp
+++ b/be/src/exec/operator/spill_iceberg_table_sink_operator.cpp
@@ -46,15 +46,15 @@ 
SpillIcebergTableSinkLocalState::SpillIcebergTableSinkLocalState(DataSinkOperato
 
 Status SpillIcebergTableSinkLocalState::init(RuntimeState* state, 
LocalSinkStateInfo& info) {
     RETURN_IF_ERROR(Base::init(state, info));
-    // Admission samples async sorter state, so the next block must wait until 
the prior append publishes it.
-    _writer->wait_for_processing_before_next_sink();
     SCOPED_TIMER(exec_time_counter());
     SCOPED_TIMER(_init_timer);
 
     _init_spill_counters();
+    _writer = std::make_unique<VIcebergTableWriter>(info.tsink, 
_output_vexpr_ctxs);
+    _writer->defer_file_cleanup_until_outer_close();
 
-    auto& p = _parent->cast<Parent>();
-    RETURN_IF_ERROR(_writer->init_properties(p._pool, p._row_desc));
+    auto& parent = _parent->cast<Parent>();
+    RETURN_IF_ERROR(_writer->init_properties(parent._pool, parent._row_desc));
     return Status::OK();
 }
 
@@ -62,7 +62,74 @@ Status SpillIcebergTableSinkLocalState::open(RuntimeState* 
state) {
     SCOPED_TIMER(Base::exec_time_counter());
     SCOPED_TIMER(Base::_open_timer);
     RETURN_IF_ERROR(Base::open(state));
-    return Status::OK();
+
+    auto& parent = _parent->cast<Parent>();
+    _output_vexpr_ctxs.resize(parent._output_vexpr_ctxs.size());
+    for (size_t i = 0; i < _output_vexpr_ctxs.size(); ++i) {
+        RETURN_IF_ERROR(parent._output_vexpr_ctxs[i]->clone(state, 
_output_vexpr_ctxs[i]));
+    }
+    return _writer->open(state, operator_profile());
+}
+
+Status SpillIcebergTableSinkLocalState::sink(RuntimeState* state, Block* 
block, bool eos) {
+    if (block->rows() > 0) {
+        DCHECK(_writer);
+        RETURN_IF_ERROR(_writer->write(state, *block));
+    }
+    if (!eos) {
+        return Status::OK();
+    }
+
+    // Finish sorting and spill merging here so they run under the EOS 
reservation.
+    Status close_status = Status::OK();
+    if (state->is_cancelled()) {
+        close_status = state->cancel_reason();
+    }
+    return _close_writer(close_status);
+}
+
+Status SpillIcebergTableSinkLocalState::_close_writer(Status close_status) {
+    DCHECK(_writer);
+    if (!_writer_closed) {
+        _writer_close_status = _writer->close(close_status);
+        _writer_closed = true;
+    }
+    if (close_status.ok() && !_writer_close_status.ok()) {
+        close_status = _writer_close_status;
+    }
+    return close_status;
+}
+
+Status SpillIcebergTableSinkLocalState::close(RuntimeState* state, Status 
exec_status) {
+    if (_closed) {
+        return Status::OK();
+    }
+
+    SCOPED_TIMER(exec_time_counter());
+    SCOPED_TIMER(_close_timer);
+
+    Status final_status = exec_status;
+    // Observe cancellation before close so a known-cancelled write is not 
finalized as successful.
+    if (final_status.ok() && state->is_cancelled()) {
+        final_status = state->cancel_reason();
+    }
+    final_status = _close_writer(final_status);
+    // close() may block on sort/merge/file I/O, so cancellation can arrive 
while it is running.
+    if (final_status.ok() && state->is_cancelled()) {
+        final_status = state->cancel_reason();
+    }
+    DCHECK(_writer);
+    _writer->finish_deferred_file_cleanup(final_status);
+    {
+        std::lock_guard lock(_writer_mutex);
+        _writer.reset();
+    }
+
+    Status base_status = Base::close(state, final_status);
+    if (final_status.ok() && !base_status.ok()) {
+        final_status = base_status;
+    }
+    return final_status;
 }
 
 bool SpillIcebergTableSinkLocalState::is_blockable() const {
@@ -71,9 +138,7 @@ bool SpillIcebergTableSinkLocalState::is_blockable() const {
 
 size_t SpillIcebergTableSinkLocalState::get_reserve_mem_size(RuntimeState* 
state, bool eos,
                                                              const Block* 
block) {
-    if (!_writer) {
-        return 0;
-    }
+    DCHECK(_writer);
     std::vector<IcebergSorterReserveMemory> per_partition_reservations;
     const size_t incoming_rows = block == nullptr ? 0 : block->rows();
     const size_t incoming_bytes = block == nullptr ? 0 : 
block->allocated_bytes();
@@ -90,7 +155,7 @@ size_t 
SpillIcebergTableSinkLocalState::get_reserve_mem_size(RuntimeState* state
         }
     }
     // Column growth remains in every touched sorter, while sorting workspace 
is reused by serial dispatch.
-    // The final queued item may contain rows and also owns the reservation 
used by async finish().
+    // The EOS block may contain rows, and its reservation also covers the 
writer close in sink().
     const size_t incoming_reserve =
             block == nullptr ? state->minimum_operator_memory_required_bytes()
                              : iceberg_cold_writer_reserve_size(
@@ -100,12 +165,16 @@ size_t 
SpillIcebergTableSinkLocalState::get_reserve_mem_size(RuntimeState* state
 }
 
 size_t SpillIcebergTableSinkLocalState::get_revocable_mem_size(RuntimeState* 
state) const {
-    if (!_writer) {
-        return 0;
+    // Retain the published container while the pipeline task may replace the 
current snapshot.
+    std::shared_ptr<VIcebergTableWriter::ActiveWriterSnapshot> active_writers;
+    {
+        std::lock_guard lock(_writer_mutex);
+        if (!_writer) {
+            return 0;
+        }
+        active_writers = _writer->active_writers();
     }
     size_t revocable_size = 0;
-    // Retain the published container while the async writer may replace the 
current snapshot.
-    auto active_writers = _writer->active_writers();
     for (const auto& writer : *active_writers) {
         if (auto* sort_writer = 
dynamic_cast<VIcebergSortWriter*>(writer.get())) {
             revocable_size += sort_writer->data_size();
@@ -116,13 +185,17 @@ size_t 
SpillIcebergTableSinkLocalState::get_revocable_mem_size(RuntimeState* sta
 
 Status SpillIcebergTableSinkLocalState::revoke_memory(RuntimeState* state) {
     RETURN_IF_CANCELLED(state);
-    if (!_writer) {
-        return Status::OK();
+    // Retain the published container while the pipeline task may replace the 
current snapshot.
+    std::shared_ptr<VIcebergTableWriter::ActiveWriterSnapshot> active_writers;
+    {
+        std::lock_guard lock(_writer_mutex);
+        if (!_writer) {
+            return Status::OK();
+        }
+        active_writers = _writer->active_writers();
     }
     std::shared_ptr<IPartitionWriterBase> largest_writer;
     size_t largest_size = 0;
-    // Retain the published container while the async writer may replace the 
current snapshot.
-    auto active_writers = _writer->active_writers();
     for (const auto& writer : *active_writers) {
         if (auto* sort_writer = 
dynamic_cast<VIcebergSortWriter*>(writer.get())) {
             size_t size = sort_writer->data_size();
diff --git a/be/src/exec/operator/spill_iceberg_table_sink_operator.h 
b/be/src/exec/operator/spill_iceberg_table_sink_operator.h
index e05d0c48164..f7a925af35e 100644
--- a/be/src/exec/operator/spill_iceberg_table_sink_operator.h
+++ b/be/src/exec/operator/spill_iceberg_table_sink_operator.h
@@ -18,6 +18,7 @@
 #pragma once
 
 #include <memory>
+#include <mutex>
 #include <vector>
 
 #include "exec/operator/iceberg_sorter_reserve_memory.h"
@@ -29,10 +30,9 @@ namespace doris {
 class SpillIcebergTableSinkLocalState;
 class SpillIcebergTableSinkOperatorX;
 
-class SpillIcebergTableSinkLocalState final
-        : public AsyncWriterSink<VIcebergTableWriter, 
SpillIcebergTableSinkOperatorX> {
+class SpillIcebergTableSinkLocalState final : public 
PipelineXSinkLocalState<FakeSharedState> {
 public:
-    using Base = AsyncWriterSink<VIcebergTableWriter, 
SpillIcebergTableSinkOperatorX>;
+    using Base = PipelineXSinkLocalState<FakeSharedState>;
     using Parent = SpillIcebergTableSinkOperatorX;
     ENABLE_FACTORY_CREATOR(SpillIcebergTableSinkLocalState);
 
@@ -41,6 +41,8 @@ public:
 
     Status init(RuntimeState* state, LocalSinkStateInfo& info) override;
     Status open(RuntimeState* state) override;
+    Status sink(RuntimeState* state, Block* block, bool eos);
+    Status close(RuntimeState* state, Status exec_status) override;
 
     bool is_blockable() const override;
     [[nodiscard]] size_t get_reserve_mem_size(RuntimeState* state, bool eos, 
const Block* block);
@@ -48,8 +50,19 @@ public:
     size_t get_revocable_mem_size(RuntimeState* state) const;
 
 private:
+    Status _close_writer(Status close_status);
     void _init_spill_counters();
     friend class SpillIcebergTableSinkOperatorX;
+    friend class IcebergTableSinkOperatorTest;
+
+    VExprContextSPtrs _output_vexpr_ctxs;
+    // Protects table-writer pointer access/reset against concurrent 
workload-memory callbacks.
+    // Blocking I/O stays outside this lock; a callback keeps the published 
writer snapshot whose
+    // sorter mutexes serialize it with write/close.
+    mutable std::mutex _writer_mutex;
+    std::unique_ptr<VIcebergTableWriter> _writer;
+    bool _writer_closed = false;
+    Status _writer_close_status;
 };
 
 class SpillIcebergTableSinkOperatorX final
@@ -79,9 +92,6 @@ public:
 
 private:
     friend class SpillIcebergTableSinkLocalState;
-    template <typename Writer, typename Parent>
-        requires(std::is_base_of_v<AsyncResultWriter, Writer>)
-    friend class AsyncWriterSink;
 
     const RowDescriptor& _row_desc;
     VExprContextSPtrs _output_vexpr_ctxs;
@@ -89,7 +99,4 @@ private:
     ObjectPool* _pool = nullptr;
 };
 
-/// Instantiated once in operator.cpp; suppresses per-TU implicit 
instantiation.
-extern template class AsyncWriterSink<VIcebergTableWriter, 
SpillIcebergTableSinkOperatorX>;
-
 } // namespace doris
diff --git a/be/src/exec/sink/viceberg_merge_sink.cpp 
b/be/src/exec/sink/viceberg_merge_sink.cpp
index 4548f0449b6..c4eb9709e2e 100644
--- a/be/src/exec/sink/viceberg_merge_sink.cpp
+++ b/be/src/exec/sink/viceberg_merge_sink.cpp
@@ -56,8 +56,7 @@ Status VIcebergMergeSink::init_properties(ObjectPool* pool, 
const RowDescriptor&
     RETURN_IF_ERROR(_build_inner_sinks());
 
     if (_writes_data_files) {
-        _table_writer = std::make_unique<VIcebergTableWriter>(_table_sink, 
_table_output_expr_ctxs,
-                                                              nullptr, 
nullptr);
+        _table_writer = std::make_unique<VIcebergTableWriter>(_table_sink, 
_table_output_expr_ctxs);
         _table_writer->defer_file_cleanup_until_outer_close();
         RETURN_IF_ERROR(_table_writer->init_properties(pool, row_desc));
     }
diff --git a/be/src/exec/sink/writer/async_result_writer.cpp 
b/be/src/exec/sink/writer/async_result_writer.cpp
index 6397dc660e0..84f9fe18ff5 100644
--- a/be/src/exec/sink/writer/async_result_writer.cpp
+++ b/be/src/exec/sink/writer/async_result_writer.cpp
@@ -81,7 +81,6 @@ AsyncResultWriter::QueuedBlock 
AsyncResultWriter::_get_block_from_queue() {
     DCHECK(!_data_queue.empty());
     auto queued = std::move(_data_queue.front());
     _data_queue.pop_front();
-    _queue_admission.begin_processing();
     DCHECK(_dependency);
     if (_data_queue_is_available()) {
         _dependency->set_ready();
@@ -92,17 +91,6 @@ AsyncResultWriter::QueuedBlock 
AsyncResultWriter::_get_block_from_queue() {
     return queued;
 }
 
-void AsyncResultWriter::_notify_block_processed() {
-    if (!_queue_admission.waits_for_processing()) {
-        return;
-    }
-    std::lock_guard l(_m);
-    _queue_admission.finish_processing();
-    if (_data_queue_is_available()) {
-        _dependency->set_ready();
-    }
-}
-
 Status AsyncResultWriter::start_writer(RuntimeState* state, RuntimeProfile* 
operator_profile) {
     // Attention!!!
     // AsyncResultWriter::open is called asynchronously,
@@ -196,7 +184,6 @@ void AsyncResultWriter::process_block(RuntimeState* state, 
RuntimeProfile* opera
         if (!status.ok()) [[unlikely]] {
             thread_context()->thread_mem_tracker_mgr->shrink_reserved();
             std::unique_lock l(_m);
-            _queue_admission.finish_processing();
             _writer_status.update(status);
             if (_is_finished() || _data_queue_is_available()) {
                 _dependency->set_ready();
@@ -211,11 +198,9 @@ void AsyncResultWriter::process_block(RuntimeState* state, 
RuntimeProfile* opera
             // Some writers finalize buffered data in close(), so the EOS 
reservation must outlive
             // both finish() and close() instead of being released between the 
two callbacks.
             reservation_held_for_finalize = true;
-            _notify_block_processed();
             break;
         }
         thread_context()->thread_mem_tracker_mgr->shrink_reserved();
-        _notify_block_processed();
     }
 
     {
@@ -225,7 +210,6 @@ void AsyncResultWriter::process_block(RuntimeState* state, 
RuntimeProfile* opera
                 _memory_used_counter->update(-queued.block->allocated_bytes());
             }
         });
-        _queue_admission.finish_processing();
         _dependency->set_ready();
     }
 
diff --git a/be/src/exec/sink/writer/async_result_writer.h 
b/be/src/exec/sink/writer/async_result_writer.h
index fe851a9171a..8366e599f70 100644
--- a/be/src/exec/sink/writer/async_result_writer.h
+++ b/be/src/exec/sink/writer/async_result_writer.h
@@ -72,10 +72,6 @@ public:
 
     void set_low_memory_mode();
 
-    void wait_for_processing_before_next_sink() {
-        _queue_admission.wait_for_processing_before_next_sink();
-    }
-
 protected:
     Status _projection_block(Block& input_block, Block* output_block);
     const VExprContextSPtrs& _vec_output_expr_ctxs;
@@ -92,14 +88,13 @@ private:
 
     void process_block(RuntimeState* state, RuntimeProfile* operator_profile);
     [[nodiscard]] bool _data_queue_is_available() const {
-        return _queue_admission.is_available(_data_queue.size());
+        return _data_queue.size() < ASYNC_WRITER_QUEUE_SIZE;
     }
     [[nodiscard]] bool _is_finished() const { return !_writer_status.ok() || 
_eos; }
     void _set_ready_to_finish();
 
     void _return_free_block(std::unique_ptr<Block>);
     QueuedBlock _get_block_from_queue();
-    void _notify_block_processed();
 
     std::mutex _m;
     std::condition_variable _cv;
@@ -107,7 +102,6 @@ private:
     // Default value is ok
     AtomicStatus _writer_status;
     bool _eos = false;
-    AsyncWriterQueueAdmission _queue_admission;
     std::atomic_bool _low_memory_mode = false;
 
     std::shared_ptr<Dependency> _dependency;
diff --git a/be/src/exec/sink/writer/async_writer_queue_admission.h 
b/be/src/exec/sink/writer/async_writer_queue_admission.h
index b5a73cb72aa..64020120ef0 100644
--- a/be/src/exec/sink/writer/async_writer_queue_admission.h
+++ b/be/src/exec/sink/writer/async_writer_queue_admission.h
@@ -23,24 +23,6 @@ namespace doris {
 
 inline constexpr size_t ASYNC_WRITER_QUEUE_SIZE = 3;
 
-class AsyncWriterQueueAdmission {
-public:
-    void wait_for_processing_before_next_sink() { _wait_for_processing = true; 
}
-    void begin_processing() { _block_being_processed = _wait_for_processing; }
-    void finish_processing() { _block_being_processed = false; }
-
-    [[nodiscard]] bool is_available(size_t queued_blocks) const {
-        return _wait_for_processing ? queued_blocks == 0 && 
!_block_being_processed
-                                    : queued_blocks < ASYNC_WRITER_QUEUE_SIZE;
-    }
-
-    [[nodiscard]] bool waits_for_processing() const { return 
_wait_for_processing; }
-
-private:
-    bool _block_being_processed = false;
-    bool _wait_for_processing = false;
-};
-
 template <typename Queue, typename BeforeRelease>
 void drain_async_writer_queue(Queue& queue, BeforeRelease before_release) {
     for (const auto& queued : queue) {
diff --git a/be/src/exec/sink/writer/iceberg/viceberg_sort_writer.cpp 
b/be/src/exec/sink/writer/iceberg/viceberg_sort_writer.cpp
index e99fabe9a73..c9a6426db7c 100644
--- a/be/src/exec/sink/writer/iceberg/viceberg_sort_writer.cpp
+++ b/be/src/exec/sink/writer/iceberg/viceberg_sort_writer.cpp
@@ -388,8 +388,12 @@ Status VIcebergSortWriter::_create_merger(bool 
is_final_merge, size_t batch_size
 }
 
 Status VIcebergSortWriter::_create_final_merger() {
-    // Final merger uses the runtime batch size and merges all remaining 
streams
-    return _create_merger(true, _runtime_state->batch_size(), 1);
+    // Keep the final output within both the normal runtime batch and the 
spill-buffer estimate.
+    return _create_merger(true, _final_merge_batch_row_count(), 1);
+}
+
+size_t VIcebergSortWriter::_final_merge_batch_row_count() const {
+    return std::min<size_t>(_runtime_state->batch_size(), 
_spill_block_batch_row_count);
 }
 
 void VIcebergSortWriter::_cleanup_spill_streams() {
diff --git a/be/src/exec/sink/writer/iceberg/viceberg_sort_writer.h 
b/be/src/exec/sink/writer/iceberg/viceberg_sort_writer.h
index b41c3182843..0cc61148e45 100644
--- a/be/src/exec/sink/writer/iceberg/viceberg_sort_writer.h
+++ b/be/src/exec/sink/writer/iceberg/viceberg_sort_writer.h
@@ -101,7 +101,7 @@ public:
 
     inline size_t written_len() const override { return 
_iceberg_partition_writer->written_len(); }
 
-    size_t data_size() const;
+    virtual size_t data_size() const;
 
     size_t get_reserve_mem_size(RuntimeState* state, bool eos) const;
 
@@ -112,9 +112,11 @@ public:
                                                         size_t incoming_bytes) 
const;
 
     // Called by the memory management system to trigger spilling data to disk
-    Status trigger_spill();
+    virtual Status trigger_spill();
 
 private:
+    friend class IcebergTableSinkOperatorTest;
+
     void _include_spill_merge_reservation(RuntimeState* state, bool eos,
                                           SorterReserveMemory* reservation) 
const;
 
@@ -161,6 +163,8 @@ private:
     // Create the final merger that merges all remaining spill streams
     Status _create_final_merger();
 
+    size_t _final_merge_batch_row_count() const;
+
     // Release all spill stream resources (both pending and currently merging)
     void _cleanup_spill_streams();
 
@@ -179,12 +183,12 @@ private:
     std::unique_ptr<FullSorter> _sorter;
     std::unique_ptr<VSortedRunMerger> _merger;
 
-    // Serialize all accesses to _sorter because async writes and revoke 
spills run on
-    // different thread pools but touch the same FullSorter instance.
+    // Serialize all accesses to _sorter because pipeline-task writes and 
revoke spills run on
+    // different threads but touch the same FullSorter instance.
     mutable std::mutex _sorter_mutex;
 
     // Set to true once close() has finished tearing down the sorter / 
underlying writer.
-    // Late-arriving revoke spills (which run on a different thread than the 
async writer)
+    // Late-arriving revoke spills (which run on a different thread than the 
pipeline task)
     // must become no-ops after close, otherwise they would write to a fresh 
spill stream
     // whose data never gets merged out (close has already produced the final 
output and
     // cleaned up spill files).
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 95f78cc0678..61cb105460d 100644
--- a/be/src/exec/sink/writer/iceberg/viceberg_table_writer.cpp
+++ b/be/src/exec/sink/writer/iceberg/viceberg_table_writer.cpp
@@ -49,10 +49,8 @@
 namespace doris {
 
 VIcebergTableWriter::VIcebergTableWriter(const TDataSink& t_sink,
-                                         const VExprContextSPtrs& 
output_expr_ctxs,
-                                         std::shared_ptr<Dependency> dep,
-                                         std::shared_ptr<Dependency> fin_dep)
-        : AsyncResultWriter(output_expr_ctxs, dep, fin_dep), _t_sink(t_sink) {
+                                         const VExprContextSPtrs& 
output_expr_ctxs)
+        : _vec_output_expr_ctxs(output_expr_ctxs), _t_sink(t_sink) {
     DCHECK(_t_sink.__isset.iceberg_table_sink);
     _active_writers.store(std::make_shared<ActiveWriterSnapshot>());
 }
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 48b7d24e126..36eb5c08aa0 100644
--- a/be/src/exec/sink/writer/iceberg/viceberg_table_writer.h
+++ b/be/src/exec/sink/writer/iceberg/viceberg_table_writer.h
@@ -21,9 +21,9 @@
 #include <gtest/gtest_prod.h>
 
 #include "common/atomic_shared_ptr.h"
+#include "common/status.h"
 #include "core/block/block.h"
 #include "core/column/column.h"
-#include "exec/sink/writer/async_result_writer.h"
 #include "exec/sink/writer/iceberg/partition_data.h"
 #include "exec/sink/writer/iceberg/partition_transformers.h"
 #include "exprs/vexpr_fwd.h"
@@ -38,6 +38,7 @@ class FileSystem;
 }
 
 class ObjectPool;
+class RowDescriptor;
 class RuntimeState;
 
 class IColumn;
@@ -45,29 +46,28 @@ class IPartitionWriterBase;
 class VIcebergSortWriter;
 struct ColumnWithTypeAndName;
 
-class VIcebergTableWriter final : public AsyncResultWriter {
+class VIcebergTableWriter {
 public:
-    VIcebergTableWriter(const TDataSink& t_sink, const VExprContextSPtrs& 
output_exprs,
-                        std::shared_ptr<Dependency> dep, 
std::shared_ptr<Dependency> fin_dep);
+    VIcebergTableWriter(const TDataSink& t_sink, const VExprContextSPtrs& 
output_exprs);
 
-    ~VIcebergTableWriter() = default;
+    virtual ~VIcebergTableWriter() = default;
 
     Status init_properties(ObjectPool* pool, const RowDescriptor& row_desc) {
         _row_desc = &row_desc;
         return Status::OK();
     }
 
-    Status open(RuntimeState* state, RuntimeProfile* profile) override;
+    virtual Status open(RuntimeState* state, RuntimeProfile* profile);
 
-    Status write(RuntimeState* state, Block& block) override;
+    virtual Status write(RuntimeState* state, Block& block);
 
     Status write_prepared_block(Block& block);
 
-    Status close(Status) override;
+    virtual Status close(Status);
 
     void defer_file_cleanup_until_outer_close() { 
_defer_file_cleanup_until_outer_close = true; }
 
-    void finish_deferred_file_cleanup(Status outer_status);
+    virtual void finish_deferred_file_cleanup(Status outer_status);
 
     bool is_rewrite_compaction() const { return _write_type == 
TIcebergWriteType::REWRITE; }
 
@@ -83,8 +83,10 @@ private:
     FRIEND_TEST(VIcebergTableWriterTest, 
RejectsNestedRequiredNullBeforeWriterCreation);
     // The lifecycle fixture inspects snapshots to verify that cross-thread 
writer ownership stays stable.
     friend class VIcebergTableWriterLifecycleTest;
+    friend class IcebergTableSinkOperatorTest;
 
-    // The spill thread needs a stable view of every partition sorter, while 
the async writer owns the map.
+    // Memory revocation needs a stable view of every partition sorter, while 
the pipeline task owns
+    // the map.
     doris::atomic_shared_ptr<ActiveWriterSnapshot> _active_writers;
     class IcebergPartitionColumn {
     public:
@@ -157,9 +159,11 @@ private:
     void _cleanup_closed_files();
     void _transfer_closed_files_to_report_cleanup();
 
+    const VExprContextSPtrs& _vec_output_expr_ctxs;
     // Currently it is a copy, maybe it is better to use move semantics to 
eliminate it.
     TDataSink _t_sink;
     RuntimeState* _state = nullptr;
+    RuntimeProfile* _operator_profile = nullptr;
 
     // Target file size in bytes for controlling when to split files
     int64_t _target_file_size_bytes = 0;
diff --git a/be/test/exec/operator/iceberg_table_sink_operator_test.cpp 
b/be/test/exec/operator/iceberg_table_sink_operator_test.cpp
new file mode 100644
index 00000000000..9273040a8a9
--- /dev/null
+++ b/be/test/exec/operator/iceberg_table_sink_operator_test.cpp
@@ -0,0 +1,591 @@
+// 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/operator/iceberg_table_sink_operator.h"
+
+#include <gtest/gtest.h>
+
+#include <functional>
+#include <limits>
+#include <memory>
+#include <string>
+#include <type_traits>
+#include <utility>
+#include <vector>
+
+#include "common/object_pool.h"
+#include "core/data_type/data_type_number.h"
+#include "core/data_type/data_type_string.h"
+#include "exec/operator/spill_iceberg_table_sink_operator.h"
+#include "exec/sink/writer/async_result_writer.h"
+#include "exec/sink/writer/iceberg/viceberg_sort_writer.h"
+#include "runtime/runtime_profile.h"
+#include "testutil/column_helper.h"
+#include "testutil/mock/mock_descriptors.h"
+#include "testutil/mock/mock_runtime_state.h"
+#include "testutil/mock/mock_slot_ref.h"
+
+namespace doris {
+
+static_assert(!std::is_base_of_v<AsyncResultWriter, VIcebergTableWriter>);
+
+namespace {
+
+constexpr size_t MB = 1024 * 1024;
+
+TDataSink make_iceberg_table_sink() {
+    TIcebergTableSink iceberg_sink;
+    TDataSink sink;
+    sink.__set_type(TDataSinkType::ICEBERG_TABLE_SINK);
+    sink.__set_iceberg_table_sink(iceberg_sink);
+    return sink;
+}
+
+struct FakeWriterState {
+    Status open_result;
+    Status write_result;
+    Status close_result;
+    int open_count = 0;
+    int write_count = 0;
+    int close_count = 0;
+    int cleanup_count = 0;
+    size_t written_rows = 0;
+    std::vector<Status> close_inputs;
+    Status cleanup_status;
+    std::function<void()> close_hook;
+};
+
+class FakeIcebergTableWriter final : public VIcebergTableWriter {
+public:
+    FakeIcebergTableWriter(const VExprContextSPtrs& output_exprs,
+                           std::shared_ptr<FakeWriterState> state)
+            : VIcebergTableWriter(make_iceberg_table_sink(), output_exprs),
+              _fake_state(std::move(state)) {}
+
+    Status open(RuntimeState*, RuntimeProfile*) override {
+        ++_fake_state->open_count;
+        return _fake_state->open_result;
+    }
+
+    Status write(RuntimeState*, Block& block) override {
+        ++_fake_state->write_count;
+        _fake_state->written_rows += block.rows();
+        return _fake_state->write_result;
+    }
+
+    Status close(Status status) override {
+        ++_fake_state->close_count;
+        _fake_state->close_inputs.emplace_back(status);
+        if (_fake_state->close_hook) {
+            _fake_state->close_hook();
+        }
+        return _fake_state->close_result;
+    }
+
+    void finish_deferred_file_cleanup(Status status) override {
+        ++_fake_state->cleanup_count;
+        _fake_state->cleanup_status = status;
+    }
+
+private:
+    std::shared_ptr<FakeWriterState> _fake_state;
+};
+
+struct FakeSortWriterState {
+    size_t data_size = 0;
+    int spill_count = 0;
+};
+
+class FakeRevocableIcebergSortWriter final : public VIcebergSortWriter {
+public:
+    explicit 
FakeRevocableIcebergSortWriter(std::shared_ptr<FakeSortWriterState> state)
+            : VIcebergSortWriter(nullptr, TSortInfo {}, 0), 
_fake_state(std::move(state)) {}
+
+    size_t data_size() const override { return _fake_state->data_size; }
+
+    Status trigger_spill() override {
+        ++_fake_state->spill_count;
+        _fake_state->data_size = 0;
+        return Status::OK();
+    }
+
+private:
+    std::shared_ptr<FakeSortWriterState> _fake_state;
+};
+
+} // namespace
+
+class IcebergTableSinkOperatorTest : public testing::Test {
+protected:
+    template <typename Parent, typename LocalState>
+    void initialize_local_state(Parent* parent, LocalState* local_state, 
MockRuntimeState* state,
+                                std::shared_ptr<FakeWriterState>* fake_state) {
+        TDataSink sink = make_iceberg_table_sink();
+        ASSERT_TRUE(parent->init(sink).ok());
+
+        auto shared_state = parent->create_shared_state();
+        LocalSinkStateInfo info {.task_idx = 0,
+                                 .parent_profile = &_parent_profile,
+                                 .sender_id = 0,
+                                 .shared_state = shared_state.get(),
+                                 .shared_state_map = {},
+                                 .tsink = sink};
+        ASSERT_TRUE(local_state->init(state, info).ok());
+
+        *fake_state = std::make_shared<FakeWriterState>();
+        auto writer = std::make_unique<FakeIcebergTableWriter>(_output_exprs, 
*fake_state);
+        local_state->_writer = std::move(writer);
+        ASSERT_TRUE(local_state->open(state).ok());
+    }
+
+    void set_partition_writers(SpillIcebergTableSinkLocalState* local_state,
+                               std::shared_ptr<VIcebergSortWriter> first,
+                               std::shared_ptr<VIcebergSortWriter> second) {
+        local_state->_writer->_partitions_to_writers.emplace("first", 
std::move(first));
+        local_state->_writer->_partitions_to_writers.emplace("second", 
std::move(second));
+        local_state->_writer->_publish_active_writers();
+    }
+
+    void mark_spilled(VIcebergSortWriter* writer, size_t count = 1) {
+        for (size_t i = 0; i < count; ++i) {
+            writer->_sorted_spill_files.emplace_back(nullptr);
+        }
+    }
+
+    void initialize_sort_writer_for_write(VIcebergSortWriter* writer, 
RuntimeState* state,
+                                          const DataTypePtr& data_type) {
+        _sort_row_desc =
+                std::make_unique<MockRowDescriptor>(std::vector<DataTypePtr> 
{data_type}, &_pool);
+        writer->_runtime_state = state;
+        writer->_ordering_expr_ctxs = MockSlotRef::create_mock_contexts(0, 
data_type);
+        writer->_sort_info.is_asc_order = {true};
+        writer->_sort_info.nulls_first = {false};
+        writer->_sorter = FullSorter::create_unique(
+                writer->_ordering_expr_ctxs, -1, 0, &writer->_pool, 
writer->_sort_info.is_asc_order,
+                writer->_sort_info.nulls_first, *_sort_row_desc, state, 
nullptr);
+        writer->_sorter->set_enable_spill();
+        writer->_target_file_size_bytes = std::numeric_limits<int64_t>::max();
+    }
+
+    std::pair<size_t, size_t> spill_batch_state(const VIcebergSortWriter& 
writer) const {
+        return {writer._avg_row_bytes, writer._spill_block_batch_row_count};
+    }
+
+    size_t final_merge_batch_row_count(const VIcebergSortWriter& writer) const 
{
+        return writer._final_merge_batch_row_count();
+    }
+
+    ObjectPool _pool;
+    RowDescriptor _row_desc;
+    std::unique_ptr<MockRowDescriptor> _sort_row_desc;
+    VExprContextSPtrs _output_exprs;
+    RuntimeProfile _parent_profile {"IcebergTableSinkOperatorTest"};
+};
+
+TEST_F(IcebergTableSinkOperatorTest, 
SyncWritersUseBlockingSchedulerWithoutDependencies) {
+    IcebergTableSinkLocalState table_sink(nullptr, nullptr);
+    SpillIcebergTableSinkLocalState spill_sink(nullptr, nullptr);
+
+    EXPECT_TRUE(table_sink.is_blockable());
+    EXPECT_TRUE(spill_sink.is_blockable());
+    EXPECT_TRUE(table_sink.dependencies().empty());
+    EXPECT_TRUE(spill_sink.dependencies().empty());
+    EXPECT_EQ(table_sink.finishdependency(), nullptr);
+    EXPECT_EQ(spill_sink.finishdependency(), nullptr);
+}
+
+TEST_F(IcebergTableSinkOperatorTest, NormalSinkFinalizesEmptyEosAfterData) {
+    MockRuntimeState state;
+    std::vector<TExpr> thrift_exprs;
+    IcebergTableSinkOperatorX parent(&_pool, 1, _row_desc, thrift_exprs);
+    IcebergTableSinkLocalState local_state(&parent, &state);
+    std::shared_ptr<FakeWriterState> fake_state;
+    initialize_local_state(&parent, &local_state, &state, &fake_state);
+
+    Block data = ColumnHelper::create_block<DataTypeInt32>({1, 2, 3});
+    Block empty = ColumnHelper::create_block<DataTypeInt32>({});
+    ASSERT_TRUE(local_state.sink(&state, &data, false).ok());
+    EXPECT_EQ(fake_state->close_count, 0);
+    // The pipeline task releases the EOS reservation as soon as sink() 
returns, so the writer
+    // must already be closed, with its buffered files flushed, when the EOS 
call returns.
+    ASSERT_TRUE(local_state.sink(&state, &empty, true).ok());
+    EXPECT_EQ(fake_state->write_count, 1);
+    EXPECT_EQ(fake_state->written_rows, 3);
+    EXPECT_EQ(fake_state->close_count, 1);
+    ASSERT_EQ(fake_state->close_inputs.size(), 1);
+    EXPECT_TRUE(fake_state->close_inputs.front().ok());
+    EXPECT_EQ(fake_state->cleanup_count, 0);
+
+    ASSERT_TRUE(local_state.close(&state, Status::OK()).ok());
+    EXPECT_EQ(fake_state->close_count, 1);
+    EXPECT_EQ(fake_state->cleanup_count, 1);
+    EXPECT_TRUE(fake_state->cleanup_status.ok());
+}
+
+TEST_F(IcebergTableSinkOperatorTest, NormalSinkFinalizesNonEmptyEos) {
+    MockRuntimeState state;
+    std::vector<TExpr> thrift_exprs;
+    IcebergTableSinkOperatorX parent(&_pool, 1, _row_desc, thrift_exprs);
+    IcebergTableSinkLocalState local_state(&parent, &state);
+    std::shared_ptr<FakeWriterState> fake_state;
+    initialize_local_state(&parent, &local_state, &state, &fake_state);
+
+    Block data = ColumnHelper::create_block<DataTypeInt32>({1, 2, 3});
+    ASSERT_TRUE(local_state.sink(&state, &data, true).ok());
+    EXPECT_EQ(fake_state->write_count, 1);
+    EXPECT_EQ(fake_state->close_count, 1);
+    ASSERT_TRUE(local_state.close(&state, Status::OK()).ok());
+    EXPECT_EQ(fake_state->close_count, 1);
+    EXPECT_EQ(fake_state->cleanup_count, 1);
+    EXPECT_TRUE(fake_state->cleanup_status.ok());
+}
+
+TEST_F(IcebergTableSinkOperatorTest, NormalSinkEosObservesCancellation) {
+    MockRuntimeState state;
+    std::vector<TExpr> thrift_exprs;
+    IcebergTableSinkOperatorX parent(&_pool, 1, _row_desc, thrift_exprs);
+    IcebergTableSinkLocalState local_state(&parent, &state);
+    std::shared_ptr<FakeWriterState> fake_state;
+    initialize_local_state(&parent, &local_state, &state, &fake_state);
+
+    state.cancel(Status::Cancelled("cancel normal Iceberg sink before EOS"));
+    Block empty = ColumnHelper::create_block<DataTypeInt32>({});
+    Status sink_status = local_state.sink(&state, &empty, true);
+    EXPECT_TRUE(sink_status.is<ErrorCode::CANCELLED>()) << 
sink_status.to_string();
+    ASSERT_EQ(fake_state->close_inputs.size(), 1);
+    EXPECT_TRUE(fake_state->close_inputs.front().is<ErrorCode::CANCELLED>());
+
+    Status close_status = local_state.close(&state, sink_status);
+    EXPECT_TRUE(close_status.is<ErrorCode::CANCELLED>()) << 
close_status.to_string();
+    EXPECT_EQ(fake_state->close_count, 1);
+    EXPECT_EQ(fake_state->cleanup_count, 1);
+    EXPECT_TRUE(fake_state->cleanup_status.is<ErrorCode::CANCELLED>());
+}
+
+TEST_F(IcebergTableSinkOperatorTest, NormalSinkWriteErrorClosesWithError) {
+    MockRuntimeState state;
+    std::vector<TExpr> thrift_exprs;
+    IcebergTableSinkOperatorX parent(&_pool, 1, _row_desc, thrift_exprs);
+    IcebergTableSinkLocalState local_state(&parent, &state);
+    std::shared_ptr<FakeWriterState> fake_state;
+    initialize_local_state(&parent, &local_state, &state, &fake_state);
+    fake_state->write_result = Status::InternalError("injected write failure");
+
+    Block data = ColumnHelper::create_block<DataTypeInt32>({1});
+    Status sink_status = local_state.sink(&state, &data, true);
+    EXPECT_TRUE(sink_status.is<ErrorCode::INTERNAL_ERROR>()) << 
sink_status.to_string();
+    EXPECT_EQ(fake_state->close_count, 0);
+
+    Status close_status = local_state.close(&state, sink_status);
+    EXPECT_TRUE(close_status.is<ErrorCode::INTERNAL_ERROR>()) << 
close_status.to_string();
+    EXPECT_EQ(fake_state->close_count, 1);
+    ASSERT_EQ(fake_state->close_inputs.size(), 1);
+    
EXPECT_TRUE(fake_state->close_inputs.front().is<ErrorCode::INTERNAL_ERROR>());
+    EXPECT_EQ(fake_state->cleanup_count, 1);
+    EXPECT_TRUE(fake_state->cleanup_status.is<ErrorCode::INTERNAL_ERROR>());
+}
+
+TEST_F(IcebergTableSinkOperatorTest, 
NormalSinkCloseErrorPropagatesAndCleansUp) {
+    MockRuntimeState state;
+    std::vector<TExpr> thrift_exprs;
+    IcebergTableSinkOperatorX parent(&_pool, 1, _row_desc, thrift_exprs);
+    IcebergTableSinkLocalState local_state(&parent, &state);
+    std::shared_ptr<FakeWriterState> fake_state;
+    initialize_local_state(&parent, &local_state, &state, &fake_state);
+    fake_state->close_result = Status::InternalError("injected close failure");
+
+    Block empty = ColumnHelper::create_block<DataTypeInt32>({});
+    Status sink_status = local_state.sink(&state, &empty, true);
+    EXPECT_TRUE(sink_status.is<ErrorCode::INTERNAL_ERROR>()) << 
sink_status.to_string();
+    EXPECT_EQ(fake_state->close_count, 1);
+
+    Status close_status = local_state.close(&state, sink_status);
+    EXPECT_TRUE(close_status.is<ErrorCode::INTERNAL_ERROR>()) << 
close_status.to_string();
+    EXPECT_EQ(fake_state->close_count, 1);
+    EXPECT_EQ(fake_state->cleanup_count, 1);
+    EXPECT_TRUE(fake_state->cleanup_status.is<ErrorCode::INTERNAL_ERROR>());
+}
+
+TEST_F(IcebergTableSinkOperatorTest, 
NormalSinkCancellationAfterEosDeletesDeferredFiles) {
+    MockRuntimeState state;
+    std::vector<TExpr> thrift_exprs;
+    IcebergTableSinkOperatorX parent(&_pool, 1, _row_desc, thrift_exprs);
+    IcebergTableSinkLocalState local_state(&parent, &state);
+    std::shared_ptr<FakeWriterState> fake_state;
+    initialize_local_state(&parent, &local_state, &state, &fake_state);
+
+    Block empty = ColumnHelper::create_block<DataTypeInt32>({});
+    ASSERT_TRUE(local_state.sink(&state, &empty, true).ok());
+    EXPECT_EQ(fake_state->close_count, 1);
+
+    state.cancel(Status::Cancelled("cancel normal Iceberg sink after EOS"));
+    Status close_status = local_state.close(&state, Status::OK());
+    EXPECT_TRUE(close_status.is<ErrorCode::CANCELLED>()) << 
close_status.to_string();
+    EXPECT_EQ(fake_state->close_count, 1);
+    EXPECT_EQ(fake_state->cleanup_count, 1);
+    EXPECT_TRUE(fake_state->cleanup_status.is<ErrorCode::CANCELLED>());
+}
+
+TEST_F(IcebergTableSinkOperatorTest, NormalSinkConvertsOkCloseToCancellation) {
+    MockRuntimeState state;
+    std::vector<TExpr> thrift_exprs;
+    IcebergTableSinkOperatorX parent(&_pool, 1, _row_desc, thrift_exprs);
+    IcebergTableSinkLocalState local_state(&parent, &state);
+    std::shared_ptr<FakeWriterState> fake_state;
+    initialize_local_state(&parent, &local_state, &state, &fake_state);
+
+    state.cancel(Status::Cancelled("cancel normal Iceberg sink"));
+    Status close_status = local_state.close(&state, Status::OK());
+    EXPECT_TRUE(close_status.is<ErrorCode::CANCELLED>()) << 
close_status.to_string();
+    ASSERT_EQ(fake_state->close_inputs.size(), 1);
+    EXPECT_TRUE(fake_state->close_inputs.front().is<ErrorCode::CANCELLED>());
+}
+
+TEST_F(IcebergTableSinkOperatorTest, 
NormalSinkObservesCancellationDuringWriterClose) {
+    MockRuntimeState state;
+    std::vector<TExpr> thrift_exprs;
+    IcebergTableSinkOperatorX parent(&_pool, 1, _row_desc, thrift_exprs);
+    IcebergTableSinkLocalState local_state(&parent, &state);
+    std::shared_ptr<FakeWriterState> fake_state;
+    initialize_local_state(&parent, &local_state, &state, &fake_state);
+    fake_state->close_hook = [&state]() {
+        state.cancel(Status::Cancelled("cancel during normal writer close"));
+    };
+
+    Status close_status = local_state.close(&state, Status::OK());
+    EXPECT_TRUE(close_status.is<ErrorCode::CANCELLED>()) << 
close_status.to_string();
+    ASSERT_EQ(fake_state->close_inputs.size(), 1);
+    EXPECT_TRUE(fake_state->close_inputs.front().ok());
+    EXPECT_EQ(fake_state->cleanup_count, 1);
+    EXPECT_TRUE(fake_state->cleanup_status.is<ErrorCode::CANCELLED>());
+}
+
+TEST_F(IcebergTableSinkOperatorTest, SpillSinkFinalizesNonEmptyEos) {
+    MockRuntimeState state;
+    std::vector<TExpr> thrift_exprs;
+    SpillIcebergTableSinkOperatorX parent(&_pool, 1, _row_desc, thrift_exprs);
+    SpillIcebergTableSinkLocalState local_state(&parent, &state);
+    std::shared_ptr<FakeWriterState> fake_state;
+    initialize_local_state(&parent, &local_state, &state, &fake_state);
+
+    Block data = ColumnHelper::create_block<DataTypeInt32>({1, 2, 3});
+    ASSERT_TRUE(local_state.sink(&state, &data, true).ok());
+    EXPECT_EQ(fake_state->write_count, 1);
+    EXPECT_EQ(fake_state->close_count, 1);
+    ASSERT_TRUE(local_state.close(&state, Status::OK()).ok());
+    EXPECT_EQ(fake_state->close_count, 1);
+    EXPECT_EQ(fake_state->cleanup_count, 1);
+    EXPECT_TRUE(fake_state->cleanup_status.ok());
+}
+
+TEST_F(IcebergTableSinkOperatorTest, SpillSinkFinalizesEmptyEosAfterData) {
+    MockRuntimeState state;
+    std::vector<TExpr> thrift_exprs;
+    SpillIcebergTableSinkOperatorX parent(&_pool, 1, _row_desc, thrift_exprs);
+    SpillIcebergTableSinkLocalState local_state(&parent, &state);
+    std::shared_ptr<FakeWriterState> fake_state;
+    initialize_local_state(&parent, &local_state, &state, &fake_state);
+
+    Block data = ColumnHelper::create_block<DataTypeInt32>({1, 2, 3});
+    Block empty = ColumnHelper::create_block<DataTypeInt32>({});
+    ASSERT_TRUE(local_state.sink(&state, &data, false).ok());
+    EXPECT_EQ(fake_state->close_count, 0);
+    ASSERT_TRUE(local_state.sink(&state, &empty, true).ok());
+    EXPECT_EQ(fake_state->close_count, 1);
+
+    ASSERT_TRUE(local_state.close(&state, Status::OK()).ok());
+    EXPECT_EQ(fake_state->close_count, 1);
+    EXPECT_EQ(fake_state->cleanup_count, 1);
+}
+
+TEST_F(IcebergTableSinkOperatorTest, 
SpillSinkReservesEosAdmissionAndLargestPartitionMerge) {
+    MockRuntimeState state;
+    std::vector<TExpr> thrift_exprs;
+    SpillIcebergTableSinkOperatorX parent(&_pool, 1, _row_desc, thrift_exprs);
+    SpillIcebergTableSinkLocalState local_state(&parent, &state);
+    std::shared_ptr<FakeWriterState> fake_state;
+    initialize_local_state(&parent, &local_state, &state, &fake_state);
+    const size_t admission_floor = 
state.minimum_operator_memory_required_bytes();
+
+    EXPECT_EQ(local_state.get_reserve_mem_size(&state, true, nullptr), 
admission_floor);
+
+    // The writer is closed inside the EOS sink() call, so the EOS reservation 
must already cover
+    // the final spill merge of the largest partition.
+    auto empty_writer = std::make_shared<VIcebergSortWriter>(nullptr, 
TSortInfo {}, 0);
+    auto spilled_writer = std::make_shared<VIcebergSortWriter>(nullptr, 
TSortInfo {}, 0);
+    initialize_sort_writer_for_write(spilled_writer.get(), &state,
+                                     std::make_shared<DataTypeInt32>());
+    auto* spilled_writer_ptr = spilled_writer.get();
+    mark_spilled(spilled_writer.get());
+    set_partition_writers(&local_state, std::move(empty_writer), 
std::move(spilled_writer));
+    // One spill run: one 8 MiB input buffer plus one 8 MiB output buffer.
+    EXPECT_EQ(local_state.get_reserve_mem_size(&state, true, nullptr), 16 * MB 
+ admission_floor);
+
+    mark_spilled(spilled_writer_ptr, 7);
+    // Eight runs fit in the 64 MiB merge limit: eight inputs plus one output 
buffer.
+    EXPECT_EQ(local_state.get_reserve_mem_size(&state, true, nullptr), 72 * MB 
+ admission_floor);
+    EXPECT_EQ(local_state.get_reserve_mem_size(&state, false, nullptr), 
admission_floor);
+
+    TQueryOptions query_options = state.query_options();
+    query_options.__set_spill_buffer_size_bytes(256 * MB);
+    query_options.__set_spill_sort_merge_mem_limit_bytes(MB);
+    state.set_query_options(query_options);
+    // The fan-in never drops below two inputs, even when one buffer exceeds 
the merge limit.
+    EXPECT_EQ(local_state.get_reserve_mem_size(&state, true, nullptr), 768 * 
MB + admission_floor);
+    ASSERT_TRUE(local_state.close(&state, Status::OK()).ok());
+}
+
+TEST_F(IcebergTableSinkOperatorTest, 
SortWriterSamplesSpillBatchBeforeConsumingInput) {
+    MockRuntimeState state;
+    TQueryOptions query_options = state.query_options();
+    query_options.__set_spill_buffer_size_bytes(MB);
+    state.set_query_options(query_options);
+
+    auto data_type = std::make_shared<DataTypeString>();
+    VIcebergSortWriter writer(nullptr, TSortInfo {}, 
std::numeric_limits<int64_t>::max());
+    initialize_sort_writer_for_write(&writer, &state, data_type);
+
+    std::string wide_value(64 * 1024, 'x');
+    Block block = ColumnHelper::create_block<DataTypeString>(
+            {wide_value, wide_value, wide_value, wide_value});
+    const size_t expected_avg_row_bytes = block.bytes() / block.rows();
+    const size_t expected_batch_rows =
+            (state.spill_buffer_size_bytes() + expected_avg_row_bytes - 1) / 
expected_avg_row_bytes;
+
+    ASSERT_TRUE(writer.write(block).ok());
+    EXPECT_EQ(block.rows(), 0);
+    EXPECT_EQ(spill_batch_state(writer),
+              std::make_pair(expected_avg_row_bytes, expected_batch_rows));
+    // Wide rows must keep the final merge output within the spill-buffer 
estimate.
+    EXPECT_LT(expected_batch_rows, static_cast<size_t>(state.batch_size()));
+    EXPECT_EQ(final_merge_batch_row_count(writer), expected_batch_rows);
+}
+
+TEST_F(IcebergTableSinkOperatorTest, 
SpillSinkAccountsAllPartitionsAndRevokesLargest) {
+    MockRuntimeState state;
+    std::vector<TExpr> thrift_exprs;
+    SpillIcebergTableSinkOperatorX parent(&_pool, 1, _row_desc, thrift_exprs);
+    SpillIcebergTableSinkLocalState local_state(&parent, &state);
+    std::shared_ptr<FakeWriterState> fake_state;
+    initialize_local_state(&parent, &local_state, &state, &fake_state);
+
+    auto large_state = std::make_shared<FakeSortWriterState>();
+    large_state->data_size = MB;
+    auto small_state = std::make_shared<FakeSortWriterState>();
+    small_state->data_size = 128 * 1024;
+    set_partition_writers(&local_state,
+                          
std::make_shared<FakeRevocableIcebergSortWriter>(large_state),
+                          
std::make_shared<FakeRevocableIcebergSortWriter>(small_state));
+
+    EXPECT_EQ(local_state.get_revocable_mem_size(&state),
+              large_state->data_size + small_state->data_size);
+    ASSERT_TRUE(local_state.revoke_memory(&state).ok());
+    EXPECT_EQ(large_state->spill_count, 1);
+    EXPECT_EQ(small_state->spill_count, 0);
+    EXPECT_EQ(local_state.get_revocable_mem_size(&state), 
small_state->data_size);
+
+    ASSERT_TRUE(local_state.close(&state, Status::OK()).ok());
+    // Memory callbacks that arrive after close see the released writer and do 
nothing.
+    EXPECT_EQ(local_state.get_revocable_mem_size(&state), 0);
+    ASSERT_TRUE(local_state.revoke_memory(&state).ok());
+    EXPECT_EQ(small_state->spill_count, 0);
+}
+
+TEST_F(IcebergTableSinkOperatorTest, SpillSinkWriteErrorClosesWithError) {
+    MockRuntimeState state;
+    std::vector<TExpr> thrift_exprs;
+    SpillIcebergTableSinkOperatorX parent(&_pool, 1, _row_desc, thrift_exprs);
+    SpillIcebergTableSinkLocalState local_state(&parent, &state);
+    std::shared_ptr<FakeWriterState> fake_state;
+    initialize_local_state(&parent, &local_state, &state, &fake_state);
+    fake_state->write_result = Status::InternalError("injected write failure");
+
+    Block data = ColumnHelper::create_block<DataTypeInt32>({1});
+    Status sink_status = local_state.sink(&state, &data, true);
+    EXPECT_TRUE(sink_status.is<ErrorCode::INTERNAL_ERROR>()) << 
sink_status.to_string();
+    EXPECT_EQ(fake_state->close_count, 0);
+
+    Status close_status = local_state.close(&state, sink_status);
+    EXPECT_TRUE(close_status.is<ErrorCode::INTERNAL_ERROR>()) << 
close_status.to_string();
+    EXPECT_EQ(fake_state->close_count, 1);
+    ASSERT_EQ(fake_state->close_inputs.size(), 1);
+    
EXPECT_TRUE(fake_state->close_inputs.front().is<ErrorCode::INTERNAL_ERROR>());
+    EXPECT_EQ(fake_state->cleanup_count, 1);
+    EXPECT_TRUE(fake_state->cleanup_status.is<ErrorCode::INTERNAL_ERROR>());
+}
+
+TEST_F(IcebergTableSinkOperatorTest, SpillSinkCloseErrorPropagatesAndCleansUp) 
{
+    MockRuntimeState state;
+    std::vector<TExpr> thrift_exprs;
+    SpillIcebergTableSinkOperatorX parent(&_pool, 1, _row_desc, thrift_exprs);
+    SpillIcebergTableSinkLocalState local_state(&parent, &state);
+    std::shared_ptr<FakeWriterState> fake_state;
+    initialize_local_state(&parent, &local_state, &state, &fake_state);
+    fake_state->close_result = Status::InternalError("injected close failure");
+
+    Block empty = ColumnHelper::create_block<DataTypeInt32>({});
+    Status sink_status = local_state.sink(&state, &empty, true);
+    EXPECT_TRUE(sink_status.is<ErrorCode::INTERNAL_ERROR>()) << 
sink_status.to_string();
+    EXPECT_EQ(fake_state->close_count, 1);
+
+    Status close_status = local_state.close(&state, sink_status);
+    EXPECT_TRUE(close_status.is<ErrorCode::INTERNAL_ERROR>()) << 
close_status.to_string();
+    EXPECT_EQ(fake_state->close_count, 1);
+    EXPECT_EQ(fake_state->cleanup_count, 1);
+    EXPECT_TRUE(fake_state->cleanup_status.is<ErrorCode::INTERNAL_ERROR>());
+}
+
+TEST_F(IcebergTableSinkOperatorTest, 
SpillSinkCancellationAfterEosDeletesDeferredFiles) {
+    MockRuntimeState state;
+    std::vector<TExpr> thrift_exprs;
+    SpillIcebergTableSinkOperatorX parent(&_pool, 1, _row_desc, thrift_exprs);
+    SpillIcebergTableSinkLocalState local_state(&parent, &state);
+    std::shared_ptr<FakeWriterState> fake_state;
+    initialize_local_state(&parent, &local_state, &state, &fake_state);
+
+    Block empty = ColumnHelper::create_block<DataTypeInt32>({});
+    ASSERT_TRUE(local_state.sink(&state, &empty, true).ok());
+    EXPECT_EQ(fake_state->close_count, 1);
+
+    state.cancel(Status::Cancelled("cancel spill Iceberg sink after EOS"));
+    Status close_status = local_state.close(&state, Status::OK());
+    EXPECT_TRUE(close_status.is<ErrorCode::CANCELLED>()) << 
close_status.to_string();
+    EXPECT_EQ(fake_state->close_count, 1);
+    EXPECT_EQ(fake_state->cleanup_count, 1);
+    EXPECT_TRUE(fake_state->cleanup_status.is<ErrorCode::CANCELLED>());
+}
+
+TEST_F(IcebergTableSinkOperatorTest, 
SpillSinkObservesCancellationDuringWriterClose) {
+    MockRuntimeState state;
+    std::vector<TExpr> thrift_exprs;
+    SpillIcebergTableSinkOperatorX parent(&_pool, 1, _row_desc, thrift_exprs);
+    SpillIcebergTableSinkLocalState local_state(&parent, &state);
+    std::shared_ptr<FakeWriterState> fake_state;
+    initialize_local_state(&parent, &local_state, &state, &fake_state);
+    fake_state->close_hook = [&state]() {
+        state.cancel(Status::Cancelled("cancel during spill writer close"));
+    };
+
+    Status close_status = local_state.close(&state, Status::OK());
+    EXPECT_TRUE(close_status.is<ErrorCode::CANCELLED>()) << 
close_status.to_string();
+    ASSERT_EQ(fake_state->close_inputs.size(), 1);
+    EXPECT_TRUE(fake_state->close_inputs.front().ok());
+    EXPECT_EQ(fake_state->cleanup_count, 1);
+    EXPECT_TRUE(fake_state->cleanup_status.is<ErrorCode::CANCELLED>());
+}
+
+} // namespace doris
diff --git a/be/test/exec/operator/spill_iceberg_table_sink_operator_test.cpp 
b/be/test/exec/operator/spill_iceberg_table_sink_operator_test.cpp
index c5484468931..71ece22689b 100644
--- a/be/test/exec/operator/spill_iceberg_table_sink_operator_test.cpp
+++ b/be/test/exec/operator/spill_iceberg_table_sink_operator_test.cpp
@@ -95,25 +95,6 @@ TEST(SpillIcebergTableSinkOperatorTest, 
ReservesAllMergeInputsAndOutputAtEos) {
     EXPECT_EQ(32 * MB, iceberg_spill_merge_workspace(3, 8 * MB, 64 * MB));
 }
 
-TEST(SpillIcebergTableSinkOperatorTest, 
WaitsUntilDequeuedBlockUpdatesSorterState) {
-    AsyncWriterQueueAdmission stateful_admission;
-    stateful_admission.wait_for_processing_before_next_sink();
-
-    EXPECT_TRUE(stateful_admission.is_available(0));
-    EXPECT_FALSE(stateful_admission.is_available(1));
-    stateful_admission.begin_processing();
-    // Dequeueing does not admit block 2 until block 1 changes the state 
sampled by admission.
-    EXPECT_FALSE(stateful_admission.is_available(0));
-    stateful_admission.finish_processing();
-    EXPECT_TRUE(stateful_admission.is_available(0));
-
-    // Writers without state-dependent admission retain the existing 
three-block queue behavior.
-    AsyncWriterQueueAdmission buffered_admission;
-    buffered_admission.begin_processing();
-    EXPECT_TRUE(buffered_admission.is_available(2));
-    EXPECT_FALSE(buffered_admission.is_available(3));
-}
-
 TEST(SpillIcebergTableSinkOperatorTest, 
TerminalWriterDrainsQueuedReservations) {
     int live_reservations = 0;
     struct Reservation {
diff --git 
a/be/test/exec/sink/writer/external_table_writer_partition_limit_test.cpp 
b/be/test/exec/sink/writer/external_table_writer_partition_limit_test.cpp
index 26f3de403a6..de828950adf 100644
--- a/be/test/exec/sink/writer/external_table_writer_partition_limit_test.cpp
+++ b/be/test/exec/sink/writer/external_table_writer_partition_limit_test.cpp
@@ -95,7 +95,7 @@ TEST_P(ExternalTableWriterPartitionLimitTest, 
IcebergRejectsNextPartitionBeforeO
     sink.__set_iceberg_table_sink(TIcebergTableSink());
     VExprContextSPtrs exprs;
     iceberg::PartitionField field(1, 1000, "p", "identity");
-    VIcebergTableWriter writer(sink, exprs, nullptr, nullptr);
+    VIcebergTableWriter writer(sink, exprs);
     // open() pins the table schema, and every written block is validated 
against it.
     std::vector<iceberg::NestedField> columns;
     columns.emplace_back(true, 1, "p", 
std::make_unique<iceberg::IntegerType>(), std::nullopt);
diff --git a/be/test/exec/sink/writer/iceberg/iceberg_table_writer_test.cpp 
b/be/test/exec/sink/writer/iceberg/iceberg_table_writer_test.cpp
index ac6c100b37e..c19a00389ba 100644
--- a/be/test/exec/sink/writer/iceberg/iceberg_table_writer_test.cpp
+++ b/be/test/exec/sink/writer/iceberg/iceberg_table_writer_test.cpp
@@ -91,7 +91,7 @@ protected:
 };
 
 TEST_F(VIcebergTableWriterLifecycleTest, 
RejectsCoordinatorWithoutExternalFileReportAck) {
-    VIcebergTableWriter writer(make_sink(), {}, nullptr, nullptr);
+    VIcebergTableWriter writer(make_sink(), {});
     RuntimeState state;
     RuntimeProfile profile("test");
 
@@ -102,7 +102,7 @@ TEST_F(VIcebergTableWriterLifecycleTest, 
RejectsCoordinatorWithoutExternalFileRe
 }
 
 TEST_F(VIcebergTableWriterLifecycleTest, SelectBlockUsesRowPermutation) {
-    VIcebergTableWriter writer(make_sink(), {}, nullptr, nullptr);
+    VIcebergTableWriter writer(make_sink(), {});
     auto values = ColumnInt32::create();
     values->insert_value(10);
     values->insert_value(20);
@@ -121,7 +121,7 @@ TEST_F(VIcebergTableWriterLifecycleTest, 
SelectBlockUsesRowPermutation) {
 }
 
 TEST_F(VIcebergTableWriterLifecycleTest, 
ColdReserveCoversManyRealPartitionSelections) {
-    VIcebergTableWriter writer(make_sink(), {}, nullptr, nullptr);
+    VIcebergTableWriter writer(make_sink(), {});
     auto values = ColumnString::create();
     for (size_t i = 0; i < 128; ++i) {
         const std::string value(256 + i, static_cast<char>('a' + i % 26));
@@ -142,7 +142,7 @@ TEST_F(VIcebergTableWriterLifecycleTest, 
ColdReserveCoversManyRealPartitionSelec
 }
 
 TEST_F(VIcebergTableWriterLifecycleTest, 
ActiveWriterSnapshotContainsEveryOpenPartition) {
-    VIcebergTableWriter writer(make_sink(), {}, nullptr, nullptr);
+    VIcebergTableWriter writer(make_sink(), {});
     add_writer(&writer, "p=1");
     add_writer(&writer, "p=2");
 
@@ -153,7 +153,7 @@ TEST_F(VIcebergTableWriterLifecycleTest, 
ActiveWriterSnapshotContainsEveryOpenPa
 }
 
 TEST_F(VIcebergTableWriterLifecycleTest, 
LoadedSnapshotRetainsWritersDuringConcurrentPublication) {
-    VIcebergTableWriter writer(make_sink(), {}, nullptr, nullptr);
+    VIcebergTableWriter writer(make_sink(), {});
     std::atomic<int> destroyed = 0;
     add_writer(&writer, "p=1", 
std::make_shared<FakePartitionWriter>(&destroyed));
     publish_active_writers(&writer);
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 7ccb060ef5d..2f136adcdae 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
@@ -71,7 +71,7 @@ TEST(VIcebergTableWriterTest, 
RejectsRequiredNullBeforeWriterCreation) {
     TIcebergTableSink sink;
     TDataSink data_sink;
     data_sink.__set_iceberg_table_sink(sink);
-    VIcebergTableWriter writer(data_sink, {}, nullptr, nullptr);
+    VIcebergTableWriter writer(data_sink, {});
     writer._schema = schema;
 
     auto type = make_nullable(std::make_shared<DataTypeInt32>());
@@ -102,7 +102,7 @@ TEST(VIcebergTableWriterTest, 
RejectsNestedRequiredNullBeforeWriterCreation) {
     TIcebergTableSink sink;
     TDataSink data_sink;
     data_sink.__set_iceberg_table_sink(sink);
-    VIcebergTableWriter writer(data_sink, {}, nullptr, nullptr);
+    VIcebergTableWriter writer(data_sink, {});
     writer._schema = schema;
 
     auto child_type = make_nullable(std::make_shared<DataTypeInt32>());
@@ -144,7 +144,7 @@ TEST(VIcebergTableWriterTest, 
TimestampIdentityPreservesUtcCommitAndNull) {
     TDataSink data_sink;
     data_sink.__set_iceberg_table_sink(sink);
     VExprContextSPtrs exprs;
-    VIcebergTableWriter writer(data_sink, exprs, nullptr, nullptr);
+    VIcebergTableWriter writer(data_sink, exprs);
     RuntimeState state;
     state.set_timezone("America/New_York");
     writer._state = &state;
@@ -228,7 +228,7 @@ TEST(VIcebergTableWriterTest, 
StaticNullBinaryPartitionsKeepNullPathsAndCommitVa
         TDataSink data_sink;
         data_sink.__set_iceberg_table_sink(sink);
         VExprContextSPtrs exprs;
-        VIcebergTableWriter writer(data_sink, exprs, nullptr, nullptr);
+        VIcebergTableWriter writer(data_sink, exprs);
         writer._schema = schema;
         auto type = make_nullable(std::make_shared<DataTypeVarbinary>());
         writer._iceberg_partition_columns.emplace_back(
@@ -292,7 +292,7 @@ TEST(VIcebergTableWriterTest, 
BinaryIdentityStaticAndDynamicRouting) {
     TDataSink data_sink;
     data_sink.__set_iceberg_table_sink(sink);
     VExprContextSPtrs exprs;
-    VIcebergTableWriter writer(data_sink, exprs, nullptr, nullptr);
+    VIcebergTableWriter writer(data_sink, exprs);
     writer._schema = schema;
     writer._iceberg_partition_columns.emplace_back(
             spec->fields()[0], TYPE_VARBINARY, 0, std::vector<size_t> {},
@@ -338,7 +338,7 @@ TEST(VIcebergTableWriterTest, RejectMissingPartitionSource) 
{
     TIcebergTableSink iceberg_sink;
     TDataSink data_sink;
     data_sink.__set_iceberg_table_sink(iceberg_sink);
-    VIcebergTableWriter writer(data_sink, {}, nullptr, nullptr);
+    VIcebergTableWriter writer(data_sink, {});
     writer._schema = schema;
     writer._partition_spec = iceberg::PartitionSpecParser::from_json(schema, 
spec_json);
 
@@ -370,7 +370,7 @@ TEST(VIcebergTableWriterTest, 
VoidPartitionFieldNeedsNoSourceColumn) {
     TIcebergTableSink iceberg_sink;
     TDataSink data_sink;
     data_sink.__set_iceberg_table_sink(iceberg_sink);
-    VIcebergTableWriter writer(data_sink, output_exprs, nullptr, nullptr);
+    VIcebergTableWriter writer(data_sink, output_exprs);
     writer._schema = schema;
     writer._partition_spec = iceberg::PartitionSpecParser::from_json(schema, 
spec_json);
     writer._iceberg_partition_columns = writer._to_iceberg_partition_columns();
@@ -413,7 +413,7 @@ TEST(VIcebergTableWriterTest, 
ResolvesNestedPartitionSource) {
     TIcebergTableSink iceberg_sink;
     TDataSink data_sink;
     data_sink.__set_iceberg_table_sink(iceberg_sink);
-    VIcebergTableWriter writer(data_sink, output_exprs, nullptr, nullptr);
+    VIcebergTableWriter writer(data_sink, output_exprs);
     writer._schema = schema;
     writer._partition_spec = iceberg::PartitionSpecParser::from_json(schema, 
spec_json);
 
@@ -462,7 +462,7 @@ TEST(VIcebergTableWriterTest, 
UuidStaticPartitionAcceptsCanonicalText) {
     TDataSink data_sink;
     data_sink.__set_iceberg_table_sink(sink);
     VExprContextSPtrs exprs;
-    VIcebergTableWriter writer(data_sink, exprs, nullptr, nullptr);
+    VIcebergTableWriter writer(data_sink, exprs);
     writer._schema = schema;
     writer._iceberg_partition_columns.emplace_back(
             spec->fields()[0], TYPE_VARBINARY, 0, std::vector<size_t> {},
diff --git a/be/test/exec/sort/full_sort_test.cpp 
b/be/test/exec/sort/full_sort_test.cpp
index bd8f91b03cc..963b1820a98 100644
--- a/be/test/exec/sort/full_sort_test.cpp
+++ b/be/test/exec/sort/full_sort_test.cpp
@@ -99,7 +99,9 @@ TEST_F(FullSorterTest, test_full_sorter2) {
         EXPECT_TRUE(sorter->append_block(&block).ok());
     }
 
-    std::cout << sorter->get_reserve_mem_size(&_state, false) << std::endl;
+    size_t non_eos_reserve_size = sorter->get_reserve_mem_size(&_state, false);
+    size_t eos_reserve_size = sorter->get_reserve_mem_size(&_state, true);
+    EXPECT_GT(eos_reserve_size, non_eos_reserve_size);
 }
 
 TEST_F(FullSorterTest, EosReservationIncludesForcedSortBelowAppendThresholds) {
diff --git 
a/regression-test/data/external_table_p0/iceberg/write/test_iceberg_write_sorted_spill.out
 
b/regression-test/data/external_table_p0/iceberg/write/test_iceberg_write_sorted_spill.out
new file mode 100644
index 00000000000..c948f4b72fc
--- /dev/null
+++ 
b/regression-test/data/external_table_p0/iceberg/write/test_iceberg_write_sorted_spill.out
@@ -0,0 +1,16 @@
+-- This file is automatically generated. You should know what you did if you 
want to edit this
+-- !sorted_spill_summary --
+R0     20000   20000   0       59997   599970000       4276294
+R1     20000   20000   1       59998   599990000       4276298
+R2     20000   20000   2       59999   600010000       4276298
+
+-- !sorted_spill_rows --
+0      R0      
payload-0-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
+15838  R1      
payload-15838-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
+23757  R0      
payload-23757-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
+31676  R2      
payload-31676-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
+39595  R1      
payload-39595-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
+47514  R0      
payload-47514-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
+55433  R2      
payload-55433-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
+7919   R2      
payload-7919-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
+
diff --git 
a/regression-test/suites/external_table_p0/iceberg/write/test_iceberg_write_sorted_spill.groovy
 
b/regression-test/suites/external_table_p0/iceberg/write/test_iceberg_write_sorted_spill.groovy
new file mode 100644
index 00000000000..b8749a4501d
--- /dev/null
+++ 
b/regression-test/suites/external_table_p0/iceberg/write/test_iceberg_write_sorted_spill.groovy
@@ -0,0 +1,114 @@
+// 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.
+
+// Sorted Iceberg writes run synchronously in the spill sink. Forced spilling 
makes every
+// partition sorter write several spill runs, so the EOS sink call has to 
merge them before the
+// data files are committed.
+suite("test_iceberg_write_sorted_spill",
+        "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_sorted_spill"
+    String dbName = "iceberg_write_sorted_spill_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",
+            "meta.cache.iceberg.table.ttl-second" = "0",
+            "meta.cache.iceberg.schema.ttl-second" = "0"
+        )
+    """
+    sql """switch ${catalogName}"""
+    sql """drop database if exists ${dbName} force"""
+    sql """create database ${dbName}"""
+    sql """use ${dbName}"""
+
+    sql """drop table if exists sorted_spill"""
+    sql """
+        create table sorted_spill (
+            id bigint,
+            region string,
+            payload string
+        )
+        order by (id desc nulls last)
+        partition by list (region) ()
+        properties (
+            "format-version" = "2",
+            "write.format.default" = "parquet"
+        )
+    """
+
+    // Each partition sorter receives several MiB, while forced spilling 
revokes the sink as soon
+    // as it holds 1 MiB of sortable data.
+    sql """set enable_spill = true"""
+    sql """set enable_force_spill = true"""
+    sql """set enable_reserve_memory = true"""
+    sql """set spill_min_revocable_mem = 1"""
+    sql """
+        insert into sorted_spill
+        select number,
+               concat('R', number % 3),
+               concat('payload-', number, '-', repeat('x', 200))
+        from numbers('number' = '60000')
+    """
+    sql """set enable_force_spill = false"""
+    sql """set enable_spill = false"""
+
+    order_qt_sorted_spill_summary """
+        select region, count(*), count(distinct id), min(id), max(id), sum(id),
+               sum(length(payload))
+        from sorted_spill
+        group by region
+    """
+    order_qt_sorted_spill_rows """
+        select id, region, payload
+        from sorted_spill
+        where id % 7919 = 0
+    """
+
+    // Every committed data file must keep the table order after the spill 
runs are merged.
+    spark_iceberg """refresh table demo.${dbName}.sorted_spill"""
+    def sparkSummary = spark_iceberg """
+        select count(*), count(distinct _file), sum(id)
+        from demo.${dbName}.sorted_spill
+    """
+    assertEquals(60000L, sparkSummary[0][0] as long)
+    assertEquals(1799970000L, sparkSummary[0][2] as long)
+    def unsortedRows = spark_iceberg """
+        select count(*)
+        from (
+            select id, lag(id) over (partition by _file order by _pos) as 
previous_id
+            from demo.${dbName}.sorted_spill
+        ) ordered_rows
+        where previous_id < id
+    """
+    assertEquals(0L, unsortedRows[0][0] as long)
+}


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

Reply via email to