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

HappenLee 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 1b5dcfdb4c6 [feature](be) Add aggregate state finalizers and batch 
merging (#68312)
1b5dcfdb4c6 is described below

commit 1b5dcfdb4c6c48b34392fdc56e6b9721942632e8
Author: HappenLee <[email protected]>
AuthorDate: Mon Oct 12 11:50:16 2026 +0800

    [feature](be) Add aggregate state finalizers and batch merging (#68312)
    
    ### What problem does this PR solve?
    
    Problem Summary:
    
    A query that has already computed one aggregate state per key still
    needs an aggregate operator to obtain its final values through
    `<agg>_merge`. This repeats aggregation for the finest grouping when
    states are also reused for coarser rollups.
    
    Add the scalar `<agg>_finalize(state)` combinator. It returns one result
    for each input state, using the existing aggregate implementation and
    serialized-state representation. For example:
    
    ```sql
    SELECT k, avg_finalize(s)
    FROM (SELECT k, avg_combine(v) AS s FROM t GROUP BY k) partial;
    ```
    
    The same implementation supports aggregates such as `count`, `sum`,
    `min`, `max`, and `array_agg`. FE validates that the state's canonical
    aggregate name matches the finalizer, derives its result type, and
    treats the function as scalar. BE handles the underlying serialized
    column type, skips outer NULL payloads, and releases temporary state
    after each row. Constant inputs use the ordinary scalar constant path.
    Existing empty-state semantics and serialized formats are unchanged.
    
    MV rollup matching follows only direct MERGE/UNION state chains for the
    same aggregate and preserves the full value expressions of
    STATE/COMBINE. This prevents scalar finalizers nested in their arguments
    from making different outer aggregates or different value expressions
    appear equivalent, while retaining compatible state rollups.
    
    Grouped `*_union` and `*_merge` now delegate to the existing vectorized
    deserialize-and-merge interfaces, including the selected path. The
    nested aggregate exposes whether its vector readers need scratch: direct
    native readers receive a null scratch pointer, while readers that need
    temporary states receive batch-local aligned storage. Nested
    implementations manage construction/destruction, and the caller Arena
    retains destination allocations. This avoids shared mutable scratch
    buffers, unused scratch allocations and per-row range dispatch. The
    Nullable selected fast path reads each selected row independently so
    skipped destinations cannot shift the serialized input cursor.
    Single-place merging and FE plans are unchanged. Q67 A/B performance has
    not been measured.
    
    Scalar finalization uses `deserialize_from_column_row()` to read
    directly into its initialized empty state. Generic string-backed readers
    and native serialized column readers implement the same contract, with
    forwarding through nullable and state-combine wrappers. This removes the
    extra temporary state allocation and merge previously performed by the
    generic range interface for each finalized row; the caller retains
    responsibility for state construction, destruction and exception
    cleanup.
    
    ### Release note
    
    Add `<aggregate>_finalize(AGG_STATE)` scalar functions to retrieve each
    aggregate state's result without merging rows.
    
    ### Check List (For Author)
    
    - Test
    - [x] Regression test: test_agg_state_finalize on a fresh local ASAN BE
    + FE cluster; output generated by the standard runner, checked against
    direct original aggregates, and verified by a normal comparison run.
    - [x] Regression test: agg_state_finalize_roll_up verifies MV rejection
    for differing outer aggregates/scalar arguments and selection for
    matching rollups, including negative values and NULLs. Generated output
    was checked independently; the normal comparison run of this suite and
    test_agg_state_finalize passed.
    - [x] Unit Test: 51 FE tests passed across CombinatorRollupHandlerTest,
    CountTest, FinalizeCombinatorTest, StateCombinatorTest,
    CombineCombinatorTest, and FunctionRegistryTest. The 9 new rollup tests
    reproduced 6 failures before the fix and all pass after it. All 9
    FunctionAggStateFinalizeTest cases passed under ASAN.
    - [x] Manual test: compare grouped and empty AVG/COUNT/SUM/MIN/MAX,
    decimal AVG and ARRAY_AGG with the original aggregates; verify rollup
    AVG is 14/3 and distinguish a missing outer-join state from COUNT's
    empty state.
        - [ ] No need to test or manual test. Explain why:
    - Behavior changed:
        - [ ] No.
    - [x] Yes. Add a family of scalar finalization functions; MV rollup
    matching now preserves complete value expressions; existing
    aggregate/state evaluation semantics are unchanged.
    - Does this need documentation?
        - [ ] No.
    - [x] Yes. Usage and semantics are included in the function-combinator
    README.
    
    ### Check List (For Reviewer who merge this PR)
    
    - [ ] Confirm the release note
    - [ ] Confirm test cases
    - [ ] Confirm document
    - [ ] Add branch pick label
    
    ### Additional validation
    
    - Standard ASAN BE + FE build, FE Checkstyle, clang-format 16, build
    hygiene and source whitespace checks passed.
    - clang-tidy was attempted with the repository script and the
    production/test translation units. New-code style and added
    cognitive-complexity warnings were fixed. A fully clean run remains
    blocked by existing header diagnostics and analyzer issues (including a
    test-helper array-bound path that does not connect its asserted unary
    arity to the input array); the local tool's crashing
    `modernize-use-scoped-lock` check was disabled only for the supplemental
    analysis. No repository analysis configuration was changed.
    
    ### Batch state merge validation
    
    - `./build.sh --be -j 48` completed successfully under ASAN.
    - 19 BE unit tests passed across AggregateStateUnionTest,
    AggregateStateCombineTest and FunctionAggStateFinalizeTest. Six new
    tests cover repeated groups, nonzero offsets, empty/all-unselected
    batches, repeated batches, plain/Nullable/all-NULL SUM, Decimal SUM,
    string ARRAY_AGG and nullable string MIN. The Nullable selected-row test
    fails before its reader fix and passes afterward.
    - `test_agg_state_union_batch` and `test_agg_state_finalize` both passed
    in normal regression comparison mode. New expected output was generated
    with the standard runner and independently checked against ordinary
    aggregates, arithmetic sums, Decimal sums and lexicographic MIN. The new
    suite also verifies that plans retain sum_merge/sum_union, and compares
    ARRAY_AGG results after multiple input batches.
    - clang-format 16.0.6, header hygiene and source whitespace checks
    passed. clang-tidy was run via the repository script; a fully clean run
    remains blocked by existing diagnostics in types.h (unmatched NOLINTEND)
    and DataTypeAggState's constructor, plus existing signed/unsigned
    comparisons in the Nullable wrapper. No unrelated analysis
    configuration/source changes were made.
    - Q67 A/B performance has not been measured.
    
    ### Scratch and direct-row reader validation
    
    - 83 BE unit tests from 10 suites passed under ASAN, including
    AggregateStateUnionTest, FunctionAggStateFinalizeTest,
    AggregateFunctionExceptionTest and native aggregate reader suites.
    Coverage includes scratch requirements, selected rows, nullable
    wrappers, direct row reads, source-buffer lifetime and exception
    cleanup.
    - clang-format 16.0.6, header hygiene and source whitespace checks
    passed. Repository clang-tidy checks produced no diagnostics on the
    changed lines; a fully clean run remains blocked by the existing
    unmatched NOLINTEND in `be/src/core/types.h`.
    - No new SQL regression run or throughput benchmark was performed for
    these two commits.
    - The wide-state batch deserialization finding in [discussion
    
4217446484](https://github.com/apache/doris/pull/68312#discussion_r4217446484)
    is intentionally left unchanged in this PR, following discussion. It is
    not claimed as fixed by the scratch-allocation or direct-row
    finalization changes.
---
 be/src/exprs/aggregate/aggregate_function.h        |  18 +
 .../exprs/aggregate/aggregate_function_array_agg.h |   8 +
 be/src/exprs/aggregate/aggregate_function_avg.h    |  10 +
 be/src/exprs/aggregate/aggregate_function_bitmap.h |   9 +
 .../aggregate/aggregate_function_bitmap_agg.h      |   9 +
 be/src/exprs/aggregate/aggregate_function_count.h  |  20 +
 be/src/exprs/aggregate/aggregate_function_map_v2.h |  10 +
 .../exprs/aggregate/aggregate_function_min_max.h   |  17 +
 be/src/exprs/aggregate/aggregate_function_null.h   |   5 +-
 .../exprs/aggregate/aggregate_function_null_v2.h   |  19 +
 .../aggregate/aggregate_function_state_combine.h   |  10 +
 .../aggregate/aggregate_function_state_union.h     |  40 +-
 be/src/exprs/aggregate/aggregate_function_sum.h    |  10 +
 .../aggregate_function_uniq_distribute_key.h       |   8 +
 be/src/exprs/aggregate/helpers.h                   |   6 +
 .../exprs/function/function_agg_state_finalize.h   | 110 +++++
 be/src/exprs/vectorized_fn_call.cpp                |  34 +-
 be/test/exprs/aggregate/agg_state_union_test.cpp   | 316 +++++++++++++
 .../function/function_agg_state_finalize_test.cpp  | 516 +++++++++++++++++++++
 .../org/apache/doris/catalog/FunctionRegistry.java |   3 +-
 .../glue/translator/ExpressionTranslator.java      |   8 +
 .../mv/rollup/AggFunctionRollUpHandler.java        |  28 +-
 .../mv/rollup/BothCombinatorRollupHandler.java     |   4 +-
 .../mv/rollup/SingleCombinatorRollupHandler.java   |   2 +-
 .../functions/AggCombinerFunctionBuilder.java      |  14 +-
 .../functions/combinator/FinalizeCombinator.java   | 100 ++++
 .../expressions/functions/combinator/readme.md     |  22 +
 .../expressions/visitor/ScalarFunctionVisitor.java |   5 +
 .../mv/rollup/CombinatorRollupHandlerTest.java     | 156 +++++++
 .../combinator/FinalizeCombinatorTest.java         | 127 +++++
 .../agg_state/test_agg_state_finalize.out          |  60 +++
 .../agg_state/test_agg_state_union_batch.out       |  37 ++
 .../agg_state_finalize_roll_up.out                 |  36 ++
 .../agg_state/test_agg_state_finalize.groovy       | 104 +++++
 .../agg_state/test_agg_state_union_batch.groovy    |  79 ++++
 .../agg_state_finalize_roll_up.groovy              |  92 ++++
 36 files changed, 2031 insertions(+), 21 deletions(-)

diff --git a/be/src/exprs/aggregate/aggregate_function.h 
b/be/src/exprs/aggregate/aggregate_function.h
index ac780b7c025..a6c12dcbab5 100644
--- a/be/src/exprs/aggregate/aggregate_function.h
+++ b/be/src/exprs/aggregate/aggregate_function.h
@@ -166,9 +166,19 @@ public:
     virtual void deserialize(AggregateDataPtr __restrict place, 
BufferReadable& buf,
                              Arena&) const = 0;
 
+    /// Deserializes one row into an empty, initialized state. The caller owns 
its lifecycle
+    /// and must keep the serialized column alive until the state is destroyed.
+    virtual void deserialize_from_column_row(AggregateDataPtr __restrict place,
+                                             const IColumn& column, size_t row,
+                                             Arena& arena) const = 0;
+
     virtual void deserialize_vec(AggregateDataPtr places, const ColumnString* 
column, Arena&,
                                  size_t num_rows) const = 0;
 
+    // Returning false guarantees that both deserialize_and_merge_vec variants 
accept nullptr
+    // for rhs and never access it. Generic readers require storage for 
temporary states.
+    virtual bool needs_deserialize_and_merge_scratch() const { return true; }
+
     virtual void deserialize_and_merge_vec(const AggregateDataPtr* places, 
size_t offset,
                                            AggregateDataPtr rhs, const 
IColumn* column, Arena&,
                                            const size_t num_rows) const = 0;
@@ -629,6 +639,14 @@ public:
         }
     }
 
+    void deserialize_from_column_row(AggregateDataPtr __restrict place, const 
IColumn& column,
+                                     size_t row, Arena& arena) const override {
+        DCHECK_LT(row, column.size());
+        const auto& strings = assert_cast<const ColumnString&>(column);
+        VectorBufferReader reader(strings.get_data_at(row));
+        assert_cast<const Derived*>(this)->deserialize(place, reader, arena);
+    }
+
     void deserialize_and_merge_from_column_range(AggregateDataPtr __restrict 
place,
                                                  const IColumn& column, size_t 
begin, size_t end,
                                                  Arena& arena) const override {
diff --git a/be/src/exprs/aggregate/aggregate_function_array_agg.h 
b/be/src/exprs/aggregate/aggregate_function_array_agg.h
index 5bf6d6fbee0..e4ae23d6240 100644
--- a/be/src/exprs/aggregate/aggregate_function_array_agg.h
+++ b/be/src/exprs/aggregate/aggregate_function_array_agg.h
@@ -353,6 +353,8 @@ public:
         this->data(place).insert_result_into(to);
     }
 
+    bool needs_deserialize_and_merge_scratch() const override { return false; }
+
     void deserialize_and_merge_vec(const AggregateDataPtr* places, size_t 
offset,
                                    AggregateDataPtr rhs, const IColumn* 
column, Arena& arena,
                                    const size_t num_rows) const override {
@@ -361,6 +363,12 @@ public:
         }
     }
 
+    void deserialize_from_column_row(AggregateDataPtr __restrict place, const 
IColumn& column,
+                                     size_t row, Arena&) const override {
+        DCHECK_LT(row, column.size());
+        this->data(place).deserialize_and_merge(column, row);
+    }
+
     void deserialize_and_merge_from_column_range(AggregateDataPtr __restrict 
place,
                                                  const IColumn& column, size_t 
begin, size_t end,
                                                  Arena& arena) const override {
diff --git a/be/src/exprs/aggregate/aggregate_function_avg.h 
b/be/src/exprs/aggregate/aggregate_function_avg.h
index 83eab79215e..01ac01b5196 100644
--- a/be/src/exprs/aggregate/aggregate_function_avg.h
+++ b/be/src/exprs/aggregate/aggregate_function_avg.h
@@ -273,6 +273,14 @@ public:
         }
     }
 
+    void deserialize_from_column_row(AggregateDataPtr __restrict place, const 
IColumn& column,
+                                     size_t row, Arena&) const override {
+        DCHECK_LT(row, column.size());
+        const auto& col = assert_cast<const ColumnFixedLengthObject&>(column);
+        const auto* data = reinterpret_cast<const 
Data*>(col.get_data().data());
+        this->data(place) = data[row];
+    }
+
     NO_SANITIZE_UNDEFINED void deserialize_and_merge_from_column_range(
             AggregateDataPtr __restrict place, const IColumn& column, size_t 
begin, size_t end,
             Arena&) const override {
@@ -287,6 +295,8 @@ public:
         }
     }
 
+    bool needs_deserialize_and_merge_scratch() const override { return false; }
+
     void deserialize_and_merge_vec(const AggregateDataPtr* places, size_t 
offset,
                                    AggregateDataPtr rhs, const IColumn* 
column, Arena& arena,
                                    const size_t num_rows) const override {
diff --git a/be/src/exprs/aggregate/aggregate_function_bitmap.h 
b/be/src/exprs/aggregate/aggregate_function_bitmap.h
index e60ab9769d4..f65ab22b787 100644
--- a/be/src/exprs/aggregate/aggregate_function_bitmap.h
+++ b/be/src/exprs/aggregate/aggregate_function_bitmap.h
@@ -186,6 +186,13 @@ public:
         }
     }
 
+    void deserialize_from_column_row(AggregateDataPtr __restrict place, const 
IColumn& column,
+                                     size_t row, Arena&) const override {
+        DCHECK_LT(row, column.size());
+        const auto& col = assert_cast<const ColumnBitmap&>(column);
+        this->data(place).merge(col.get_data()[row]);
+    }
+
     void deserialize_and_merge_from_column_range(AggregateDataPtr __restrict 
place,
                                                  const IColumn& column, size_t 
begin, size_t end,
                                                  Arena&) const override {
@@ -198,6 +205,8 @@ public:
         }
     }
 
+    bool needs_deserialize_and_merge_scratch() const override { return false; }
+
     void deserialize_and_merge_vec(const AggregateDataPtr* places, size_t 
offset,
                                    AggregateDataPtr rhs, const IColumn* 
column, Arena&,
                                    const size_t num_rows) const override {
diff --git a/be/src/exprs/aggregate/aggregate_function_bitmap_agg.h 
b/be/src/exprs/aggregate/aggregate_function_bitmap_agg.h
index 8d6af091066..75319df1287 100644
--- a/be/src/exprs/aggregate/aggregate_function_bitmap_agg.h
+++ b/be/src/exprs/aggregate/aggregate_function_bitmap_agg.h
@@ -172,6 +172,13 @@ public:
         }
     }
 
+    void deserialize_from_column_row(AggregateDataPtr __restrict place, const 
IColumn& column,
+                                     size_t row, Arena&) const override {
+        DCHECK_LT(row, column.size());
+        const auto& col = assert_cast<const ColumnBitmap&>(column);
+        this->data(place).value = col.get_data()[row];
+    }
+
     void deserialize_and_merge_from_column_range(AggregateDataPtr __restrict 
place,
                                                  const IColumn& column, size_t 
begin, size_t end,
                                                  Arena&) const override {
@@ -184,6 +191,8 @@ public:
         }
     }
 
+    bool needs_deserialize_and_merge_scratch() const override { return false; }
+
     void deserialize_and_merge_vec(const AggregateDataPtr* places, size_t 
offset,
                                    AggregateDataPtr rhs, const IColumn* 
column, Arena&,
                                    const size_t num_rows) const override {
diff --git a/be/src/exprs/aggregate/aggregate_function_count.h 
b/be/src/exprs/aggregate/aggregate_function_count.h
index fb806419e9d..978343669d2 100644
--- a/be/src/exprs/aggregate/aggregate_function_count.h
+++ b/be/src/exprs/aggregate/aggregate_function_count.h
@@ -122,6 +122,14 @@ public:
         }
     }
 
+    void deserialize_from_column_row(AggregateDataPtr __restrict place, const 
IColumn& column,
+                                     size_t row, Arena&) const override {
+        DCHECK_LT(row, column.size());
+        const auto& col = assert_cast<const ColumnFixedLengthObject&>(column);
+        const auto* data = reinterpret_cast<const 
Data*>(col.get_data().data());
+        AggregateFunctionCount::data(place) = data[row];
+    }
+
     void deserialize_and_merge_from_column_range(AggregateDataPtr __restrict 
place,
                                                  const IColumn& column, size_t 
begin, size_t end,
                                                  Arena&) const override {
@@ -134,6 +142,8 @@ public:
         }
     }
 
+    bool needs_deserialize_and_merge_scratch() const override { return false; }
+
     void deserialize_and_merge_vec(const AggregateDataPtr* places, size_t 
offset,
                                    AggregateDataPtr rhs, const IColumn* 
column, Arena& arena,
                                    const size_t num_rows) const override {
@@ -286,6 +296,14 @@ public:
         }
     }
 
+    void deserialize_from_column_row(AggregateDataPtr __restrict place, const 
IColumn& column,
+                                     size_t row, Arena&) const override {
+        DCHECK_LT(row, column.size());
+        const auto& col = assert_cast<const ColumnFixedLengthObject&>(column);
+        const auto* data = reinterpret_cast<const 
Data*>(col.get_data().data());
+        AggregateFunctionCountNotNullUnary::data(place) = data[row];
+    }
+
     void deserialize_and_merge_from_column_range(AggregateDataPtr __restrict 
place,
                                                  const IColumn& column, size_t 
begin, size_t end,
                                                  Arena&) const override {
@@ -298,6 +316,8 @@ public:
         }
     }
 
+    bool needs_deserialize_and_merge_scratch() const override { return false; }
+
     void deserialize_and_merge_vec(const AggregateDataPtr* places, size_t 
offset,
                                    AggregateDataPtr rhs, const IColumn* 
column, Arena& arena,
                                    const size_t num_rows) const override {
diff --git a/be/src/exprs/aggregate/aggregate_function_map_v2.h 
b/be/src/exprs/aggregate/aggregate_function_map_v2.h
index 47582fc6329..5e62dcdfb5c 100644
--- a/be/src/exprs/aggregate/aggregate_function_map_v2.h
+++ b/be/src/exprs/aggregate/aggregate_function_map_v2.h
@@ -243,6 +243,14 @@ public:
         }
     }
 
+    void deserialize_from_column_row(AggregateDataPtr __restrict place, const 
IColumn& column,
+                                     size_t row, Arena&) const override {
+        DCHECK_LT(row, column.size());
+        const auto& col = assert_cast<const ColumnMap&>(column);
+        auto map = col[row].get<TYPE_MAP>();
+        this->data(place).add(map[0], map[1]);
+    }
+
     void deserialize_and_merge_from_column_range(AggregateDataPtr __restrict 
place,
                                                  const IColumn& column, size_t 
begin, size_t end,
                                                  Arena&) const override {
@@ -255,6 +263,8 @@ public:
         }
     }
 
+    bool needs_deserialize_and_merge_scratch() const override { return false; }
+
     void deserialize_and_merge_vec(const AggregateDataPtr* places, size_t 
offset,
                                    AggregateDataPtr rhs, const IColumn* 
column, Arena&,
                                    const size_t num_rows) const override {
diff --git a/be/src/exprs/aggregate/aggregate_function_min_max.h 
b/be/src/exprs/aggregate/aggregate_function_min_max.h
index 416bf226196..12a87aa2497 100644
--- a/be/src/exprs/aggregate/aggregate_function_min_max.h
+++ b/be/src/exprs/aggregate/aggregate_function_min_max.h
@@ -230,6 +230,19 @@ public:
         }
     }
 
+    void deserialize_from_column_row(AggregateDataPtr __restrict place, const 
IColumn& column,
+                                     size_t row, Arena& arena) const override {
+        DCHECK_LT(row, column.size());
+        if constexpr (Data::UsesFixedLengthStateSerialization) {
+            const auto& col = assert_cast<const 
ColumnFixedLengthObject&>(column);
+            const auto* data = reinterpret_cast<const 
Data*>(col.get_data().data());
+            // An empty serialized state need not initialize its value payload.
+            this->data(place).change_if_better(data[row], arena);
+        } else {
+            Base::deserialize_from_column_row(place, column, row, arena);
+        }
+    }
+
     void deserialize_and_merge_from_column_range(AggregateDataPtr __restrict 
place,
                                                  const IColumn& column, size_t 
begin, size_t end,
                                                  Arena& arena) const override {
@@ -246,6 +259,10 @@ public:
         }
     }
 
+    bool needs_deserialize_and_merge_scratch() const override {
+        return !Data::UsesFixedLengthStateSerialization;
+    }
+
     void deserialize_and_merge_vec(const AggregateDataPtr* places, size_t 
offset,
                                    AggregateDataPtr rhs, const IColumn* 
column, Arena& arena,
                                    const size_t num_rows) const override {
diff --git a/be/src/exprs/aggregate/aggregate_function_null.h 
b/be/src/exprs/aggregate/aggregate_function_null.h
index c4406417c21..25c97c1cd6c 100644
--- a/be/src/exprs/aggregate/aggregate_function_null.h
+++ b/be/src/exprs/aggregate/aggregate_function_null.h
@@ -244,13 +244,15 @@ public:
                                             AggregateDataPtr rhs, const 
IColumn* column,
                                             Arena& arena, const size_t 
num_rows) const override {
         if (nested_function->is_trivial()) {
-            BufferReadable buf({column->get_data_at(0).data, 0});
+            // Selected rows need independent readers: skipping a destination 
must not leave
+            // a shared reader pointing at that row's serialized state for the 
next destination.
             size_t size_of_data = this->size_of_data();
             if constexpr (result_is_nullable) {
                 for (int i = 0; i != num_rows; ++i) {
                     if (!places[i]) {
                         continue;
                     }
+                    BufferReadable buf(column->get_data_at(i));
                     buf.read_binary(*(bool*)(rhs + size_of_data * i));
                     if (get_flag(rhs + size_of_data * i)) {
                         nested_function->deserialize(nested_place(rhs + 
size_of_data * i), buf,
@@ -267,6 +269,7 @@ public:
             } else {
                 for (size_t i = 0; i != num_rows; ++i) {
                     if (places[i]) {
+                        BufferReadable buf(column->get_data_at(i));
                         nested_function->deserialize(rhs + size_of_data * i, 
buf, arena);
                     }
                 }
diff --git a/be/src/exprs/aggregate/aggregate_function_null_v2.h 
b/be/src/exprs/aggregate/aggregate_function_null_v2.h
index cb9d7dd5316..a9766e23818 100644
--- a/be/src/exprs/aggregate/aggregate_function_null_v2.h
+++ b/be/src/exprs/aggregate/aggregate_function_null_v2.h
@@ -302,6 +302,10 @@ public:
         }
     }
 
+    bool needs_deserialize_and_merge_scratch() const override {
+        return nested_function->needs_deserialize_and_merge_scratch();
+    }
+
     void deserialize_and_merge_vec(const AggregateDataPtr* places, size_t 
offset,
                                    AggregateDataPtr rhs, const IColumn* 
column, Arena& arena,
                                    const size_t num_rows) const override {
@@ -342,6 +346,21 @@ public:
         }
     }
 
+    void deserialize_from_column_row(AggregateDataPtr __restrict place, const 
IColumn& column,
+                                     size_t row, Arena& arena) const override {
+        DCHECK_LT(row, column.size());
+        if constexpr (result_is_nullable) {
+            const auto& nullable_col = assert_cast<const 
ColumnNullable&>(column);
+            if (!nullable_col.is_null_at(row)) {
+                set_flag(place);
+                nested_function->deserialize_from_column_row(
+                        nested_place(place), nullable_col.get_nested_column(), 
row, arena);
+            }
+        } else {
+            nested_function->deserialize_from_column_row(place, column, row, 
arena);
+        }
+    }
+
     void deserialize_and_merge_from_column_range(AggregateDataPtr __restrict 
place,
                                                  const IColumn& column, size_t 
begin, size_t end,
                                                  Arena& arena) const override {
diff --git a/be/src/exprs/aggregate/aggregate_function_state_combine.h 
b/be/src/exprs/aggregate/aggregate_function_state_combine.h
index d21e0d363ac..c00524c9e58 100644
--- a/be/src/exprs/aggregate/aggregate_function_state_combine.h
+++ b/be/src/exprs/aggregate/aggregate_function_state_combine.h
@@ -139,6 +139,10 @@ public:
         _function->deserialize_vec(places, column, arena, num_rows);
     }
 
+    bool needs_deserialize_and_merge_scratch() const override {
+        return _function->needs_deserialize_and_merge_scratch();
+    }
+
     void deserialize_and_merge_vec(const AggregateDataPtr* places, size_t 
offset,
                                    AggregateDataPtr rhs, const IColumn* 
column, Arena& arena,
                                    const size_t num_rows) const override {
@@ -156,6 +160,12 @@ public:
         _function->deserialize_and_merge(place, rhs, buf, arena);
     }
 
+    void deserialize_from_column_row(AggregateDataPtr __restrict place, const 
IColumn& column,
+                                     size_t row, Arena& arena) const override {
+        DCHECK_LT(row, column.size());
+        _function->deserialize_from_column_row(place, column, row, arena);
+    }
+
     void deserialize_and_merge_from_column_range(AggregateDataPtr __restrict 
place,
                                                  const IColumn& column, size_t 
begin, size_t end,
                                                  Arena& arena) const override {
diff --git a/be/src/exprs/aggregate/aggregate_function_state_union.h 
b/be/src/exprs/aggregate/aggregate_function_state_union.h
index 1c8242bf53f..16b1b17592c 100644
--- a/be/src/exprs/aggregate/aggregate_function_state_union.h
+++ b/be/src/exprs/aggregate/aggregate_function_state_union.h
@@ -71,11 +71,12 @@ public:
 
     void add_batch(size_t batch_size, AggregateDataPtr* places, size_t 
place_offset,
                    const IColumn** columns, Arena& arena, bool agg_many) const 
override {
-        for (size_t i = 0; i < batch_size; ++i) {
-            //the range is [i, i]
-            _function->deserialize_and_merge_from_column_range(places[i] + 
place_offset,
-                                                               *columns[0], i, 
i, arena);
-        }
+        add_batch_impl<false>(batch_size, places, place_offset, columns, 
arena);
+    }
+
+    void add_batch_selected(size_t batch_size, AggregateDataPtr* places, 
size_t place_offset,
+                            const IColumn** columns, Arena& arena) const 
override {
+        add_batch_impl<true>(batch_size, places, place_offset, columns, arena);
     }
     void reset(AggregateDataPtr place) const override { 
_function->reset(place); }
 
@@ -107,6 +108,35 @@ public:
 
     size_t align_of_data() const override { return _function->align_of_data(); 
}
 
+private:
+    template <bool selected>
+    void add_batch_impl(size_t batch_size, AggregateDataPtr* places, size_t 
place_offset,
+                        const IColumn** columns, Arena& arena) const {
+        // Some batch implementations read the first serialized row before 
entering their loop.
+        if (batch_size == 0) {
+            return;
+        }
+        // Delegate the whole batch so native state columns (e.g. SUM) can 
merge directly,
+        // instead of dispatching through the range interface for every input 
row.
+        // Generic implementations need aligned scratch states and manage 
their construction,
+        // destruction and exception cleanup. Release only this storage after 
the batch;
+        // keep passing the caller's arena for allocations that merged states 
may retain.
+        // The function can be shared by evaluators, so scratch must not be a 
mutable member.
+        Arena scratch_arena;
+        AggregateDataPtr scratch = nullptr;
+        if (_function->needs_deserialize_and_merge_scratch()) {
+            scratch = scratch_arena.aligned_alloc(batch_size * 
_function->size_of_data(),
+                                                  _function->align_of_data());
+        }
+        if constexpr (selected) {
+            _function->deserialize_and_merge_vec_selected(places, 
place_offset, scratch, columns[0],
+                                                          arena, batch_size);
+        } else {
+            _function->deserialize_and_merge_vec(places, place_offset, 
scratch, columns[0], arena,
+                                                 batch_size);
+        }
+    }
+
 protected:
     AggregateFunctionPtr _function;
     DataTypePtr _return_type;
diff --git a/be/src/exprs/aggregate/aggregate_function_sum.h 
b/be/src/exprs/aggregate/aggregate_function_sum.h
index 8c00d1934ea..12b657f5d41 100644
--- a/be/src/exprs/aggregate/aggregate_function_sum.h
+++ b/be/src/exprs/aggregate/aggregate_function_sum.h
@@ -160,6 +160,14 @@ public:
         }
     }
 
+    void deserialize_from_column_row(AggregateDataPtr __restrict place, const 
IColumn& column,
+                                     size_t row, Arena&) const override {
+        DCHECK_LT(row, column.size());
+        const auto& col = assert_cast<const ColumnFixedLengthObject&>(column);
+        const auto* data = reinterpret_cast<const 
Data*>(col.get_data().data());
+        this->data(place) = data[row];
+    }
+
     void deserialize_and_merge_from_column_range(AggregateDataPtr __restrict 
place,
                                                  const IColumn& column, size_t 
begin, size_t end,
                                                  Arena&) const override {
@@ -172,6 +180,8 @@ public:
         }
     }
 
+    bool needs_deserialize_and_merge_scratch() const override { return false; }
+
     void deserialize_and_merge_vec(const AggregateDataPtr* places, size_t 
offset,
                                    AggregateDataPtr rhs, const IColumn* 
column, Arena& arena,
                                    const size_t num_rows) const override {
diff --git a/be/src/exprs/aggregate/aggregate_function_uniq_distribute_key.h 
b/be/src/exprs/aggregate/aggregate_function_uniq_distribute_key.h
index 7b3985ecd5a..9a3c9cffd46 100644
--- a/be/src/exprs/aggregate/aggregate_function_uniq_distribute_key.h
+++ b/be/src/exprs/aggregate/aggregate_function_uniq_distribute_key.h
@@ -211,6 +211,14 @@ public:
         }
     }
 
+    void deserialize_from_column_row(AggregateDataPtr __restrict place, const 
IColumn& column,
+                                     size_t row, Arena&) const override {
+        DCHECK_LT(row, column.size());
+        const auto& col = assert_cast<const ColumnFixedLengthObject&>(column);
+        const auto* data = reinterpret_cast<const 
UInt64*>(col.get_data().data());
+        this->data(place).count = data[row];
+    }
+
     void deserialize_and_merge_from_column_range(AggregateDataPtr __restrict 
place,
                                                  const IColumn& column, size_t 
begin, size_t end,
                                                  Arena&) const override {
diff --git a/be/src/exprs/aggregate/helpers.h b/be/src/exprs/aggregate/helpers.h
index eac02206e7c..48a971743de 100644
--- a/be/src/exprs/aggregate/helpers.h
+++ b/be/src/exprs/aggregate/helpers.h
@@ -36,6 +36,7 @@
  * 4. deserialize_and_merge_vec_selected
  * 5. serialize_without_key_to_column
  * 6. deserialize_and_merge_from_column
+ * 7. deserialize_from_column_row
  */
 #define CHECK_AGG_FUNCTION_SERIALIZED_TYPE(FunctionTemplate)                   
                    \
     do {                                                                       
                    \
@@ -43,6 +44,11 @@
                 
!std::is_same_v<decltype(&FunctionTemplate::get_serialized_type),               
   \
                                 
decltype(&IAggregateFunction::get_serialized_type)>;               \
         if constexpr (_is_new_serialized_type) {                               
                    \
+            static_assert(                                                     
                    \
+                    
!std::is_same_v<decltype(&FunctionTemplate::deserialize_from_column_row),      \
+                                    decltype(&IAggregateFunctionHelper<        
                    \
+                                             
FunctionTemplate>::deserialize_from_column_row)>,     \
+                    "need to override deserialize_from_column_row");           
                    \
             
static_assert(!std::is_same_v<decltype(&FunctionTemplate::serialize_to_column), 
       \
                                           decltype(&IAggregateFunctionHelper<  
                    \
                                                    
FunctionTemplate>::serialize_to_column)>,       \
diff --git a/be/src/exprs/function/function_agg_state_finalize.h 
b/be/src/exprs/function/function_agg_state_finalize.h
new file mode 100644
index 00000000000..aacdb65921c
--- /dev/null
+++ b/be/src/exprs/function/function_agg_state_finalize.h
@@ -0,0 +1,110 @@
+// 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 <cstring>
+
+#include "core/arena.h"
+#include "core/block/block.h"
+#include "core/column/column_nullable.h"
+#include "exprs/aggregate/aggregate_function.h"
+#include "exprs/function/function.h"
+#include "util/defer_op.h"
+
+namespace doris {
+
+class FunctionAggStateFinalize : public IFunction {
+public:
+    FunctionAggStateFinalize(DataTypePtr return_type, AggregateFunctionPtr 
agg_function)
+            : _return_type(std::move(return_type)), 
_agg_function(std::move(agg_function)) {}
+
+    static FunctionBasePtr create(const DataTypes& argument_types, const 
DataTypePtr& return_type,
+                                  const AggregateFunctionPtr& agg_function) {
+        return std::make_shared<DefaultFunction>(
+                std::make_shared<FunctionAggStateFinalize>(return_type, 
agg_function),
+                argument_types, return_type);
+    }
+
+    String get_name() const override { return _agg_function->get_name() + 
"_finalize"; }
+
+    size_t get_number_of_arguments() const override { return 1; }
+
+    // An outer NULL can have an empty, invalid serialized payload. Do not 
deserialize it.
+    bool use_default_implementation_for_nulls() const override { return false; 
}
+
+    DataTypePtr get_return_type_impl(const DataTypes& arguments) const 
override {
+        return _return_type;
+    }
+
+    Status execute_impl(FunctionContext* context, Block& block, const 
ColumnNumbers& arguments,
+                        uint32_t result, size_t input_rows_count) const 
override {
+        const auto input =
+                
block.get_by_position(arguments[0]).column->convert_to_full_column_if_const();
+        const auto* nullable = check_and_get_column<ColumnNullable>(*input);
+        const auto& states = nullable ? nullable->get_nested_column() : *input;
+        auto output = _agg_function->get_return_type()->create_column();
+        _agg_function->check_result_column_type(*output);
+        output->reserve(input_rows_count);
+        // Keep the reusable state storage separate from its per-row 
variable-length data.
+        Arena state_arena;
+        const auto state_size = _agg_function->size_of_data();
+        auto* place = state_arena.aligned_alloc(state_size, 
_agg_function->align_of_data());
+        Arena arena;
+        auto finalize_row = [&](size_t row) {
+            // Serialized states can use native columns as well as strings.
+            _agg_function->deserialize_from_column_row(place, states, row, 
arena);
+            _agg_function->insert_result_into(place, *output);
+        };
+        auto finalize_rows = [&]<bool is_trivial>() {
+            for (size_t row = 0; row < input_rows_count; ++row) {
+                if (nullable && nullable->is_null_at(row)) {
+                    output->insert_default();
+                    continue;
+                }
+                if constexpr (is_trivial) {
+                    // Trivial states support zero-init and need no 
destruction.
+                    std::memset(place, 0, state_size);
+                    finalize_row(row);
+                } else {
+                    _agg_function->create(place);
+                    DEFER(_agg_function->destroy(place));
+                    finalize_row(row);
+                }
+                // Destroy states before reclaiming any variable-length data 
they own.
+                arena.clear();
+            }
+        };
+        if (_agg_function->is_trivial()) {
+            finalize_rows.template operator()<true>();
+        } else {
+            finalize_rows.template operator()<false>();
+        }
+        ColumnPtr result_column = std::move(output);
+        if (_return_type->is_nullable()) {
+            result_column = wrap_in_nullable(result_column, block, arguments, 
input_rows_count);
+        }
+        block.replace_by_position(result, std::move(result_column));
+        return Status::OK();
+    }
+
+private:
+    DataTypePtr _return_type;
+    AggregateFunctionPtr _agg_function;
+};
+
+} // namespace doris
diff --git a/be/src/exprs/vectorized_fn_call.cpp 
b/be/src/exprs/vectorized_fn_call.cpp
index a908fea81d3..eeab5a03fe1 100644
--- a/be/src/exprs/vectorized_fn_call.cpp
+++ b/be/src/exprs/vectorized_fn_call.cpp
@@ -49,6 +49,7 @@
 #include "exec/pipeline/pipeline_task.h"
 #include "exprs/function/array/function_array_distance.h"
 #include "exprs/function/function_agg_state.h"
+#include "exprs/function/function_agg_state_finalize.h"
 #include "exprs/function/function_fake.h"
 #include "exprs/function/function_java_udf.h"
 #include "exprs/function/function_python_udf.h"
@@ -79,6 +80,7 @@ class TExprNode;
 namespace doris {
 
 const std::string AGG_STATE_SUFFIX = "_state";
+const std::string AGG_FINALIZE_SUFFIX = "_finalize";
 
 // Now left child is a function call, we need to check if it is a distance 
function
 const static std::set<std::string> DISTANCE_FUNCS = 
{L2DistanceApproximate::name,
@@ -216,6 +218,33 @@ size_t raw_comparison_value_size(PrimitiveType 
primitive_type) {
     }
 }
 
+Status create_agg_state_finalize_function(const std::string& function_name,
+                                          const DataTypes& argument_types,
+                                          const DataTypePtr& return_type,
+                                          FunctionBasePtr& function) {
+    if (argument_types.size() != 1 ||
+        remove_nullable(argument_types[0])->get_primitive_type() != 
TYPE_AGG_STATE) {
+        return Status::InternalError("Finalize function requires one AGG_STATE 
argument");
+    }
+    const auto state_type = remove_nullable(argument_types[0]);
+    const auto* agg_state = assert_cast<const 
DataTypeAggState*>(state_type.get());
+    if (agg_state->get_function_name() + AGG_FINALIZE_SUFFIX != function_name) 
{
+        return Status::InternalError("{} does not match function {}", 
state_type->get_name(),
+                                     function_name);
+    }
+    const auto& nested = agg_state->get_nested_function();
+    auto expected_type = nested->get_return_type();
+    if (argument_types[0]->is_nullable()) {
+        expected_type = make_nullable(expected_type);
+    }
+    if (!expected_type->equals(*return_type)) {
+        return Status::InternalError("{} expects return type {}, but got {}", 
function_name,
+                                     expected_type->get_name(), 
return_type->get_name());
+    }
+    function = FunctionAggStateFinalize::create(argument_types, return_type, 
nested);
+    return Status::OK();
+}
+
 } // namespace
 
 VectorizedFnCall::VectorizedFnCall(const TExprNode& node) : VExpr(node) {
@@ -294,8 +323,11 @@ Status VectorizedFnCall::prepare(RuntimeState* state, 
const RowDescriptor& desc,
             _function = FunctionAggState::create(
                     argument_types, _data_type,
                     assert_cast<const 
DataTypeAggState*>(_data_type.get())->get_nested_function());
+        } else if (match_suffix(_fn.name.function_name, AGG_FINALIZE_SUFFIX)) {
+            RETURN_IF_ERROR(create_agg_state_finalize_function(
+                    _fn.name.function_name, argument_types, _data_type, 
_function));
         } else {
-            return Status::InternalError("Function {} is not endwith 
'_state'", _fn.signature);
+            return Status::InternalError("Unsupported AggState function {}", 
_fn.signature);
         }
     } else {
         // get the function. won't prepare function.
diff --git a/be/test/exprs/aggregate/agg_state_union_test.cpp 
b/be/test/exprs/aggregate/agg_state_union_test.cpp
new file mode 100644
index 00000000000..0a712b860ab
--- /dev/null
+++ b/be/test/exprs/aggregate/agg_state_union_test.cpp
@@ -0,0 +1,316 @@
+// 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 <gtest/gtest.h>
+
+#include <array>
+
+#include "agent/be_exec_version_manager.h"
+#include "core/column/column_complex.h"
+#include "core/data_type/data_type_bitmap.h"
+#include "core/data_type/data_type_decimal.h"
+#include "core/data_type/data_type_string.h"
+#include "exprs/aggregate/aggregate_function_state_combine.h"
+#include "exprs/aggregate/aggregate_function_state_merge.h"
+#include "exprs/aggregate/aggregate_function_state_union.h"
+#include "testutil/column_helper.h"
+
+namespace doris {
+
+// Reuse the state lifecycle forwarding while inspecting the scratch passed by 
UNION/MERGE.
+class ScratchCheckingAggregateFunction final : public AggregateStateUnion {
+public:
+    using AggregateStateUnion::AggregateStateUnion;
+
+    bool needs_deserialize_and_merge_scratch() const override {
+        return _function->needs_deserialize_and_merge_scratch();
+    }
+
+    void deserialize_and_merge_vec(const AggregateDataPtr* places, size_t 
offset,
+                                   AggregateDataPtr rhs, const IColumn* 
column, Arena& arena,
+                                   size_t num_rows) const override {
+        check_scratch(rhs);
+        ++batch_calls;
+        _function->deserialize_and_merge_vec(places, offset, rhs, column, 
arena, num_rows);
+    }
+
+    void deserialize_and_merge_vec_selected(const AggregateDataPtr* places, 
size_t offset,
+                                            AggregateDataPtr rhs, const 
IColumn* column,
+                                            Arena& arena, size_t num_rows) 
const override {
+        check_scratch(rhs);
+        ++selected_batch_calls;
+        _function->deserialize_and_merge_vec_selected(places, offset, rhs, 
column, arena, num_rows);
+    }
+
+    mutable size_t batch_calls = 0;
+    mutable size_t selected_batch_calls = 0;
+
+private:
+    void check_scratch(ConstAggregateDataPtr rhs) const {
+        EXPECT_EQ(rhs != nullptr, needs_deserialize_and_merge_scratch());
+        EXPECT_EQ(reinterpret_cast<uintptr_t>(rhs) % align_of_data(), 0);
+    }
+};
+
+class AggregateStateUnionTest : public testing::Test {
+protected:
+    static constexpr size_t GROUPS = 3;
+    using Places = std::array<AggregateDataPtr, GROUPS>;
+
+    static Places create_places(const AggregateFunctionPtr& function, Arena& 
arena, size_t offset) {
+        Places places;
+        for (auto& place : places) {
+            place = arena.aligned_alloc(offset + function->size_of_data(),
+                                        function->align_of_data());
+            memset(place, 0x5a, offset);
+            function->create(place + offset);
+        }
+        return places;
+    }
+
+    static void compare_results(const AggregateFunctionPtr& function, const 
Places& actual,
+                                const Places& expected, size_t offset) {
+        auto actual_result = function->get_return_type()->create_column();
+        auto expected_result = function->get_return_type()->create_column();
+        for (size_t group = 0; group < GROUPS; ++group) {
+            function->insert_result_into(actual[group] + offset, 
*actual_result);
+            function->insert_result_into(expected[group] + offset, 
*expected_result);
+            EXPECT_EQ(actual_result->compare_at(group, group, 
*expected_result, 1), 0)
+                    << "group=" << group;
+            EXPECT_EQ(std::string(actual[group], offset), std::string(offset, 
0x5a));
+        }
+    }
+
+    static void check_batches(const AggregateFunctionPtr& function,
+                              const AggregateFunctionPtr& nested, const 
IColumn& states,
+                              bool selected) {
+        SCOPED_TRACE(selected ? "selected" : "all rows");
+        Arena arena;
+        const size_t offset = 2 * function->align_of_data();
+        auto actual = create_places(function, arena, offset);
+        auto expected = create_places(function, arena, offset);
+        DEFER({
+            for (size_t group = 0; group < GROUPS; ++group) {
+                function->destroy(actual[group] + offset);
+                function->destroy(expected[group] + offset);
+            }
+        });
+        // Repeated destinations, nonzero offsets, and a NULL-only group are 
intentional.
+        const std::array<size_t, 6> groups = {0, 1, 0, 2, 0, 1};
+        std::array<AggregateDataPtr, 6> rows;
+        for (size_t row = 0; row < rows.size(); ++row) {
+            rows[row] = selected && (row == 0 || row == 2) ? nullptr : 
actual[groups[row]];
+        }
+        const IColumn* columns[] = {&states};
+        // Empty batches must not inspect row zero, including the Nullable 
fast path.
+        auto empty = states.clone_empty();
+        const IColumn* empty_columns[] = {empty.get()};
+        function->add_batch(0, rows.data(), offset, empty_columns, arena, 
false);
+        function->add_batch_selected(0, rows.data(), offset, empty_columns, 
arena);
+        for (int batch = 0; batch < 3; ++batch) {
+            if (selected) {
+                function->add_batch_selected(states.size(), rows.data(), 
offset, columns, arena);
+            } else {
+                function->add_batch(states.size(), rows.data(), offset, 
columns, arena, false);
+            }
+            for (size_t row = 0; row < rows.size(); ++row) {
+                if (rows[row]) {
+                    
nested->deserialize_and_merge_from_column_range(expected[groups[row]] + offset,
+                                                                    states, 
row, row, arena);
+                }
+            }
+            // Read results after the batch scratch arena has been destroyed.
+            compare_results(nested, actual, expected, offset);
+        }
+        rows.fill(nullptr);
+        function->add_batch_selected(states.size(), rows.data(), offset, 
columns, arena);
+        compare_results(nested, actual, expected, offset);
+    }
+
+    static void check(const std::string& name, const DataTypePtr& argument, 
const ColumnPtr& input,
+                      bool result_nullable = true) {
+        auto type = std::make_shared<DataTypeAggState>(DataTypes {argument}, 
result_nullable, name,
+                                                       
BeExecVersionManager::get_newest_version());
+        auto nested = type->get_nested_function();
+        check_function(nested, {input}, type);
+    }
+
+    static void check_function(const AggregateFunctionPtr& nested, const 
Columns& inputs,
+                               const DataTypePtr& state_type) {
+        auto states = nested->create_serialize_column();
+        std::vector<const IColumn*> columns;
+        for (const auto& input : inputs) {
+            columns.push_back(input.get());
+        }
+        Arena arena;
+        // Serialize independently built states, as produced by *_state or 
*_combine.
+        for (size_t row = 0; row < inputs[0]->size(); ++row) {
+            auto* place = arena.aligned_alloc(nested->size_of_data(), 
nested->align_of_data());
+            nested->create(place);
+            DEFER(nested->destroy(place));
+            nested->add(place, columns.data(), row, arena);
+            auto serialized = nested->create_serialize_column();
+            nested->serialize_without_key_to_column(place, *serialized);
+            states->insert_range_from(*serialized, 0, 1);
+        }
+        for (bool combine : {false, true}) {
+            SCOPED_TRACE(combine ? "combine reader" : "direct reader");
+            auto reader = combine ? AggregateStateCombine::create(
+                                            nested, 
nested->get_argument_types(), state_type)
+                                  : nested;
+            auto checked = std::make_shared<ScratchCheckingAggregateFunction>(
+                    reader, DataTypes {state_type}, state_type);
+            EXPECT_EQ(checked->needs_deserialize_and_merge_scratch(),
+                      nested->needs_deserialize_and_merge_scratch());
+            for (bool merge : {false, true}) {
+                SCOPED_TRACE(merge ? "merge" : "union");
+                auto function =
+                        merge ? AggregateStateMerge::create(checked, 
{state_type},
+                                                            
nested->get_return_type())
+                              : AggregateStateUnion::create(checked, 
{state_type}, state_type);
+                check_batches(function, nested, *states, false);
+                check_batches(function, nested, *states, true);
+            }
+            EXPECT_GT(checked->batch_calls, 0);
+            EXPECT_GT(checked->selected_batch_calls, 0);
+        }
+    }
+
+    static void check_scratch_requirement(const std::string& name, const 
DataTypes& arguments,
+                                          const Columns& inputs, bool 
needs_scratch,
+                                          bool result_nullable = false, bool 
null_v2 = false) {
+        SCOPED_TRACE(name);
+        AggregateFunctionAttr attr;
+        attr.enable_aggregate_function_null_v2 = null_v2;
+        auto nested = AggregateFunctionSimpleFactory::instance().get(
+                name, arguments, nullptr, result_nullable,
+                BeExecVersionManager::get_newest_version(), attr);
+        ASSERT_NE(nested, nullptr);
+        nested->set_version(BeExecVersionManager::get_newest_version());
+        ASSERT_EQ(nested->needs_deserialize_and_merge_scratch(), 
needs_scratch);
+        check_function(nested, inputs, nested->get_serialized_type());
+    }
+};
+
+TEST_F(AggregateStateUnionTest, Sum) {
+    check("sum", std::make_shared<DataTypeInt64>(),
+          ColumnHelper::create_column<DataTypeInt64>({2, 50, 7, 0, 11, 19}));
+}
+
+TEST_F(AggregateStateUnionTest, NullableSum) {
+    check("sum", make_nullable(std::make_shared<DataTypeInt64>()),
+          ColumnHelper::create_nullable_column<DataTypeInt64>({2, 50, 7, 0, 
11, 19},
+                                                              {0, 0, 0, 1, 0, 
0}));
+}
+
+TEST_F(AggregateStateUnionTest, AllNullSum) {
+    check("sum", make_nullable(std::make_shared<DataTypeInt64>()),
+          ColumnHelper::create_nullable_column<DataTypeInt64>({0, 0, 0, 0, 0, 
0},
+                                                              {1, 1, 1, 1, 1, 
1}));
+}
+
+TEST_F(AggregateStateUnionTest, DecimalSum) {
+    auto type = std::make_shared<DataTypeDecimal128>(27, 9);
+    auto input = type->create_column();
+    auto& values = assert_cast<ColumnDecimal128V3&>(*input);
+    for (Int128 value : {2, 50, 7, 0, 11, 19}) {
+        values.insert_value(Decimal128V3(value * 1000000000));
+    }
+    check("sum", type, std::move(input));
+}
+
+TEST_F(AggregateStateUnionTest, StringArrayStateLifetime) {
+    check("array_agg", std::make_shared<DataTypeString>(),
+          ColumnHelper::create_column<DataTypeString>(
+                  {std::string(4096, 'a'), "b", "c", "", std::string(8192, 
'd'), "e"}),
+          false);
+}
+
+TEST_F(AggregateStateUnionTest, NullableStringMinStateLifetime) {
+    check("min", make_nullable(std::make_shared<DataTypeString>()),
+          ColumnHelper::create_nullable_column<DataTypeString>(
+                  {std::string(4096, 'a'), "z", "c", "", std::string(8192, 
'b'), "e"},
+                  {0, 0, 0, 1, 0, 0}));
+}
+
+TEST_F(AggregateStateUnionTest, NativeNumericReadersNeedNoScratch) {
+    auto type = std::make_shared<DataTypeInt64>();
+    auto input = ColumnHelper::create_column<DataTypeInt64>({2, 50, 7, 0, 11, 
19});
+    for (const auto* name : {"sum", "avg", "count", "min", "max", 
"bitmap_agg"}) {
+        check_scratch_requirement(name, {type}, {input}, false);
+    }
+    check_scratch_requirement("count", {make_nullable(type)},
+                              
{ColumnHelper::create_nullable_column<DataTypeInt64>(
+                                      {2, 50, 7, 0, 11, 19}, {0, 0, 0, 1, 0, 
0})},
+                              false);
+}
+
+TEST_F(AggregateStateUnionTest, NativeComplexReadersNeedNoScratch) {
+    auto type = std::make_shared<DataTypeString>();
+    const std::vector<std::string> values = {std::string(4096, 'a'), "b", "c", 
"",
+                                             std::string(8192, 'd'), "e"};
+    auto input = ColumnHelper::create_column<DataTypeString>(values);
+    // Nullable inputs select the native array reader; non-nullable inputs 
select collect_list.
+    check_scratch_requirement(
+            "array_agg", {make_nullable(type)},
+            {ColumnHelper::create_nullable_column<DataTypeString>(values, {0, 
0, 0, 1, 0, 0})},
+            false);
+    check_scratch_requirement("map_agg_v2", {type, type}, {input, input}, 
false);
+    auto bitmap_type = std::make_shared<DataTypeBitMap>();
+    auto bitmaps = ColumnBitmap::create();
+    for (UInt64 value : {2, 50, 7, 0, 11, 19}) {
+        bitmaps->insert_value(BitmapValue(value));
+    }
+    check_scratch_requirement("bitmap_union", {bitmap_type}, 
{std::move(bitmaps)}, false);
+}
+
+TEST_F(AggregateStateUnionTest, GenericAndStringMinMaxNeedScratch) {
+    auto type = std::make_shared<DataTypeString>();
+    auto input = ColumnHelper::create_column<DataTypeString>(
+            {std::string(4096, 'a'), "z", "c", "", std::string(8192, 'b'), 
"e"});
+    for (const auto* name : {"min", "max"}) {
+        check_scratch_requirement(name, {type}, {input}, true);
+    }
+    check_scratch_requirement("array_agg", {type}, {input}, true);
+}
+
+TEST_F(AggregateStateUnionTest, NullableReadersPreserveScratchRequirement) {
+    auto type = make_nullable(std::make_shared<DataTypeInt64>());
+    for (bool all_null : {false, true}) {
+        auto input = ColumnHelper::create_nullable_column<DataTypeInt64>(
+                {2, 50, 7, 0, 11, 19}, all_null ? std::vector<UInt8> {1, 1, 1, 
1, 1, 1}
+                                                : std::vector<UInt8> {0, 0, 0, 
1, 0, 0});
+        for (bool null_v2 : {false, true}) {
+            for (bool result_nullable : {false, true}) {
+                check_scratch_requirement("sum", {type}, {input}, !null_v2, 
result_nullable,
+                                          null_v2);
+            }
+            // The generic serialized-string reader still needs scratch inside 
Nullable V2.
+            check_scratch_requirement("multi_distinct_count", {type}, {input}, 
true, false,
+                                      null_v2);
+        }
+    }
+}
+
+TEST_F(AggregateStateUnionTest, FixedLengthColumnCanStillNeedScratch) {
+    // This reader reconstructs a hash-set state from each fixed-width 
serialized count.
+    check_scratch_requirement(
+            "multi_distinct_count_distribute_key", 
{std::make_shared<DataTypeInt64>()},
+            {ColumnHelper::create_column<DataTypeInt64>({2, 50, 7, 0, 11, 
19})}, true);
+}
+
+} // namespace doris
diff --git a/be/test/exprs/function/function_agg_state_finalize_test.cpp 
b/be/test/exprs/function/function_agg_state_finalize_test.cpp
new file mode 100644
index 00000000000..77d2a0dc0fb
--- /dev/null
+++ b/be/test/exprs/function/function_agg_state_finalize_test.cpp
@@ -0,0 +1,516 @@
+// 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 "exprs/function/function_agg_state_finalize.h"
+
+#include <gtest/gtest.h>
+
+#include <algorithm>
+
+#include "agent/be_exec_version_manager.h"
+#include "core/column/column_array.h"
+#include "core/column/column_complex.h"
+#include "core/column/column_const.h"
+#include "core/column/column_string.h"
+#include "core/data_type/data_type_agg_state.h"
+#include "core/data_type/data_type_bitmap.h"
+#include "core/data_type/data_type_number.h"
+#include "core/data_type/data_type_string.h"
+#include "exprs/aggregate/aggregate_function_state_combine.h"
+#include "exprs/aggregate/aggregate_function_state_merge.h"
+#include "testutil/column_helper.h"
+
+namespace doris {
+
+class FunctionAggStateFinalizeTest : public testing::Test {
+protected:
+    static std::shared_ptr<DataTypeAggState> state_type(const std::string& 
name,
+                                                        const DataTypePtr& 
argument_type,
+                                                        bool result_nullable = 
true) {
+        return std::make_shared<DataTypeAggState>(DataTypes {argument_type}, 
result_nullable, name,
+                                                  
BeExecVersionManager::get_newest_version());
+    }
+
+    static void append_state(const AggregateFunctionPtr& function, const 
ColumnPtr& input,
+                             IColumn& states) {
+        ASSERT_EQ(function->get_argument_types().size(), 1);
+        Arena arena;
+        auto* place = arena.aligned_alloc(function->size_of_data(), 
function->align_of_data());
+        function->create(place);
+        DEFER(function->destroy(place));
+        const IColumn* columns[] = {input.get()};
+        function->check_input_columns_type(columns);
+        function->add_batch_single_place(input->size(), place, columns, arena);
+        // Some aggregates resize their no-key output instead of appending to 
it.
+        auto serialized = function->create_serialize_column();
+        function->serialize_without_key_to_column(place, *serialized);
+        ASSERT_EQ(serialized->size(), 1);
+        states.insert_range_from(*serialized, 0, 1);
+    }
+
+    static ColumnPtr finalize(const DataTypePtr& type, const ColumnPtr& 
states) {
+        auto nested = assert_cast<const 
DataTypeAggState*>(remove_nullable(type).get())
+                              ->get_nested_function();
+        auto result_type = nested->get_return_type();
+        if (type->is_nullable()) {
+            result_type = make_nullable(result_type);
+        }
+        auto function = FunctionAggStateFinalize::create({type}, result_type, 
nested);
+        Block block {{states, type, "state"}, {nullptr, result_type, 
"result"}};
+        EXPECT_TRUE(function->execute(nullptr, block, {0}, 1, 
states->size()).ok());
+        return block.get_by_position(1).column;
+    }
+
+    static void check_row_reader(const std::string& name, const DataTypes& 
arguments,
+                                 const Columns& inputs, bool result_nullable = 
false,
+                                 bool null_v2 = false) {
+        SCOPED_TRACE(name);
+        SCOPED_TRACE(null_v2);
+        AggregateFunctionAttr attr;
+        attr.enable_aggregate_function_null_v2 = null_v2;
+        const auto version = BeExecVersionManager::get_newest_version();
+        auto nested = AggregateFunctionSimpleFactory::instance().get(
+                name, arguments, nullptr, result_nullable, version, attr);
+        ASSERT_NE(nested, nullptr);
+        nested->set_version(version);
+        auto actual = nested->get_return_type()->create_column();
+        auto expected = actual->clone_empty();
+        nested->check_result_column_type(*actual);
+        nested->check_result_column_type(*expected);
+        {
+            auto states = nested->create_serialize_column();
+            std::vector<const IColumn*> columns;
+            for (const auto& input : inputs) {
+                ASSERT_EQ(input->size(), 6);
+                columns.push_back(input.get());
+            }
+            Arena arena;
+            for (size_t group = 0; group < 4; ++group) {
+                AggregateFunctionGuard state(nested.get());
+                // Three independent groups followed by an empty state.
+                if (group < 3) {
+                    nested->check_input_columns_type(columns.data());
+                    nested->add(state.data(), columns.data(), group * 2, 
arena);
+                    nested->add(state.data(), columns.data(), group * 2 + 1, 
arena);
+                }
+                auto serialized = nested->create_serialize_column();
+                nested->serialize_without_key_to_column(state.data(), 
*serialized);
+                states->insert_range_from(*serialized, 0, 1);
+            }
+            for (bool combine : {false, true}) {
+                auto reader = combine ? AggregateStateCombine::create(nested, 
arguments,
+                                                                      
nested->get_serialized_type())
+                                      : nested;
+                for (size_t row : {2, 0, 3, 1, 0}) {
+                    // Read out of order and repeat a row to catch input 
mutation.
+                    AggregateFunctionGuard direct(reader.get());
+                    AggregateFunctionGuard merged(nested.get());
+                    reader->deserialize_from_column_row(direct.data(), 
*states, row, arena);
+                    
nested->deserialize_and_merge_from_column_range(merged.data(), *states, row,
+                                                                    row, 
arena);
+                    nested->insert_result_into(direct.data(), *actual);
+                    nested->insert_result_into(merged.data(), *expected);
+                }
+            }
+        }
+        // Results must survive both the source column and the deserialized 
states.
+        ASSERT_EQ(actual->size(), 10);
+        for (size_t row = 0; row < actual->size(); ++row) {
+            EXPECT_EQ(actual->compare_at(row, row, *expected, 1), 0) << "row=" 
<< row;
+        }
+    }
+};
+
+struct alignas(64) FinalizeTrackingState {
+    Int64 value = 0;
+};
+
+class FinalizeTrackingAggregateFunction final
+        : public IAggregateFunctionDataHelper<FinalizeTrackingState,
+                                              
FinalizeTrackingAggregateFunction> {
+public:
+    FinalizeTrackingAggregateFunction()
+            : IAggregateFunctionDataHelper<FinalizeTrackingState,
+                                           
FinalizeTrackingAggregateFunction>({}) {}
+
+    String get_name() const override { return "finalize_tracking"; }
+    DataTypePtr get_return_type() const override { return 
std::make_shared<DataTypeInt64>(); }
+
+    void create(AggregateDataPtr place) const override {
+        EXPECT_EQ(reinterpret_cast<uintptr_t>(place) % align_of_data(), 0);
+        IAggregateFunctionDataHelper::create(place);
+        ++created;
+    }
+    void destroy(AggregateDataPtr place) const noexcept override {
+        IAggregateFunctionDataHelper::destroy(place);
+        ++destroyed;
+    }
+    void add(AggregateDataPtr, const IColumn**, ssize_t, Arena&) const 
override {}
+    void merge(AggregateDataPtr, ConstAggregateDataPtr, Arena&) const override 
{ ++merged; }
+    void serialize(ConstAggregateDataPtr place, BufferWritable& buf) const 
override {
+        buf.write_binary(data(place).value);
+    }
+    void deserialize(AggregateDataPtr place, BufferReadable& buf, Arena&) 
const override {
+        ++deserialized;
+        buf.read_binary(data(place).value);
+        if (data(place).value == -1) {
+            throw Exception(ErrorCode::INTERNAL_ERROR, "mock deserialize 
failure");
+        }
+    }
+    void insert_result_into(ConstAggregateDataPtr place, IColumn& to) const 
override {
+        if (data(place).value == -2) {
+            throw Exception(ErrorCode::INTERNAL_ERROR, "mock result insertion 
failure");
+        }
+        assert_cast<ColumnInt64&>(to).insert_value(data(place).value);
+    }
+
+    void check_counts(size_t rows) const {
+        EXPECT_EQ(created, rows);
+        EXPECT_EQ(destroyed, rows);
+        EXPECT_EQ(deserialized, rows);
+        EXPECT_EQ(merged, 0);
+    }
+
+    mutable size_t created = 0;
+    mutable size_t destroyed = 0;
+    mutable size_t deserialized = 0;
+    mutable size_t merged = 0;
+};
+
+TEST_F(FunctionAggStateFinalizeTest, UsesOneAlignedStateWithoutMerging) {
+    auto aggregate = std::make_shared<FinalizeTrackingAggregateFunction>();
+    FunctionAggStateFinalize function(aggregate->get_return_type(), aggregate);
+    auto states = ColumnString::create();
+    VectorBufferWriter writer(*states);
+    constexpr Int64 rows = 128;
+    for (Int64 row = 0; row < rows; ++row) {
+        writer.write_binary(row);
+        writer.commit();
+    }
+    Block block {{std::move(states), aggregate->get_serialized_type(), 
"state"},
+                 {nullptr, aggregate->get_return_type(), "result"}};
+    ASSERT_TRUE(function.execute_impl(nullptr, block, {0}, 1, rows).ok());
+    aggregate->check_counts(rows);
+    for (Int64 row = 0; row < rows; ++row) {
+        EXPECT_EQ(block.get_by_position(1).column->get_int(row), row);
+    }
+}
+
+TEST_F(FunctionAggStateFinalizeTest, 
DestroysStateOnDeserializeAndResultFailure) {
+    for (Int64 failure : {-1, -2}) {
+        auto aggregate = std::make_shared<FinalizeTrackingAggregateFunction>();
+        FunctionAggStateFinalize function(aggregate->get_return_type(), 
aggregate);
+        auto states = ColumnString::create();
+        VectorBufferWriter writer(*states);
+        writer.write_binary(Int64(7));
+        writer.commit();
+        writer.write_binary(failure);
+        writer.commit();
+        Block block {{std::move(states), aggregate->get_serialized_type(), 
"state"},
+                     {nullptr, aggregate->get_return_type(), "result"}};
+        EXPECT_THROW(static_cast<void>(function.execute_impl(nullptr, block, 
{0}, 1, 2)),
+                     Exception);
+        aggregate->check_counts(2);
+    }
+}
+
+TEST_F(FunctionAggStateFinalizeTest, SingleRowNativeReadersMatchMerge) {
+    auto number = std::make_shared<DataTypeInt64>();
+    auto values = ColumnHelper::create_column<DataTypeInt64>({2, 4, 7, 7, 11, 
19});
+    for (const auto* name : {"sum", "avg", "min", "max", "count", "bitmap_agg",
+                             "multi_distinct_count_distribute_key"}) {
+        check_row_reader(name, {number}, {values});
+    }
+    auto string = std::make_shared<DataTypeString>();
+    auto strings = ColumnHelper::create_column<DataTypeString>(
+            {std::string(8192, 'x'), "b", "c", "c", "d", "e"});
+    check_row_reader("map_agg_v2", {string, string}, {strings, strings});
+    auto bitmap_type = std::make_shared<DataTypeBitMap>();
+    auto bitmaps = ColumnBitmap::create();
+    for (UInt64 value : {2, 4, 7, 7, 11, 19}) {
+        bitmaps->insert_value(BitmapValue(value));
+    }
+    ColumnPtr bitmap_input = std::move(bitmaps);
+    for (const auto* name : {"bitmap_union", "bitmap_intersect", 
"group_bitmap_xor"}) {
+        check_row_reader(name, {bitmap_type}, {bitmap_input});
+    }
+    check_row_reader(
+            "array_agg", {make_nullable(string)},
+            {ColumnHelper::create_nullable_column<DataTypeString>(
+                    {std::string(8192, 'x'), "b", "c", "", "d", "e"}, {0, 0, 
0, 1, 0, 0})});
+}
+
+TEST_F(FunctionAggStateFinalizeTest, SingleRowNullableReadersMatchMerge) {
+    auto type = make_nullable(std::make_shared<DataTypeInt64>());
+    auto input = ColumnHelper::create_nullable_column<DataTypeInt64>({2, 4, 0, 
0, 0, 19},
+                                                                     {0, 0, 1, 
1, 1, 0});
+    for (bool null_v2 : {false, true}) {
+        for (bool result_nullable : {false, true}) {
+            for (const auto* name : {"sum", "avg", "min", "max"}) {
+                check_row_reader(name, {type}, {input}, result_nullable, 
null_v2);
+            }
+        }
+        check_row_reader("count", {type}, {input}, false, null_v2);
+        // Nullable V2 can also wrap a generic string-backed state.
+        check_row_reader("multi_distinct_count", {type}, {input}, false, 
null_v2);
+    }
+}
+
+TEST_F(FunctionAggStateFinalizeTest, SingleRowStringReadersMatchMerge) {
+    auto type = std::make_shared<DataTypeString>();
+    auto input = ColumnHelper::create_column<DataTypeString>(
+            {std::string(8192, 'x'), "b", "c", "c", "d", "e"});
+    for (const auto* name : {"min", "max", "array_agg"}) {
+        check_row_reader(name, {type}, {input});
+    }
+    auto limit_type = std::make_shared<DataTypeInt32>();
+    auto limits = ColumnHelper::create_column<DataTypeInt32>({2, 2, 2, 2, 2, 
2});
+    check_row_reader("topn", {type, limit_type}, {input, limits});
+}
+
+TEST_F(FunctionAggStateFinalizeTest, FinalizesEachRowIndependently) {
+    auto type = state_type("avg", std::make_shared<DataTypeInt64>());
+    auto states = type->create_column();
+    append_state(type->get_nested_function(), 
ColumnHelper::create_column<DataTypeInt64>({1, 3}),
+                 *states);
+    append_state(type->get_nested_function(), 
ColumnHelper::create_column<DataTypeInt64>({10}),
+                 *states);
+    ColumnPtr serialized = std::move(states);
+    auto result = finalize(type, serialized);
+    const auto& values = assert_cast<const ColumnFloat64&>(*result).get_data();
+    ASSERT_EQ(values.size(), 2);
+    EXPECT_DOUBLE_EQ(values[0], 2);
+    EXPECT_DOUBLE_EQ(values[1], 10);
+    // Repeated execution must not consume or mutate the serialized state.
+    EXPECT_EQ(result->compare_at(0, 0, *finalize(type, serialized), 1), 0);
+}
+
+TEST_F(FunctionAggStateFinalizeTest, EmptyNonNullableAvgMatchesMerge) {
+    auto type = state_type("avg", std::make_shared<DataTypeInt64>());
+    auto states = type->create_column();
+    append_state(type->get_nested_function(), 
ColumnHelper::create_column<DataTypeInt64>({}),
+                 *states);
+    ColumnPtr serialized = std::move(states);
+    auto result = finalize(type, serialized);
+    auto nested = type->get_nested_function();
+    auto merge = AggregateStateMerge::create(nested, {type}, 
nested->get_return_type());
+    Arena arena;
+    auto* place = arena.aligned_alloc(merge->size_of_data(), 
merge->align_of_data());
+    merge->create(place);
+    DEFER(merge->destroy(place));
+    const IColumn* columns[] = {serialized.get()};
+    merge->add(place, columns, 0, arena);
+    auto expected = nested->get_return_type()->create_column();
+    merge->check_result_column_type(*expected);
+    merge->insert_result_into(place, *expected);
+    EXPECT_EQ(result->compare_at(0, 0, *expected, 1), 0);
+}
+
+TEST_F(FunctionAggStateFinalizeTest, TrivialStatesResetAcrossOuterNulls) {
+    auto type = state_type("sum", std::make_shared<DataTypeInt64>());
+    auto function = type->get_nested_function();
+    ASSERT_TRUE(function->is_trivial());
+    auto states = type->create_column();
+    append_state(function, ColumnHelper::create_column<DataTypeInt64>({2, 4}), 
*states);
+    states->insert_default();
+    append_state(function, ColumnHelper::create_column<DataTypeInt64>({}), 
*states);
+    append_state(function, ColumnHelper::create_column<DataTypeInt64>({-7}), 
*states);
+    append_state(function, ColumnHelper::create_column<DataTypeInt64>({10}), 
*states);
+    ColumnPtr nullable_states = ColumnNullable::create(
+            std::move(states), ColumnHelper::create_column<DataTypeUInt8>({0, 
1, 0, 0, 0}));
+
+    auto result = finalize(make_nullable(type), nullable_states);
+    const auto& values = assert_cast<const 
ColumnNullable&>(*result).get_nested_column();
+    ASSERT_EQ(result->size(), 5);
+    EXPECT_EQ(values.get_int(0), 6);
+    EXPECT_TRUE(result->is_null_at(1));
+    EXPECT_FALSE(result->is_null_at(2));
+    EXPECT_EQ(values.get_int(2), 0);
+    EXPECT_EQ(values.get_int(3), -7);
+    EXPECT_EQ(values.get_int(4), 10);
+
+    auto repeated = finalize(make_nullable(type), nullable_states);
+    for (size_t row = 0; row < result->size(); ++row) {
+        EXPECT_EQ(result->compare_at(row, row, *repeated, 1), 0);
+    }
+}
+
+TEST_F(FunctionAggStateFinalizeTest, NativeSerializedColumnsAndEmptyCount) {
+    for (const auto& name : {"count", "sum", "min", "max"}) {
+        SCOPED_TRACE(name);
+        auto type =
+                state_type(name, std::make_shared<DataTypeInt64>(), name != 
std::string("count"));
+        auto states = type->create_column();
+        append_state(type->get_nested_function(),
+                     ColumnHelper::create_column<DataTypeInt64>({2, 4}), 
*states);
+        append_state(type->get_nested_function(), 
ColumnHelper::create_column<DataTypeInt64>({}),
+                     *states);
+        auto result = finalize(type, std::move(states));
+        EXPECT_EQ(result->get_int(0), name == std::string("count") ? 2
+                                      : name == std::string("sum") ? 6
+                                      : name == std::string("min") ? 2
+                                                                   : 4);
+        // Empty non-nullable states have the existing aggregate's identity 
value.
+        auto expected = 
type->get_nested_function()->get_return_type()->create_column();
+        Arena arena;
+        auto function = type->get_nested_function();
+        auto* place = arena.aligned_alloc(function->size_of_data(), 
function->align_of_data());
+        function->create(place);
+        DEFER(function->destroy(place));
+        function->check_result_column_type(*expected);
+        function->insert_result_into(place, *expected);
+        EXPECT_EQ(result->compare_at(1, 0, *expected, 1), 0);
+    }
+}
+
+// GTest assertion macros inflate complexity in this table-driven check.
+// NOLINTNEXTLINE(readability-function-cognitive-complexity)
+TEST_F(FunctionAggStateFinalizeTest, NullableInputsAndEmptyStates) {
+    for (const auto& name : {"avg", "sum", "min", "max", "count"}) {
+        SCOPED_TRACE(name);
+        auto type = state_type(name, 
make_nullable(std::make_shared<DataTypeInt64>()),
+                               name != std::string("count"));
+        auto states = type->create_column();
+        append_state(type->get_nested_function(),
+                     ColumnHelper::create_nullable_column<DataTypeInt64>({2, 
0, 4}, {0, 1, 0}),
+                     *states);
+        append_state(type->get_nested_function(),
+                     ColumnHelper::create_nullable_column<DataTypeInt64>({0}, 
{1}), *states);
+        append_state(type->get_nested_function(),
+                     ColumnHelper::create_nullable_column<DataTypeInt64>({}, 
{}), *states);
+        auto result = finalize(type, std::move(states));
+        ASSERT_EQ(result->size(), 3);
+        EXPECT_FALSE(result->is_null_at(0));
+        if (name == std::string("count")) {
+            EXPECT_EQ(result->get_int(0), 2);
+            EXPECT_EQ(result->get_int(1), 0);
+            EXPECT_EQ(result->get_int(2), 0);
+        } else {
+            EXPECT_TRUE(result->is_null_at(1));
+            EXPECT_TRUE(result->is_null_at(2));
+        }
+    }
+}
+
+TEST_F(FunctionAggStateFinalizeTest, OuterNullAndConstantStates) {
+    auto type = state_type("avg", 
make_nullable(std::make_shared<DataTypeInt64>()));
+    auto states = type->create_column();
+    append_state(type->get_nested_function(),
+                 ColumnHelper::create_nullable_column<DataTypeInt64>({2, 4}, 
{0, 0}), *states);
+    ColumnPtr constant = ColumnConst::create(states->clone_resized(1), 5);
+    auto result = finalize(type, constant);
+    EXPECT_TRUE(is_column_const(*result));
+    EXPECT_EQ(result->size(), 5);
+    // NULL string payload is deliberately empty, and must not be deserialized.
+    states->insert_default();
+    ColumnPtr nullable_states = ColumnNullable::create(
+            std::move(states), ColumnHelper::create_column<DataTypeUInt8>({0, 
1}));
+    result = finalize(make_nullable(type), nullable_states);
+    ASSERT_EQ(result->size(), 2);
+    EXPECT_FALSE(result->is_null_at(0));
+    EXPECT_TRUE(result->is_null_at(1));
+    auto null_state = nullable_states->clone_empty();
+    null_state->insert_from(*nullable_states, 1);
+    ColumnPtr null_constant = ColumnConst::create(std::move(null_state), 7);
+    result = finalize(make_nullable(type), null_constant);
+    EXPECT_EQ(result->size(), 7);
+    EXPECT_TRUE(result->is_null_at(0));
+}
+
+TEST_F(FunctionAggStateFinalizeTest, VariableLengthArrayResultsOwnTheirData) {
+    auto type = state_type("array_agg", std::make_shared<DataTypeString>(), 
false);
+    auto states = type->create_column();
+    const std::string large(8192, 'x');
+    for (size_t group = 0; group < 16; ++group) {
+        append_state(type->get_nested_function(),
+                     ColumnHelper::create_column<DataTypeString>({large, 
std::to_string(group)}),
+                     *states);
+    }
+    auto result = finalize(type, std::move(states));
+    const auto& arrays = assert_cast<const ColumnArray&>(*result);
+    const auto& strings = assert_cast<const ColumnString&>(
+            assert_cast<const 
ColumnNullable&>(arrays.get_data()).get_nested_column());
+    ASSERT_EQ(arrays.size(), 16);
+    for (size_t group = 0; group < 16; ++group) {
+        EXPECT_EQ(arrays.get_offsets()[group], (group + 1) * 2);
+        EXPECT_EQ(strings.get_data_at(group * 2).to_string(), large);
+        EXPECT_EQ(strings.get_data_at(group * 2 + 1).to_string(), 
std::to_string(group));
+    }
+}
+
+TEST_F(FunctionAggStateFinalizeTest, 
CollectSetReadsMultipleValuesAndEmptyStates) {
+    auto type = state_type("collect_set", std::make_shared<DataTypeInt64>(), 
false);
+    auto states = type->create_column();
+    append_state(type->get_nested_function(), 
ColumnHelper::create_column<DataTypeInt64>({1, 2, 2}),
+                 *states);
+    append_state(type->get_nested_function(), 
ColumnHelper::create_column<DataTypeInt64>({}),
+                 *states);
+    append_state(type->get_nested_function(), 
ColumnHelper::create_column<DataTypeInt64>({9, 7, 9}),
+                 *states);
+    auto result = finalize(type, std::move(states));
+    const auto& arrays = assert_cast<const ColumnArray&>(*result);
+    const auto& values = assert_cast<const 
ColumnNullable&>(arrays.get_data()).get_nested_column();
+    const std::vector<std::vector<Int64>> expected {{1, 2}, {}, {7, 9}};
+    ASSERT_EQ(arrays.size(), expected.size());
+    for (size_t row = 0; row < expected.size(); ++row) {
+        std::vector<Int64> actual;
+        const auto& offsets = arrays.get_offsets();
+        for (size_t i = offsets[static_cast<ssize_t>(row) - 1]; i < 
offsets[row]; ++i) {
+            actual.push_back(values.get_int(i));
+        }
+        std::ranges::sort(actual);
+        EXPECT_EQ(actual, expected[row]);
+    }
+}
+
+TEST_F(FunctionAggStateFinalizeTest, EmptyBlock) {
+    auto type = state_type("avg", std::make_shared<DataTypeInt64>());
+    auto result = finalize(type, type->create_column());
+    EXPECT_EQ(result->size(), 0);
+}
+
+TEST_F(FunctionAggStateFinalizeTest, VariableLengthStatesWithOuterNulls) {
+    auto type = state_type("collect_set", std::make_shared<DataTypeString>(), 
false);
+    auto states = type->create_column();
+    auto null_map = ColumnUInt8::create();
+    const std::string large(16384, 'x');
+    for (size_t group = 0; group < 16; ++group) {
+        // Valid rows exceed the initial arena chunk; outer NULL payloads must 
be skipped.
+        states->insert_default();
+        null_map->insert_value(1);
+        append_state(type->get_nested_function(),
+                     ColumnHelper::create_column<DataTypeString>({large + 
std::to_string(group)}),
+                     *states);
+        null_map->insert_value(0);
+    }
+    auto result = finalize(make_nullable(type),
+                           ColumnNullable::create(std::move(states), 
std::move(null_map)));
+    const auto& arrays = assert_cast<const ColumnArray&>(
+            assert_cast<const ColumnNullable&>(*result).get_nested_column());
+    const auto& strings = assert_cast<const ColumnString&>(
+            assert_cast<const 
ColumnNullable&>(arrays.get_data()).get_nested_column());
+    ASSERT_EQ(result->size(), 32);
+    for (size_t group = 0; group < 16; ++group) {
+        EXPECT_TRUE(result->is_null_at(group * 2));
+        EXPECT_FALSE(result->is_null_at(group * 2 + 1));
+        EXPECT_EQ(arrays.get_offsets()[group * 2], group);
+        EXPECT_EQ(arrays.get_offsets()[group * 2 + 1], group + 1);
+        EXPECT_EQ(strings.get_data_at(group).to_string(), large + 
std::to_string(group));
+    }
+}
+
+} // namespace doris
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/catalog/FunctionRegistry.java 
b/fe/fe-core/src/main/java/org/apache/doris/catalog/FunctionRegistry.java
index 0a82f786c22..fbc3e9d84af 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/catalog/FunctionRegistry.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/catalog/FunctionRegistry.java
@@ -115,7 +115,8 @@ public class FunctionRegistry {
                 return containsAggregateFunction(functionBuilders);
             }
             if (isBuiltinAggStateCombinator(name)) {
-                return !name.endsWith(AggCombinerFunctionBuilder.STATE_SUFFIX);
+                return !name.endsWith(AggCombinerFunctionBuilder.STATE_SUFFIX)
+                        && 
!name.endsWith(AggCombinerFunctionBuilder.FINALIZE_SUFFIX);
             }
         }
 
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/nereids/glue/translator/ExpressionTranslator.java
 
b/fe/fe-core/src/main/java/org/apache/doris/nereids/glue/translator/ExpressionTranslator.java
index a77299b8c4c..948238357a4 100644
--- 
a/fe/fe-core/src/main/java/org/apache/doris/nereids/glue/translator/ExpressionTranslator.java
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/nereids/glue/translator/ExpressionTranslator.java
@@ -92,6 +92,7 @@ import 
org.apache.doris.nereids.trees.expressions.functions.agg.AggregateParam;
 import org.apache.doris.nereids.trees.expressions.functions.agg.Count;
 import 
org.apache.doris.nereids.trees.expressions.functions.agg.NotNullableAggregateFunction;
 import 
org.apache.doris.nereids.trees.expressions.functions.combinator.CombineCombinator;
+import 
org.apache.doris.nereids.trees.expressions.functions.combinator.FinalizeCombinator;
 import 
org.apache.doris.nereids.trees.expressions.functions.combinator.ForEachCombinator;
 import 
org.apache.doris.nereids.trees.expressions.functions.combinator.MergeCombinator;
 import 
org.apache.doris.nereids.trees.expressions.functions.combinator.StateCombinator;
@@ -831,6 +832,13 @@ public class ExpressionTranslator extends 
DefaultExpressionVisitor<Expr, PlanTra
         return isNullPredicate;
     }
 
+    @Override
+    public Expr visitFinalizeCombinator(FinalizeCombinator combinator, 
PlanTranslatorContext context) {
+        FunctionCallExpr functionCallExpr = (FunctionCallExpr) 
visitScalarFunction(combinator, context);
+        functionCallExpr.getFn().setBinaryType(Function.BinaryType.AGG_STATE);
+        return functionCallExpr;
+    }
+
     @Override
     public Expr visitStateCombinator(StateCombinator combinator, 
PlanTranslatorContext context) {
         List<Expr> arguments = combinator.getArguments().stream()
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/exploration/mv/rollup/AggFunctionRollUpHandler.java
 
b/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/exploration/mv/rollup/AggFunctionRollUpHandler.java
index 19008042451..6d02b4379a2 100644
--- 
a/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/exploration/mv/rollup/AggFunctionRollUpHandler.java
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/exploration/mv/rollup/AggFunctionRollUpHandler.java
@@ -23,6 +23,11 @@ import org.apache.doris.nereids.trees.expressions.Expression;
 import org.apache.doris.nereids.trees.expressions.functions.Function;
 import 
org.apache.doris.nereids.trees.expressions.functions.agg.AggregateFunction;
 import org.apache.doris.nereids.trees.expressions.functions.agg.RollUpTrait;
+import 
org.apache.doris.nereids.trees.expressions.functions.combinator.Combinator;
+import 
org.apache.doris.nereids.trees.expressions.functions.combinator.CombineCombinator;
+import 
org.apache.doris.nereids.trees.expressions.functions.combinator.MergeCombinator;
+import 
org.apache.doris.nereids.trees.expressions.functions.combinator.StateCombinator;
+import 
org.apache.doris.nereids.trees.expressions.functions.combinator.UnionCombinator;
 
 import com.google.common.collect.ImmutableList;
 
@@ -73,12 +78,23 @@ public abstract class AggFunctionRollUpHandler {
     }
 
     /**
-     * Extract the target expression in actualFunction by targetClazz
-     * Such as actualFunction def is avg_merge(avg_union(c1)), target Clazz is 
Combinator
-     * after extracting, the return argument is avg_union(c1)
+     * Unwrap direct MERGE/UNION state chains for the same aggregate function.
+     * STATE and COMBINE consume values, so keep their complete argument 
expressions for comparison.
+     * For example, sum_combine(avg_finalize(avg_state(v))) must retain SUM 
and its finalized value.
      */
-    protected static <T> T extractLastExpression(Expression actualFunction, 
Class<T> targetClazz) {
-        List<Expression> expressions = 
actualFunction.collectToList(targetClazz::isInstance);
-        return targetClazz.cast(expressions.get(expressions.size() - 1));
+    protected static Combinator extractRollupCombinator(Combinator current) {
+        while (current instanceof MergeCombinator || current instanceof 
UnionCombinator) {
+            Expression argument = current.getArguments().get(0);
+            if (!(argument instanceof StateCombinator || argument instanceof 
CombineCombinator
+                    || argument instanceof UnionCombinator)) {
+                break;
+            }
+            Combinator next = (Combinator) argument;
+            if 
(!current.getNestedFunction().getName().equals(next.getNestedFunction().getName()))
 {
+                break;
+            }
+            current = next;
+        }
+        return current;
     }
 }
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/exploration/mv/rollup/BothCombinatorRollupHandler.java
 
b/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/exploration/mv/rollup/BothCombinatorRollupHandler.java
index 38c1dedcefe..c4c047ce0a7 100644
--- 
a/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/exploration/mv/rollup/BothCombinatorRollupHandler.java
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/exploration/mv/rollup/BothCombinatorRollupHandler.java
@@ -47,8 +47,8 @@ public class BothCombinatorRollupHandler extends 
AggFunctionRollUpHandler {
             return false;
         }
         if (queryAggregateFunction instanceof Combinator && viewFunction 
instanceof Combinator) {
-            Combinator queryCombinator = 
extractLastExpression(queryAggregateFunction, Combinator.class);
-            Combinator viewCombinator = extractLastExpression(viewFunction, 
Combinator.class);
+            Combinator queryCombinator = extractRollupCombinator((Combinator) 
queryAggregateFunction);
+            Combinator viewCombinator = extractRollupCombinator((Combinator) 
viewFunction);
             // construct actual aggregate function in combinator and compare
             return 
Objects.equals(queryCombinator.getNestedFunction().withChildren(queryCombinator.getArguments()),
                     
viewCombinator.getNestedFunction().withChildren(viewCombinator.getArguments()));
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/exploration/mv/rollup/SingleCombinatorRollupHandler.java
 
b/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/exploration/mv/rollup/SingleCombinatorRollupHandler.java
index 9a865d7a08c..6d012a2f0c9 100644
--- 
a/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/exploration/mv/rollup/SingleCombinatorRollupHandler.java
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/exploration/mv/rollup/SingleCombinatorRollupHandler.java
@@ -56,7 +56,7 @@ public class SingleCombinatorRollupHandler extends 
AggFunctionRollUpHandler {
         if (!(queryAggregateFunction instanceof Combinator)
                 && (viewFunction instanceof UnionCombinator || viewFunction 
instanceof StateCombinator
                         || viewFunction instanceof CombineCombinator)) {
-            Combinator viewCombinator = extractLastExpression(viewFunction, 
Combinator.class);
+            Combinator viewCombinator = extractRollupCombinator((Combinator) 
viewFunction);
             return Objects.equals(queryAggregateFunction,
                     
viewCombinator.getNestedFunction().withChildren(viewCombinator.getArguments()));
         }
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/expressions/functions/AggCombinerFunctionBuilder.java
 
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/expressions/functions/AggCombinerFunctionBuilder.java
index c32162ca095..90ba14b1a2f 100644
--- 
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/expressions/functions/AggCombinerFunctionBuilder.java
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/expressions/functions/AggCombinerFunctionBuilder.java
@@ -23,6 +23,7 @@ import 
org.apache.doris.nereids.trees.expressions.SlotReference;
 import 
org.apache.doris.nereids.trees.expressions.functions.agg.AggregateFunction;
 import 
org.apache.doris.nereids.trees.expressions.functions.agg.NotSupportAggState;
 import 
org.apache.doris.nereids.trees.expressions.functions.combinator.CombineCombinator;
+import 
org.apache.doris.nereids.trees.expressions.functions.combinator.FinalizeCombinator;
 import 
org.apache.doris.nereids.trees.expressions.functions.combinator.ForEachCombinator;
 import 
org.apache.doris.nereids.trees.expressions.functions.combinator.MergeCombinator;
 import 
org.apache.doris.nereids.trees.expressions.functions.combinator.StateCombinator;
@@ -43,12 +44,14 @@ public class AggCombinerFunctionBuilder extends 
FunctionBuilder {
     public static final String COMBINATOR_LINKER = "_";
     public static final String STATE = "state";
     public static final String MERGE = "merge";
+    public static final String FINALIZE = "finalize";
     public static final String UNION = "union";
     public static final String COMBINE = "combine";
     public static final String FOREACH = "foreach";
 
     public static final String STATE_SUFFIX = COMBINATOR_LINKER + STATE;
     public static final String MERGE_SUFFIX = COMBINATOR_LINKER + MERGE;
+    public static final String FINALIZE_SUFFIX = COMBINATOR_LINKER + FINALIZE;
     public static final String UNION_SUFFIX = COMBINATOR_LINKER + UNION;
     public static final String COMBINE_SUFFIX = COMBINATOR_LINKER + COMBINE;
     public static final String FOREACH_SUFFIX = COMBINATOR_LINKER + FOREACH;
@@ -121,7 +124,7 @@ public class AggCombinerFunctionBuilder extends 
FunctionBuilder {
         return (AggregateFunction) nestedBuilder.build(nestedName, 
forEachargs).first;
     }
 
-    private AggregateFunction buildMergeOrUnion(String nestedName, List<? 
extends Object> arguments) {
+    private AggregateFunction buildFromState(String nestedName, List<? extends 
Object> arguments) {
         if (arguments.size() != 1 || !(arguments.get(0) instanceof Expression)
                 || !((Expression) 
arguments.get(0)).getDataType().isAggStateType()) {
             String argString = arguments.stream().map(arg -> {
@@ -162,10 +165,13 @@ public class AggCombinerFunctionBuilder extends 
FunctionBuilder {
             AggregateFunction nestedFunction = buildState(nestedName, 
arguments);
             return Pair.of(new CombineCombinator((List<Expression>) arguments, 
nestedFunction), nestedFunction);
         } else if (combinatorSuffix.equalsIgnoreCase(MERGE)) {
-            AggregateFunction nestedFunction = buildMergeOrUnion(nestedName, 
arguments);
+            AggregateFunction nestedFunction = buildFromState(nestedName, 
arguments);
             return Pair.of(new MergeCombinator((List<Expression>) arguments, 
nestedFunction), nestedFunction);
+        } else if (combinatorSuffix.equalsIgnoreCase(FINALIZE)) {
+            AggregateFunction nestedFunction = buildFromState(nestedName, 
arguments);
+            return Pair.of(new FinalizeCombinator((List<Expression>) 
arguments, nestedFunction), nestedFunction);
         } else if (combinatorSuffix.equalsIgnoreCase(UNION)) {
-            AggregateFunction nestedFunction = buildMergeOrUnion(nestedName, 
arguments);
+            AggregateFunction nestedFunction = buildFromState(nestedName, 
arguments);
             return Pair.of(new UnionCombinator((List<Expression>) arguments, 
nestedFunction), nestedFunction);
         } else if (combinatorSuffix.equalsIgnoreCase(FOREACH)) {
             AggregateFunction nestedFunction = buildForEach(nestedName, 
arguments);
@@ -182,7 +188,7 @@ public class AggCombinerFunctionBuilder extends 
FunctionBuilder {
     public static boolean isAggStateCombinator(String name) {
         return name.toLowerCase().endsWith(STATE_SUFFIX) || 
name.toLowerCase().endsWith(MERGE_SUFFIX)
                 || name.toLowerCase().endsWith(UNION_SUFFIX) || 
name.toLowerCase().endsWith(COMBINE_SUFFIX)
-                || name.toLowerCase().endsWith(FOREACH_SUFFIX);
+                || name.toLowerCase().endsWith(FOREACH_SUFFIX) || 
name.toLowerCase().endsWith(FINALIZE_SUFFIX);
     }
 
     public static String getNestedName(String name) {
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/expressions/functions/combinator/FinalizeCombinator.java
 
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/expressions/functions/combinator/FinalizeCombinator.java
new file mode 100644
index 00000000000..10321d707d9
--- /dev/null
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/expressions/functions/combinator/FinalizeCombinator.java
@@ -0,0 +1,100 @@
+// 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.
+
+package org.apache.doris.nereids.trees.expressions.functions.combinator;
+
+import org.apache.doris.catalog.FunctionSignature;
+import org.apache.doris.nereids.exceptions.AnalysisException;
+import org.apache.doris.nereids.trees.expressions.Expression;
+import 
org.apache.doris.nereids.trees.expressions.functions.AggCombinerFunctionBuilder;
+import org.apache.doris.nereids.trees.expressions.functions.ComputeNullable;
+import 
org.apache.doris.nereids.trees.expressions.functions.ExplicitlyCastableSignature;
+import 
org.apache.doris.nereids.trees.expressions.functions.agg.AggregateFunction;
+import 
org.apache.doris.nereids.trees.expressions.functions.scalar.ScalarFunction;
+import 
org.apache.doris.nereids.trees.expressions.functions.scalar.ScalarFunctionParams;
+import org.apache.doris.nereids.trees.expressions.shape.UnaryExpression;
+import org.apache.doris.nereids.trees.expressions.visitor.ExpressionVisitor;
+import org.apache.doris.nereids.types.AggStateType;
+
+import com.google.common.collect.ImmutableList;
+
+import java.util.List;
+
+/** Finalize each serialized aggregate state without aggregating rows. */
+public class FinalizeCombinator extends ScalarFunction
+        implements UnaryExpression, ExplicitlyCastableSignature, 
ComputeNullable, Combinator {
+
+    private final AggregateFunction nested;
+
+    public FinalizeCombinator(List<Expression> arguments, AggregateFunction 
nested) {
+        super(nested.getName() + AggCombinerFunctionBuilder.FINALIZE_SUFFIX, 
arguments);
+        this.nested = nested;
+        checkStateFunction();
+    }
+
+    private FinalizeCombinator(ScalarFunctionParams functionParams, 
AggregateFunction nested) {
+        super(functionParams);
+        this.nested = nested;
+        checkStateFunction();
+    }
+
+    private void checkStateFunction() {
+        AggStateType inputType = (AggStateType) getArgument(0).getDataType();
+        // Keep the name check because acceptsType alone accepts unrelated 
aggregate states.
+        // TODO: Unify aggregate alias canonicalization across FE and BE. 
Stored aliases absent
+        // from AggStateType's mapping (for example, std) remain unsupported 
by finalize for now.
+        AggStateType expected = new AggStateType(nested.getName(), 
inputType.getSubTypes(),
+                inputType.getSubTypeNullables(), nested.nullable());
+        if (!inputType.getFunctionName().equals(expected.getFunctionName())) {
+            throw new AnalysisException(getName() + " requires a state of " + 
expected.getFunctionName()
+                    + ", but got " + inputType.toSql());
+        }
+    }
+
+    @Override
+    public FinalizeCombinator withChildren(List<Expression> children) {
+        return new FinalizeCombinator(getFunctionParams(children), nested);
+    }
+
+    @Override
+    public List<FunctionSignature> getSignatures() {
+        return 
ImmutableList.of(FunctionSignature.ret(nested.getDataType()).args(getArgument(0).getDataType()));
+    }
+
+    @Override
+    public boolean nullable() {
+        return getArgument(0).nullable() || nested.nullable();
+    }
+
+    @Override
+    public AggregateFunction getNestedFunction() {
+        return nested;
+    }
+
+    @Override
+    public <R, C> R accept(ExpressionVisitor<R, C> visitor, C context) {
+        return visitor.visitFinalizeCombinator(this, context);
+    }
+
+    @Override
+    public void checkLegalityBeforeTypeCoercion() {
+        // Stored states retain serialized parameters, not the original 
constant expressions.
+        if (getArgument(0) instanceof StateCombinator || getArgument(0) 
instanceof CombineCombinator) {
+            nested.checkLegalityBeforeTypeCoercion();
+        }
+    }
+}
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/expressions/functions/combinator/readme.md
 
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/expressions/functions/combinator/readme.md
index e861156605a..28d06aad7cf 100644
--- 
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/expressions/functions/combinator/readme.md
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/expressions/functions/combinator/readme.md
@@ -5,3 +5,25 @@ The function combiner is used to automatically create some 
functions based on th
 Currently we can generate the combinator function we want by implementing a 
new `FunctionBuilder`. For example `AggStateFunctionBuilder` can generate 
combiner functions created by the `_state`/`_merge` combiner, so that we can 
use functions such as `sum_state` and `sum_merge` in SQL statements.
 
 When we input `sum_state`, if `BuiltinFunctionBuilder` does not find a 
function named `sum_state`, then it will try to use `AggStateFunctionBuilder` 
to construct `StateCombinator`, so we get a `StateCombinator` object with 
nested function sum, and use it to generate the `sum_state` function.
+
+## Finalize one aggregate state
+
+`<aggregate>_finalize(state)` is a scalar combinator that returns the result 
of each
+serialized aggregate state independently. It accepts exactly one `AGG_STATE` of
+the matching aggregate (including its aliases), and uses the same result type 
and
+state semantics as `<aggregate>_merge`. An outer SQL NULL state returns NULL;
+an empty serialized state follows the nested aggregate's existing behavior.
+
+For example, after pre-aggregating each key, the finest grouping can read its 
AVG
+without another aggregate operator:
+
+```sql
+SET enable_agg_state = true;
+SELECT k, avg_finalize(s)
+FROM (SELECT k, avg_combine(v) AS s FROM t GROUP BY k) partial;
+```
+
+`avg_finalize` returns one result per input row. In contrast, `avg_merge` 
merges
+all input states in each group before computing one result. The same scalar
+combinator applies to other aggregates supporting `AGG_STATE`, such as `sum`,
+`count`, `min`, `max`, and `array_agg`; it does not change their serialized 
formats.
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/expressions/visitor/ScalarFunctionVisitor.java
 
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/expressions/visitor/ScalarFunctionVisitor.java
index a888b2af817..9d5e1b7485e 100644
--- 
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/expressions/visitor/ScalarFunctionVisitor.java
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/expressions/visitor/ScalarFunctionVisitor.java
@@ -31,6 +31,7 @@ import 
org.apache.doris.nereids.trees.expressions.functions.ai.AISimilarity;
 import org.apache.doris.nereids.trees.expressions.functions.ai.AISummarize;
 import org.apache.doris.nereids.trees.expressions.functions.ai.AITranslate;
 import org.apache.doris.nereids.trees.expressions.functions.ai.Embed;
+import 
org.apache.doris.nereids.trees.expressions.functions.combinator.FinalizeCombinator;
 import 
org.apache.doris.nereids.trees.expressions.functions.combinator.StateCombinator;
 import org.apache.doris.nereids.trees.expressions.functions.scalar.Abs;
 import org.apache.doris.nereids.trees.expressions.functions.scalar.Acos;
@@ -2882,6 +2883,10 @@ public interface ScalarFunctionVisitor<R, C> {
         return visitScalarFunction(yearsDiff, context);
     }
 
+    default R visitFinalizeCombinator(FinalizeCombinator combinator, C 
context) {
+        return visitScalarFunction(combinator, context);
+    }
+
     default R visitStateCombinator(StateCombinator combinator, C context) {
         return visitScalarFunction(combinator, context);
     }
diff --git 
a/fe/fe-core/src/test/java/org/apache/doris/nereids/rules/exploration/mv/rollup/CombinatorRollupHandlerTest.java
 
b/fe/fe-core/src/test/java/org/apache/doris/nereids/rules/exploration/mv/rollup/CombinatorRollupHandlerTest.java
new file mode 100644
index 00000000000..4821dbef6e8
--- /dev/null
+++ 
b/fe/fe-core/src/test/java/org/apache/doris/nereids/rules/exploration/mv/rollup/CombinatorRollupHandlerTest.java
@@ -0,0 +1,156 @@
+// 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.
+
+package org.apache.doris.nereids.rules.exploration.mv.rollup;
+
+import org.apache.doris.catalog.FunctionRegistry;
+import org.apache.doris.common.Pair;
+import org.apache.doris.nereids.trees.expressions.Expression;
+import org.apache.doris.nereids.trees.expressions.SlotReference;
+import org.apache.doris.nereids.trees.expressions.functions.Function;
+import 
org.apache.doris.nereids.trees.expressions.functions.agg.AggregateFunction;
+import org.apache.doris.nereids.trees.expressions.functions.agg.Sum;
+import 
org.apache.doris.nereids.trees.expressions.functions.combinator.StateCombinator;
+import org.apache.doris.nereids.trees.expressions.functions.scalar.Abs;
+import org.apache.doris.nereids.types.DoubleType;
+import org.apache.doris.nereids.util.MemoTestUtils;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+
+class CombinatorRollupHandlerTest {
+    private final FunctionRegistry registry = new FunctionRegistry();
+    private final SlotReference value = new SlotReference("v", 
DoubleType.INSTANCE, true);
+
+    @BeforeAll
+    static void setUp() {
+        MemoTestUtils.createConnectContext();
+    }
+
+    private Expression build(String name, Expression argument) {
+        return registry.findFunctionBuilder(name, argument).build(name, 
argument).first;
+    }
+
+    private Expression finalizeValue(boolean stored) {
+        Expression state = build("avg_state", value);
+        return build("avg_finalize", stored ? new SlotReference("s", 
state.getDataType(), true) : state);
+    }
+
+    private boolean canRollup(AggFunctionRollUpHandler handler, Expression 
query, Expression view) {
+        SlotReference mvSlot = SlotReference.of("mv_state", 
view.getDataType());
+        return handler.canRollup((AggregateFunction) query, query, 
Pair.of(view, mvSlot),
+                ImmutableMap.of(view, mvSlot));
+    }
+
+    private void assertRollup(AggFunctionRollUpHandler handler, Expression 
query, Expression view, String name) {
+        Assertions.assertTrue(canRollup(handler, query, view));
+        SlotReference mvSlot = SlotReference.of("mv_state", 
view.getDataType());
+        Function result = handler.doRollup((AggregateFunction) query, query, 
Pair.of(view, mvSlot),
+                ImmutableMap.of(view, mvSlot));
+        Assertions.assertEquals(name, result.getName());
+        Assertions.assertEquals(ImmutableList.of(mvSlot), 
result.getArguments());
+    }
+
+    @Test
+    void testDifferentOuterAggregatesOverFinalize() {
+        for (boolean stored : ImmutableList.of(false, true)) {
+            Expression finalized = finalizeValue(stored);
+            
Assertions.assertFalse(canRollup(BothCombinatorRollupHandler.INSTANCE,
+                    build("sum_combine", finalized), build("max_combine", 
finalized)));
+        }
+    }
+
+    @Test
+    void testScalarArgumentMustMatch() {
+        Expression finalized = finalizeValue(false);
+        Assertions.assertFalse(canRollup(BothCombinatorRollupHandler.INSTANCE,
+                build("sum_combine", finalized), build("sum_combine", new 
Abs(finalized))));
+    }
+
+    @Test
+    void testSameOuterAggregateOverFinalize() {
+        Expression finalized = finalizeValue(false);
+        assertRollup(BothCombinatorRollupHandler.INSTANCE,
+                build("sum_combine", finalized), build("sum_combine", 
finalized), "sum_union");
+    }
+
+    @Test
+    void testMergeAndUnionStateChain() {
+        Expression state = build("sum_state", value);
+        assertRollup(BothCombinatorRollupHandler.INSTANCE,
+                build("sum_merge", state), build("sum_union", state), 
"sum_merge");
+        assertRollup(BothCombinatorRollupHandler.INSTANCE,
+                build("sum_merge", build("sum_union", state)), 
build("sum_union", state), "sum_merge");
+    }
+
+    @Test
+    void testUnionStopsAtCombineValueArguments() {
+        Expression finalized = finalizeValue(false);
+        assertRollup(BothCombinatorRollupHandler.INSTANCE,
+                build("sum_merge", build("sum_combine", finalized)),
+                build("sum_union", build("sum_combine", finalized)), 
"sum_merge");
+        Assertions.assertFalse(canRollup(BothCombinatorRollupHandler.INSTANCE,
+                build("sum_merge", build("sum_combine", finalized)),
+                build("max_union", build("max_combine", finalized))));
+    }
+
+    @Test
+    void testStateRetainsFinalizeArgument() {
+        Expression finalized = finalizeValue(false);
+        Assertions.assertFalse(canRollup(BothCombinatorRollupHandler.INSTANCE,
+                build("sum_merge", build("sum_state", finalized)),
+                build("max_union", build("max_state", finalized))));
+    }
+
+    @Test
+    void testStoredStateMustMatch() {
+        Expression state = build("sum_state", value);
+        SlotReference first = SlotReference.of("first_state", 
state.getDataType());
+        SlotReference second = SlotReference.of("second_state", 
state.getDataType());
+        assertRollup(BothCombinatorRollupHandler.INSTANCE,
+                build("sum_merge", first), build("sum_union", first), 
"sum_merge");
+        Assertions.assertFalse(canRollup(BothCombinatorRollupHandler.INSTANCE,
+                build("sum_merge", first), build("sum_union", second)));
+    }
+
+    @Test
+    void testSingleCombinatorOverFinalize() {
+        for (boolean stored : ImmutableList.of(false, true)) {
+            Expression finalized = finalizeValue(stored);
+            Sum query = new Sum(finalized);
+            assertRollup(SingleCombinatorRollupHandler.INSTANCE,
+                    query, build("sum_combine", finalized), "sum_merge");
+            
Assertions.assertFalse(canRollup(SingleCombinatorRollupHandler.INSTANCE,
+                    query, build("max_combine", finalized)));
+            
Assertions.assertFalse(canRollup(SingleCombinatorRollupHandler.INSTANCE,
+                    query, build("sum_combine", new Abs(finalized))));
+        }
+    }
+
+    @Test
+    void testSingleCombinatorStateChain() {
+        Expression finalized = finalizeValue(false);
+        Sum query = new Sum(finalized);
+        assertRollup(SingleCombinatorRollupHandler.INSTANCE, query,
+                build("sum_union", StateCombinator.create(query)), 
"sum_merge");
+        assertRollup(SingleCombinatorRollupHandler.INSTANCE, query,
+                build("sum_union", build("sum_combine", finalized)), 
"sum_merge");
+    }
+}
diff --git 
a/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/expressions/functions/combinator/FinalizeCombinatorTest.java
 
b/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/expressions/functions/combinator/FinalizeCombinatorTest.java
new file mode 100644
index 00000000000..080ba6347cf
--- /dev/null
+++ 
b/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/expressions/functions/combinator/FinalizeCombinatorTest.java
@@ -0,0 +1,127 @@
+// 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.
+
+package org.apache.doris.nereids.trees.expressions.functions.combinator;
+
+import org.apache.doris.analysis.FunctionCallExpr;
+import org.apache.doris.catalog.Function;
+import org.apache.doris.catalog.FunctionRegistry;
+import org.apache.doris.nereids.exceptions.AnalysisException;
+import org.apache.doris.nereids.glue.translator.ExpressionTranslator;
+import org.apache.doris.nereids.glue.translator.PlanTranslatorContext;
+import org.apache.doris.nereids.trees.expressions.Expression;
+import org.apache.doris.nereids.trees.expressions.SlotReference;
+import org.apache.doris.nereids.trees.expressions.functions.FunctionBuilder;
+import org.apache.doris.nereids.trees.expressions.functions.agg.Avg;
+import org.apache.doris.nereids.trees.expressions.functions.agg.Count;
+import org.apache.doris.nereids.types.AggStateType;
+import org.apache.doris.nereids.types.DecimalV3Type;
+import org.apache.doris.nereids.types.DoubleType;
+import org.apache.doris.nereids.types.IntegerType;
+import org.apache.doris.nereids.util.MemoTestUtils;
+import org.apache.doris.nereids.util.PlanChecker;
+
+import com.google.common.collect.ImmutableList;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+class FinalizeCombinatorTest {
+    private FinalizeCombinator build(String name, Expression argument) {
+        FunctionBuilder builder = new 
FunctionRegistry().findFunctionBuilder(name, argument);
+        return (FinalizeCombinator) builder.build(name, argument).first;
+    }
+
+    @Test
+    void testScalarClassificationAndTranslation() {
+        FunctionRegistry registry = new FunctionRegistry();
+        
Assertions.assertTrue(registry.isBuiltinAggStateCombinator("avg_finalize"));
+        Assertions.assertFalse(registry.isAggregateFunction(null, 
"avg_finalize"));
+        
Assertions.assertFalse(registry.isBuiltinAggStateCombinator("abs_finalize"));
+        SlotReference value = new SlotReference("v", IntegerType.INSTANCE, 
true);
+        StateCombinator state = StateCombinator.create(new Avg(value));
+        FinalizeCombinator finalize = build("avg_finalize", state);
+        Assertions.assertEquals(DoubleType.INSTANCE, finalize.getDataType());
+        Assertions.assertTrue(finalize.nullable());
+        FunctionCallExpr translated = (FunctionCallExpr) 
ExpressionTranslator.translate(
+                finalize, new PlanTranslatorContext());
+        Assertions.assertEquals("avg_finalize", 
translated.getFn().getFunctionName().getFunction());
+        Assertions.assertEquals(Function.BinaryType.AGG_STATE, 
translated.getFn().getBinaryType());
+        Assertions.assertEquals(Function.NullableMode.ALWAYS_NULLABLE, 
translated.getFn().getNullableMode());
+        Assertions.assertEquals(state.getDataType().toCatalogDataType(), 
translated.getFn().getArgs()[0]);
+    }
+
+    @Test
+    void testCountAndOuterNullability() {
+        SlotReference value = new SlotReference("v", IntegerType.INSTANCE, 
true);
+        StateCombinator state = StateCombinator.create(new Count(value));
+        Assertions.assertFalse(build("count_finalize", state).nullable());
+        SlotReference nullableState = new SlotReference("s", 
state.getDataType(), true);
+        Assertions.assertTrue(build("count_finalize", 
nullableState).nullable());
+    }
+
+    @Test
+    void testNonNullableStateUsesNestedResultType() {
+        SlotReference value = new SlotReference("v", IntegerType.INSTANCE, 
false);
+        StateCombinator state = StateCombinator.create(new Avg(value));
+        Assertions.assertFalse(build("avg_finalize", state).nullable());
+    }
+
+    @Test
+    void testDecimalResultPrecision() {
+        MemoTestUtils.createConnectContext();
+        SlotReference value = new SlotReference("v", 
DecimalV3Type.createDecimalV3Type(12, 2), true);
+        FinalizeCombinator finalize = build("avg_finalize", 
StateCombinator.create(new Avg(value)));
+        Assertions.assertEquals(DecimalV3Type.createDecimalV3Type(38, 4), 
finalize.getDataType());
+    }
+
+    @Test
+    void testStateFunctionMustMatch() {
+        for (String sql : ImmutableList.of("select sum_finalize(avg_state(1))",
+                "select avg_finalize(sum_state(1))")) {
+            AnalysisException exception = 
Assertions.assertThrows(AnalysisException.class,
+                    () -> 
PlanChecker.from(MemoTestUtils.createConnectContext()).analyze(sql));
+            Assertions.assertTrue(exception.getMessage().contains("requires a 
state of"));
+        }
+    }
+
+    @Test
+    void testAliasCanonicalization() {
+        AggStateType state = new AggStateType("var_pop", 
ImmutableList.of(DoubleType.INSTANCE),
+                ImmutableList.of(true), true);
+        FinalizeCombinator finalize = build("variance_finalize", new 
SlotReference("s", state, false));
+        Assertions.assertEquals("variance_finalize", finalize.getName());
+        Assertions.assertEquals(DoubleType.INSTANCE, finalize.getDataType());
+    }
+
+    @Test
+    void testInvalidArguments() {
+        for (String sql : ImmutableList.of("select avg_finalize(1)", "select 
avg_finalize()",
+                "select avg_finalize(avg_state(1), avg_state(2))",
+                "select avg_finalize(distinct avg_state(1))")) {
+            Assertions.assertThrows(AnalysisException.class,
+                    () -> 
PlanChecker.from(MemoTestUtils.createConnectContext()).analyze(sql));
+        }
+    }
+
+    @Test
+    void testStoredParameterizedStateAndCombine() {
+        PlanChecker.from(MemoTestUtils.createConnectContext()).analyze(
+                "select topn_finalize(s) from (select topn_combine('x', 2) s) 
t");
+        PlanChecker.from(MemoTestUtils.createConnectContext()).analyze(
+                "select avg_finalize(avg_combine(cast(1 as int)))");
+    }
+}
diff --git 
a/regression-test/data/datatype_p0/agg_state/test_agg_state_finalize.out 
b/regression-test/data/datatype_p0/agg_state/test_agg_state_finalize.out
new file mode 100644
index 00000000000..763c1420073
--- /dev/null
+++ b/regression-test/data/datatype_p0/agg_state/test_agg_state_finalize.out
@@ -0,0 +1,60 @@
+-- This file is automatically generated. You should know what you did if you 
want to edit this
+-- !rows --
+1      1       1       1       1       1
+1      3       1       3       3       3
+2      10      1       10      10      10
+3      \N      0       \N      \N      \N
+
+-- !grouped --
+1      2       2       4       1       3
+2      10      1       10      10      10
+3      \N      0       \N      \N      \N
+
+-- !empty --
+\N     0       \N
+
+-- !decimal --
+1      2.5000
+2      10.0000
+3      \N
+
+-- !array --
+1      ["a", "b"]
+2      ["c"]
+3      [null]
+
+-- !parameters --
+{"a":1}
+
+-- !const --
+0      7       0
+1      7       0
+2      7       0
+
+-- !rollup --
+\N     4.666666666666667
+1      2
+2      10
+3      \N
+
+-- !stored --
+1      2       2       4
+2      10      1       10
+3      \N      0       \N
+
+-- !outer_null --
+0      \N      \N
+1      2       2
+2      10      1
+3      \N      0
+4      \N      \N
+
+-- !union --
+4.666666666666667
+
+-- !alias --
+\N     \N
+0      0
+0      0
+0      0
+
diff --git 
a/regression-test/data/datatype_p0/agg_state/test_agg_state_union_batch.out 
b/regression-test/data/datatype_p0/agg_state/test_agg_state_union_batch.out
new file mode 100644
index 00000000000..a09c50a2b2e
--- /dev/null
+++ b/regression-test/data/datatype_p0/agg_state/test_agg_state_union_batch.out
@@ -0,0 +1,37 @@
+-- This file is automatically generated. You should know what you did if you 
want to edit this
+-- !merge --
+0      7142142 5715717 71421.420000000 value-0
+1      7143571 5712856 71435.710000000 value-1
+2      7145000 5720000 71450.000000000 value-100
+3      7146429 5717144 71464.290000000 value-10
+4      7137858 5714283 71378.580000000 value-1005
+5      7139286 5711431 71392.860000000 value-1006
+6      7140714 \N      \N      \N
+
+-- !direct --
+0      7142142 5715717 71421.420000000 value-0
+1      7143571 5712856 71435.710000000 value-1
+2      7145000 5720000 71450.000000000 value-100
+3      7146429 5717144 71464.290000000 value-10
+4      7137858 5714283 71378.580000000 value-1005
+5      7139286 5711431 71392.860000000 value-1006
+6      7140714 \N      \N      \N
+
+-- !union --
+0      7142142 5715717 71421.420000000 value-0
+1      7143571 5712856 71435.710000000 value-1
+2      7145000 5720000 71450.000000000 value-100
+3      7146429 5717144 71464.290000000 value-10
+4      7137858 5714283 71378.580000000 value-1005
+5      7139286 5711431 71392.860000000 value-1006
+6      7140714 \N      \N      \N
+
+-- !array --
+0      true    true
+1      true    true
+2      true    true
+3      true    true
+4      true    true
+5      true    true
+6      true    true
+
diff --git 
a/regression-test/data/nereids_rules_p0/mv/agg_with_roll_up/agg_state_finalize_roll_up.out
 
b/regression-test/data/nereids_rules_p0/mv/agg_with_roll_up/agg_state_finalize_roll_up.out
new file mode 100644
index 00000000000..cd51bb4190f
--- /dev/null
+++ 
b/regression-test/data/nereids_rules_p0/mv/agg_with_roll_up/agg_state_finalize_roll_up.out
@@ -0,0 +1,36 @@
+-- This file is automatically generated. You should know what you did if you 
want to edit this
+-- !combined_before --
+1      400
+2      500
+3      \N
+
+-- !plain_before --
+1      400
+2      500
+3      \N
+
+-- !different_aggregate --
+1      400
+2      500
+3      \N
+
+-- !different_argument --
+1      400
+2      500
+3      \N
+
+-- !combined_after --
+1      400
+2      500
+3      \N
+
+-- !plain_after --
+1      400
+2      500
+3      \N
+
+-- !merge_after --
+1      400
+2      500
+3      \N
+
diff --git 
a/regression-test/suites/datatype_p0/agg_state/test_agg_state_finalize.groovy 
b/regression-test/suites/datatype_p0/agg_state/test_agg_state_finalize.groovy
new file mode 100644
index 00000000000..5285a97d554
--- /dev/null
+++ 
b/regression-test/suites/datatype_p0/agg_state/test_agg_state_finalize.groovy
@@ -0,0 +1,104 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+suite("test_agg_state_finalize") {
+    sql "set enable_agg_state=true"
+    sql "drop table if exists agg_finalize_input"
+    sql """
+        create table agg_finalize_input (k int, v int, s string, d decimal(12, 
2))
+        duplicate key(k) distributed by hash(k) buckets 1
+        properties("replication_num"="1")
+    """
+    sql """
+        insert into agg_finalize_input values
+            (1, 1, 'a', 1.25), (1, 3, 'b', 3.75),
+            (2, 10, 'c', 10.00), (3, null, null, null)
+    """
+    order_qt_rows """
+        select k, avg_finalize(avg_state(v)), count_finalize(count_state(v)),
+               sum_finalize(sum_state(v)), min_finalize(min_state(v)), 
max_finalize(max_state(v))
+        from agg_finalize_input
+    """
+    order_qt_grouped """
+        select k, avg_finalize(a), count_finalize(c), sum_finalize(s), 
min_finalize(mi), max_finalize(ma)
+        from (select k, avg_combine(v) a, count_combine(v) c, sum_combine(v) s,
+                     min_combine(v) mi, max_combine(v) ma from 
agg_finalize_input group by k) t
+    """
+    order_qt_empty """
+        select avg_finalize(a), count_finalize(c), sum_finalize(s)
+        from (select avg_combine(v) a, count_combine(v) c, sum_combine(v) s
+              from agg_finalize_input where k < 0) t
+    """
+    order_qt_decimal """
+        select k, avg_finalize(a)
+        from (select k, avg_combine(d) a from agg_finalize_input group by k) t
+    """
+    order_qt_array """
+        select k, array_sort(array_agg_finalize(a))
+        from (select k, array_agg_combine(s) a from agg_finalize_input group 
by k) t
+    """
+    order_qt_parameters """
+        select topn_finalize(s) from (select topn_combine('a', 2) s) t
+    """
+    order_qt_const """
+        select number, avg_finalize(avg_state(7)), 
count_finalize(count_state(cast(null as int)))
+        from numbers("number"="3")
+    """
+    // The finest groups need only a scalar projection; coarser groups merge 
their states.
+    order_qt_rollup """
+        with partial as (select k, avg_combine(v) a from agg_finalize_input 
group by k)
+        select k, avg_finalize(a) from partial
+        union all
+        select null, avg_merge(a) from partial
+    """
+    sql "drop table if exists agg_finalize_stored"
+    sql """
+        create table agg_finalize_stored (
+            k int, a agg_state<avg(int)> generic, c agg_state<count(int)> 
generic,
+            s agg_state<sum(int)> generic
+        ) aggregate key(k) distributed by hash(k) buckets 1
+        properties("replication_num"="1")
+    """
+    sql """
+        insert into agg_finalize_stored
+        select k, avg_state(v), count_state(v), sum_state(v) from 
agg_finalize_input
+    """
+    order_qt_stored """
+        select k, avg_finalize(a), count_finalize(c), sum_finalize(s) from 
agg_finalize_stored
+    """
+    order_qt_outer_null """
+        select n.number, avg_finalize(t.a), count_finalize(t.c)
+        from numbers("number"="5") n left join agg_finalize_stored t on 
n.number=t.k
+    """
+    order_qt_union """
+        select avg_finalize(a) from (select avg_union(a) a from 
agg_finalize_stored) t
+    """
+    order_qt_alias """
+        select var_pop_finalize(variance_state(v)), 
variance_finalize(var_pop_state(v))
+        from agg_finalize_input
+    """
+    for (def query : ["select sum_finalize(avg_state(1))", "select 
avg_finalize(sum_state(1))"]) {
+        test {
+            sql query
+            exception "requires a state of"
+        }
+    }
+    test {
+        sql "select avg_finalize(1)"
+        exception "Can not found function"
+    }
+}
diff --git 
a/regression-test/suites/datatype_p0/agg_state/test_agg_state_union_batch.groovy
 
b/regression-test/suites/datatype_p0/agg_state/test_agg_state_union_batch.groovy
new file mode 100644
index 00000000000..87de9d8bc1c
--- /dev/null
+++ 
b/regression-test/suites/datatype_p0/agg_state/test_agg_state_union_batch.groovy
@@ -0,0 +1,79 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+suite("test_agg_state_union_batch") {
+    sql "set enable_agg_state=true"
+    sql "drop table if exists agg_state_union_batch_input"
+    sql """
+        create table agg_state_union_batch_input (
+            k int not null, v bigint not null, n bigint, d decimal(27, 9), s 
string
+        ) duplicate key(k) distributed by hash(k) buckets 1
+        properties("replication_num"="1")
+    """
+    sql """
+        insert into agg_state_union_batch_input
+        select number % 7, number,
+               if(number % 7 = 6 or number % 5 = 0, null, number),
+               if(number % 7 = 6, null, cast(number as decimal(27, 9)) / 100),
+               if(number % 7 = 6, null, concat('value-', cast(number as 
string)))
+        from numbers("number"="10000")
+    """
+    explain {
+        sql "select k, sum_merge(sum_state(v)) from 
agg_state_union_batch_input group by k"
+        contains "sum_merge"
+    }
+    explain {
+        sql "select k, sum_union(sum_state(v)) from 
agg_state_union_batch_input group by k"
+        contains "sum_union"
+    }
+    // Multiple input batches, repeated groups, multiple state offsets, and an 
all-NULL group.
+    order_qt_merge """
+        select k, sum_merge(sum_state(v)), sum_merge(sum_state(n)),
+               sum_merge(sum_state(d)), min_merge(min_state(s))
+        from agg_state_union_batch_input group by k
+    """
+    order_qt_direct """
+        select k, sum(v), sum(n), sum(d), min(s)
+        from agg_state_union_batch_input group by k
+    """
+    order_qt_union """
+        select k, sum_finalize(sv), sum_finalize(sn), sum_finalize(sd), 
min_finalize(ms)
+        from (
+            select k, sum_union(sum_state(v)) sv, sum_union(sum_state(n)) sn,
+                   sum_union(sum_state(d)) sd, min_union(min_state(s)) ms
+            from agg_state_union_batch_input group by k
+        ) t
+    """
+    // ARRAY_AGG owns nontrivial state. Read its result after all batch 
scratch storage is freed.
+    order_qt_array """
+        with merged as (
+            select k, array_sort(array_agg_merge(array_agg_state(s))) a
+            from agg_state_union_batch_input group by k
+        ), united as (
+            select k, array_sort(array_agg_finalize(a)) a
+            from (
+                select k, array_agg_union(array_agg_state(s)) a
+                from agg_state_union_batch_input group by k
+            ) t
+        ), direct as (
+            select k, array_sort(array_agg(s)) a
+            from agg_state_union_batch_input group by k
+        )
+        select d.k, m.a <=> d.a, u.a <=> d.a
+        from direct d join merged m on d.k=m.k join united u on d.k=u.k
+    """
+}
diff --git 
a/regression-test/suites/nereids_rules_p0/mv/agg_with_roll_up/agg_state_finalize_roll_up.groovy
 
b/regression-test/suites/nereids_rules_p0/mv/agg_with_roll_up/agg_state_finalize_roll_up.groovy
new file mode 100644
index 00000000000..cf57a756b49
--- /dev/null
+++ 
b/regression-test/suites/nereids_rules_p0/mv/agg_with_roll_up/agg_state_finalize_roll_up.groovy
@@ -0,0 +1,92 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+suite("agg_state_finalize_roll_up") {
+    String db = context.config.getDbNameByFile(context.file)
+    sql "set enable_agg_state=true"
+    sql "set pre_materialized_view_rewrite_strategy=TRY_IN_RBO"
+    for (String mv : ["finalize_max_mv", "finalize_abs_mv", "finalize_sum_mv", 
"finalize_union_mv"]) {
+        sql "drop materialized view if exists ${mv}"
+    }
+    sql "drop table if exists agg_finalize_rollup_input"
+    sql """
+        create table agg_finalize_rollup_input (k int, g int, v double)
+        duplicate key(k, g) distributed by hash(k) buckets 1
+        properties("replication_num"="1")
+    """
+    sql """
+        insert into agg_finalize_rollup_input values
+            (1, 1, -4), (1, 1, 3), (1, 2, -2), (1, 2, 7),
+            (2, 1, 5), (2, 2, null), (3, 1, null)
+    """
+    // Make the finer-group MV cheaper than scanning the base table so the 
test executes its rollup.
+    sql """
+        insert into agg_finalize_rollup_input
+        select k, g, v from agg_finalize_rollup_input cross join 
numbers("number"="99")
+    """
+    sql "analyze table agg_finalize_rollup_input with sync"
+
+    String combinedQuery = """
+        select k, sum_finalize(sum_combine(avg_finalize(avg_state(v))))
+        from agg_finalize_rollup_input group by k
+    """
+    String plainQuery = """
+        select k, sum(avg_finalize(avg_state(v)))
+        from agg_finalize_rollup_input group by k
+    """
+    order_qt_combined_before combinedQuery
+    order_qt_plain_before plainQuery
+
+    // Sharing the inner AVG finalizer must not make SUM compatible with MAX.
+    create_async_mv(db, "finalize_max_mv", """
+        select k, g, max_combine(avg_finalize(avg_state(v))) s
+        from agg_finalize_rollup_input group by k, g
+    """)
+    mv_rewrite_fail(combinedQuery, "finalize_max_mv")
+    mv_rewrite_fail(plainQuery, "finalize_max_mv")
+    order_qt_different_aggregate combinedQuery
+
+    // The full value expression matters even when both outer aggregates are 
SUM.
+    create_async_mv(db, "finalize_abs_mv", """
+        select k, g, sum_combine(abs(avg_finalize(avg_state(v)))) s
+        from agg_finalize_rollup_input group by k, g
+    """)
+    mv_rewrite_fail(combinedQuery, "finalize_abs_mv")
+    mv_rewrite_fail(plainQuery, "finalize_abs_mv")
+    order_qt_different_argument combinedQuery
+
+    create_async_mv(db, "finalize_sum_mv", """
+        select k, g, sum_combine(avg_finalize(avg_state(v))) s
+        from agg_finalize_rollup_input group by k, g
+    """)
+    mv_rewrite_success(combinedQuery, "finalize_sum_mv")
+    mv_rewrite_success(plainQuery, "finalize_sum_mv")
+    order_qt_combined_after combinedQuery
+    order_qt_plain_after plainQuery
+
+    // Existing MERGE/UNION/STATE rollup remains available.
+    create_async_mv(db, "finalize_union_mv", """
+        select k, g, sum_union(sum_state(v)) s
+        from agg_finalize_rollup_input group by k, g
+    """)
+    String mergeQuery = """
+        select k, sum_merge(sum_state(v))
+        from agg_finalize_rollup_input group by k
+    """
+    mv_rewrite_success(mergeQuery, "finalize_union_mv")
+    order_qt_merge_after mergeQuery
+}


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

Reply via email to