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

Gabriel39 pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/doris.git


The following commit(s) were added to refs/heads/master by this push:
     new 7809a73814e [regression](test) expand Iceberg and Paimon partition 
evolution coverage (#65992)
7809a73814e is described below

commit 7809a73814eb5f6e7b0b888290db9fbfd25046b6
Author: Gabriel <[email protected]>
AuthorDate: Fri Jul 24 19:55:51 2026 +0800

    [regression](test) expand Iceberg and Paimon partition evolution coverage 
(#65992)
    
    ## What changed
    
    - add five Iceberg P0 suites for partition evolution with static/runtime
    filters, snapshot/tag/branch reads, position deletes, equality deletes,
    deletion vectors, and a complete format/scanner transform timeline
    - add three Paimon P0 suites for fixed-partition complex schema
    evolution, partitioned primary-key delete/upsert/compaction, and atomic
    rejection of unsupported partition-key mutations
    - keep independent dimensions in separate Groovy suites for parallel
    execution, while marking four profile-dependent suites `nonConcurrent`
    - add deterministic runner-generated `.out` files and update the
    Iceberg/Paimon schema evolution and time-travel coverage matrix
    
    ## Coverage highlights
    
    - Iceberg add/drop/replace partition fields and identity, bucket,
    truncate, year/month/day/hour transforms
    - complex STRUCT/MAP/ARRAY changes combined with partition filtering and
    historical references
    - runtime-filter pruning with attributable positive profile-counter
    assertions on supported identity partition paths
    - Parquet/ORC, file scanner V1/V2, Iceberg v2 position/equality deletes,
    and v3 deletion vectors
    - Paimon Parquet/ORC, asserted JNI/native split paths, physical DV
    metadata, primary-key upsert/delete/compaction, snapshot/tag/branch
    - topology-independent execution on a distributed Doris cluster
    
    ## Validation
    
    - master: `f1460f89230441bc1b6b1872d66bd32a526b25b1`
    - one FE and two live BEs
    - eight partition-evolution extension suites: 8 passed, 0 failed, 0
    fatal, 0 skipped
    - documented P0 matrix: 20 suites, including 8 partition-evolution
    extensions
    - no Doris production code changed
    - no additional Doris product issue was reproduced
---
 ...iceberg_partition_evolution_equality_delete.out |  36 ++
 ...est_iceberg_partition_evolution_filter_refs.out |  71 ++++
 ..._iceberg_partition_evolution_format_scanner.out | 153 +++++++++
 ...est_iceberg_partition_evolution_position_dv.out | 201 +++++++++++
 ..._iceberg_partition_evolution_runtime_filter.out |  44 +++
 .../test_paimon_partition_mutation_atomicity.out   |  14 +
 .../test_paimon_partition_pk_delete_refs.out       | 109 ++++++
 .../test_paimon_partition_schema_filter_refs.out   | 145 ++++++++
 ...berg_partition_evolution_equality_delete.groovy | 302 +++++++++++++++++
 ..._iceberg_partition_evolution_filter_refs.groovy | 275 ++++++++++++++++
 ...eberg_partition_evolution_format_scanner.groovy | 211 ++++++++++++
 ..._iceberg_partition_evolution_position_dv.groovy | 318 ++++++++++++++++++
 ...eberg_partition_evolution_runtime_filter.groovy | 277 ++++++++++++++++
 .../iceberg_paimon_schema_time_travel_coverage.md  |  85 ++++-
 ...test_paimon_partition_mutation_atomicity.groovy | 148 +++++++++
 .../test_paimon_partition_pk_delete_refs.groovy    | 346 +++++++++++++++++++
 ...test_paimon_partition_schema_filter_refs.groovy | 366 +++++++++++++++++++++
 17 files changed, 3089 insertions(+), 12 deletions(-)

diff --git 
a/regression-test/data/external_table_p0/iceberg/test_iceberg_partition_evolution_equality_delete.out
 
b/regression-test/data/external_table_p0/iceberg/test_iceberg_partition_evolution_equality_delete.out
new file mode 100644
index 00000000000..5c4002a33f0
--- /dev/null
+++ 
b/regression-test/data/external_table_p0/iceberg/test_iceberg_partition_evolution_equality_delete.out
@@ -0,0 +1,36 @@
+-- This file is automatically generated. You should know what you did if you 
want to edit this
+-- !current_filter --
+1
+5
+7
+
+-- !current_nested --
+7      7000000000
+
+-- !base_tag --
+1
+2
+
+-- !first_delete_snapshot --
+1
+
+-- !added_snapshot --
+1
+5
+6
+
+-- !final_tag --
+1
+5
+7
+
+-- !scanner_v2 --
+1
+5
+7
+
+-- !scanner_v1 --
+1
+5
+7
+
diff --git 
a/regression-test/data/external_table_p0/iceberg/test_iceberg_partition_evolution_filter_refs.out
 
b/regression-test/data/external_table_p0/iceberg/test_iceberg_partition_evolution_filter_refs.out
new file mode 100644
index 00000000000..e4a7f2ca565
--- /dev/null
+++ 
b/regression-test/data/external_table_p0/iceberg/test_iceberg_partition_evolution_filter_refs.out
@@ -0,0 +1,71 @@
+-- This file is automatically generated. You should know what you did if you 
want to edit this
+-- !current_identity_filter --
+1
+3
+5
+7
+
+-- !current_truncate_source_filter --
+1
+3
+5
+7
+
+-- !current_temporal_source_filter --
+5
+6
+7
+8
+
+-- !current_readded_nested_field --
+7      7000
+8      8000
+
+-- !identity_base_snapshot --
+1      A
+2      B
+
+-- !identity_base_tag --
+1      A
+2      B
+
+-- !identity_base_branch --
+1      A
+2      B
+
+-- !identity_added_snapshot --
+1
+3
+
+-- !identity_replaced_snapshot --
+1
+3
+5
+
+-- !identity_dropped_tag --
+1
+3
+5
+7
+
+-- !temporal_year_tag --
+11
+12
+
+-- !temporal_current_all_specs --
+11
+12
+13
+14
+15
+16
+17
+18
+
+-- !temporal_hour_snapshot --
+17
+18
+
+-- !temporal_hour_tag --
+18
+
diff --git 
a/regression-test/data/external_table_p0/iceberg/test_iceberg_partition_evolution_format_scanner.out
 
b/regression-test/data/external_table_p0/iceberg/test_iceberg_partition_evolution_format_scanner.out
new file mode 100644
index 00000000000..c8cd07156d5
--- /dev/null
+++ 
b/regression-test/data/external_table_p0/iceberg/test_iceberg_partition_evolution_format_scanner.out
@@ -0,0 +1,153 @@
+-- This file is automatically generated. You should know what you did if you 
want to edit this
+-- !identity_parquet_v2_current --
+1      \N
+2      \N
+3      \N
+4      \N
+5      \N
+6      \N
+7      7000
+8      8000
+
+-- !identity_parquet_v2_base_tag --
+1      A       base-a
+2      B       base-b
+
+-- !identity_parquet_v2_final_tag --
+1      base-a
+3      bucket-a
+5      truncate-a
+7      drop-a
+
+-- !temporal_parquet_v2_current --
+11
+12
+13
+14
+15
+16
+17
+18
+
+-- !temporal_parquet_v2_final_tag --
+13
+14
+15
+16
+17
+18
+
+-- !identity_parquet_v1_current --
+1      \N
+2      \N
+3      \N
+4      \N
+5      \N
+6      \N
+7      7000
+8      8000
+
+-- !identity_parquet_v1_base_tag --
+1      A       base-a
+2      B       base-b
+
+-- !identity_parquet_v1_final_tag --
+1      base-a
+3      bucket-a
+5      truncate-a
+7      drop-a
+
+-- !temporal_parquet_v1_current --
+11
+12
+13
+14
+15
+16
+17
+18
+
+-- !temporal_parquet_v1_final_tag --
+13
+14
+15
+16
+17
+18
+
+-- !identity_orc_v2_current --
+1      \N
+2      \N
+3      \N
+4      \N
+5      \N
+6      \N
+7      7000
+8      8000
+
+-- !identity_orc_v2_base_tag --
+1      A       base-a
+2      B       base-b
+
+-- !identity_orc_v2_final_tag --
+1      base-a
+3      bucket-a
+5      truncate-a
+7      drop-a
+
+-- !temporal_orc_v2_current --
+11
+12
+13
+14
+15
+16
+17
+18
+
+-- !temporal_orc_v2_final_tag --
+13
+14
+15
+16
+17
+18
+
+-- !identity_orc_v1_current --
+1      \N
+2      \N
+3      \N
+4      \N
+5      \N
+6      \N
+7      7000
+8      8000
+
+-- !identity_orc_v1_base_tag --
+1      A       base-a
+2      B       base-b
+
+-- !identity_orc_v1_final_tag --
+1      base-a
+3      bucket-a
+5      truncate-a
+7      drop-a
+
+-- !temporal_orc_v1_current --
+11
+12
+13
+14
+15
+16
+17
+18
+
+-- !temporal_orc_v1_final_tag --
+13
+14
+15
+16
+17
+18
+
diff --git 
a/regression-test/data/external_table_p0/iceberg/test_iceberg_partition_evolution_position_dv.out
 
b/regression-test/data/external_table_p0/iceberg/test_iceberg_partition_evolution_position_dv.out
new file mode 100644
index 00000000000..bba7dbde155
--- /dev/null
+++ 
b/regression-test/data/external_table_p0/iceberg/test_iceberg_partition_evolution_position_dv.out
@@ -0,0 +1,201 @@
+-- This file is automatically generated. You should know what you did if you 
want to edit this
+-- !position_parquet_current_filter --
+1
+5
+7
+10
+
+-- !position_parquet_current_nested --
+7      7000000000
+
+-- !position_parquet_rf_disabled --
+1
+5
+7
+10
+
+-- !position_parquet_base_snapshot --
+1
+2
+3
+4
+
+-- !position_parquet_base_tag --
+1
+2
+
+-- !position_parquet_first_delete_snapshot --
+1
+
+-- !position_parquet_added_delete_snapshot --
+1
+5
+
+-- !position_parquet_final_tag --
+1
+5
+7
+10
+
+-- !position_parquet_scanner_v2 --
+1
+5
+7
+10
+
+-- !position_parquet_scanner_v1 --
+1
+5
+7
+10
+
+-- !dv_parquet_current_filter --
+1
+5
+7
+10
+
+-- !dv_parquet_current_nested --
+7      7000000000
+
+-- !dv_parquet_rf_disabled --
+1
+5
+7
+10
+
+-- !dv_parquet_base_snapshot --
+1
+2
+3
+4
+
+-- !dv_parquet_base_tag --
+1
+2
+
+-- !dv_parquet_first_delete_snapshot --
+1
+
+-- !dv_parquet_added_delete_snapshot --
+1
+5
+
+-- !dv_parquet_final_tag --
+1
+5
+7
+10
+
+-- !dv_parquet_scanner_v2 --
+1
+5
+7
+10
+
+-- !dv_parquet_scanner_v1 --
+1
+5
+7
+10
+
+-- !position_orc_current_filter --
+1
+5
+7
+10
+
+-- !position_orc_current_nested --
+7      7000000000
+
+-- !position_orc_rf_disabled --
+1
+5
+7
+10
+
+-- !position_orc_base_snapshot --
+1
+2
+3
+4
+
+-- !position_orc_base_tag --
+1
+2
+
+-- !position_orc_first_delete_snapshot --
+1
+
+-- !position_orc_added_delete_snapshot --
+1
+5
+
+-- !position_orc_final_tag --
+1
+5
+7
+10
+
+-- !position_orc_scanner_v2 --
+1
+5
+7
+10
+
+-- !position_orc_scanner_v1 --
+1
+5
+7
+10
+
+-- !dv_orc_current_filter --
+1
+5
+7
+10
+
+-- !dv_orc_current_nested --
+7      7000000000
+
+-- !dv_orc_rf_disabled --
+1
+5
+7
+10
+
+-- !dv_orc_base_snapshot --
+1
+2
+3
+4
+
+-- !dv_orc_base_tag --
+1
+2
+
+-- !dv_orc_first_delete_snapshot --
+1
+
+-- !dv_orc_added_delete_snapshot --
+1
+5
+
+-- !dv_orc_final_tag --
+1
+5
+7
+10
+
+-- !dv_orc_scanner_v2 --
+1
+5
+7
+10
+
+-- !dv_orc_scanner_v1 --
+1
+5
+7
+10
+
diff --git 
a/regression-test/data/external_table_p0/iceberg/test_iceberg_partition_evolution_runtime_filter.out
 
b/regression-test/data/external_table_p0/iceberg/test_iceberg_partition_evolution_runtime_filter.out
new file mode 100644
index 00000000000..78518f51a42
--- /dev/null
+++ 
b/regression-test/data/external_table_p0/iceberg/test_iceberg_partition_evolution_runtime_filter.out
@@ -0,0 +1,44 @@
+-- This file is automatically generated. You should know what you did if you 
want to edit this
+-- !category_rf_disabled --
+1
+4
+6
+8
+
+-- !added_identity_rf_disabled --
+1
+4
+6
+8
+
+-- !bucket_source_rf_disabled --
+4
+
+-- !temporal_rf_disabled --
+6
+7
+8
+9
+
+-- !bucket_source_rf_enabled --
+4
+
+-- !temporal_rf_enabled --
+6
+7
+8
+9
+
+-- !base_snapshot_rf --
+1
+
+-- !added_snapshot_rf --
+1
+4
+
+-- !dropped_tag_rf --
+1
+4
+6
+8
+
diff --git 
a/regression-test/data/external_table_p0/paimon/test_paimon_partition_mutation_atomicity.out
 
b/regression-test/data/external_table_p0/paimon/test_paimon_partition_mutation_atomicity.out
new file mode 100644
index 00000000000..e1fcf8c5234
--- /dev/null
+++ 
b/regression-test/data/external_table_p0/paimon/test_paimon_partition_mutation_atomicity.out
@@ -0,0 +1,14 @@
+-- This file is automatically generated. You should know what you did if you 
want to edit this
+-- !current_partition_filter --
+1
+2
+4
+
+-- !current_added_payload --
+4      added
+
+-- !base_tag_partition_filter --
+1      A       1       base-a1
+2      A       2       base-a2
+3      B       1       base-b1
+
diff --git 
a/regression-test/data/external_table_p0/paimon/test_paimon_partition_pk_delete_refs.out
 
b/regression-test/data/external_table_p0/paimon/test_paimon_partition_pk_delete_refs.out
new file mode 100644
index 00000000000..0d8ef36d633
--- /dev/null
+++ 
b/regression-test/data/external_table_p0/paimon/test_paimon_partition_pk_delete_refs.out
@@ -0,0 +1,109 @@
+-- This file is automatically generated. You should know what you did if you 
want to edit this
+-- !parquet_current_partition_filter --
+1      alpha-updated
+5      epsilon
+
+-- !parquet_current_promoted_and_readded --
+5      5000000000      5000
+
+-- !parquet_current_evolved_fields --
+1      updated-1       extra-1 \N
+5      insert-5        extra-5 5000
+
+-- !parquet_pre_dv_old_row_null --
+1      updated-1       extra-1 \N
+
+-- !parquet_rf_disabled --
+1      alpha-updated
+5      epsilon
+
+-- !parquet_base_snapshot --
+1      alpha   old-note-1      base-1
+2      beta    old-note-2      base-delete
+
+-- !parquet_base_tag --
+1      alpha   old-note-1      base-1
+2      beta    old-note-2      base-delete
+
+-- !parquet_first_delete_snapshot --
+1      alpha-updated   new-note-1      updated-1       extra-1
+4      delta   delete-later    insert-4        extra-4
+
+-- !parquet_final_tag --
+1      alpha-updated   \N      updated-1       extra-1
+5      epsilon 5000    insert-5        extra-5
+
+-- !parquet_jni_current_dv --
+1      p1      alpha-updated   \N      updated-1       extra-1
+5      p1      epsilon 5000    insert-5        extra-5
+3      p2      gamma   \N      base-p2 \N
+
+-- !parquet_jni_historical --
+1      p1      alpha   old-note-1      base-1
+2      p1      beta    old-note-2      base-delete
+3      p2      gamma   old-note-3      base-p2
+
+-- !parquet_native_current_dv --
+1      p1      alpha-updated   \N      updated-1       extra-1
+5      p1      epsilon 5000    insert-5        extra-5
+3      p2      gamma   \N      base-p2 \N
+
+-- !parquet_native_historical --
+1      p1      alpha   old-note-1      base-1
+2      p1      beta    old-note-2      base-delete
+3      p2      gamma   old-note-3      base-p2
+
+-- !orc_current_partition_filter --
+1      alpha-updated
+5      epsilon
+
+-- !orc_current_promoted_and_readded --
+5      5000000000      5000
+
+-- !orc_current_evolved_fields --
+1      updated-1       extra-1 \N
+5      insert-5        extra-5 5000
+
+-- !orc_pre_dv_old_row_null --
+1      updated-1       extra-1 \N
+
+-- !orc_rf_disabled --
+1      alpha-updated
+5      epsilon
+
+-- !orc_base_snapshot --
+1      alpha   old-note-1      base-1
+2      beta    old-note-2      base-delete
+
+-- !orc_base_tag --
+1      alpha   old-note-1      base-1
+2      beta    old-note-2      base-delete
+
+-- !orc_first_delete_snapshot --
+1      alpha-updated   new-note-1      updated-1       extra-1
+4      delta   delete-later    insert-4        extra-4
+
+-- !orc_final_tag --
+1      alpha-updated   \N      updated-1       extra-1
+5      epsilon 5000    insert-5        extra-5
+
+-- !orc_jni_current_dv --
+1      p1      alpha-updated   \N      updated-1       extra-1
+5      p1      epsilon 5000    insert-5        extra-5
+3      p2      gamma   \N      base-p2 \N
+
+-- !orc_jni_historical --
+1      p1      alpha   old-note-1      base-1
+2      p1      beta    old-note-2      base-delete
+3      p2      gamma   old-note-3      base-p2
+
+-- !orc_native_current_dv --
+1      p1      alpha-updated   \N      updated-1       extra-1
+5      p1      epsilon 5000    insert-5        extra-5
+3      p2      gamma   \N      base-p2 \N
+
+-- !orc_native_historical --
+1      p1      alpha   old-note-1      base-1
+2      p1      beta    old-note-2      base-delete
+3      p2      gamma   old-note-3      base-p2
+
diff --git 
a/regression-test/data/external_table_p0/paimon/test_paimon_partition_schema_filter_refs.out
 
b/regression-test/data/external_table_p0/paimon/test_paimon_partition_schema_filter_refs.out
new file mode 100644
index 00000000000..ad3c6a41095
--- /dev/null
+++ 
b/regression-test/data/external_table_p0/paimon/test_paimon_partition_schema_filter_refs.out
@@ -0,0 +1,145 @@
+-- This file is automatically generated. You should know what you did if you 
want to edit this
+-- !parquet_current_partition_filter --
+1
+3
+5
+7
+
+-- !parquet_current_promoted_metric --
+5      5000000000
+
+-- !parquet_current_readded_payload --
+7      7000
+
+-- !parquet_current_all_complex_children --
+1      base-1  \N      100     \N      1000    \N
+3      add-3   \N      300     301     3000    3001
+5      rename-5        \N      500     501     5000    5001
+7      readd-7 7000    700     701     7000    7001
+
+-- !parquet_rf_disabled --
+1
+3
+5
+7
+
+-- !parquet_base_snapshot --
+1      p1      base-1
+2      p2      base-2
+
+-- !parquet_base_tag --
+1      p1      base-1
+2      p2      base-2
+
+-- !parquet_base_branch --
+1      p1      base-1
+2      p2      base-2
+
+-- !parquet_added_snapshot_complex --
+1      base-1  100     \N      1000    \N
+3      add-3   300     301     3000    3001
+
+-- !parquet_final_tag --
+1
+3
+5
+7
+
+-- !parquet_base_snapshot_complex --
+1      100     1000
+2      200     2000
+
+-- !parquet_jni_current_complex --
+1      base-1  \N      100     1000
+3      add-3   \N      300     3000
+4      add-4   \N      400     4000
+5      rename-5        \N      500     5000
+7      readd-7 7000    700     7000
+
+-- !parquet_jni_historical_complex --
+1      base-1  100     1000
+2      base-2  200     2000
+
+-- !parquet_native_current_complex --
+1      base-1  \N      100     1000
+3      add-3   \N      300     3000
+4      add-4   \N      400     4000
+5      rename-5        \N      500     5000
+7      readd-7 7000    700     7000
+
+-- !parquet_native_historical_complex --
+1      base-1  100     1000
+2      base-2  200     2000
+
+-- !orc_current_partition_filter --
+1
+3
+5
+7
+
+-- !orc_current_promoted_metric --
+5      5000000000
+
+-- !orc_current_readded_payload --
+7      7000
+
+-- !orc_current_all_complex_children --
+1      base-1  \N      100     \N      1000    \N
+3      add-3   \N      300     301     3000    3001
+5      rename-5        \N      500     501     5000    5001
+7      readd-7 7000    700     701     7000    7001
+
+-- !orc_rf_disabled --
+1
+3
+5
+7
+
+-- !orc_base_snapshot --
+1      p1      base-1
+2      p2      base-2
+
+-- !orc_base_tag --
+1      p1      base-1
+2      p2      base-2
+
+-- !orc_base_branch --
+1      p1      base-1
+2      p2      base-2
+
+-- !orc_added_snapshot_complex --
+1      base-1  100     \N      1000    \N
+3      add-3   300     301     3000    3001
+
+-- !orc_final_tag --
+1
+3
+5
+7
+
+-- !orc_base_snapshot_complex --
+1      100     1000
+2      200     2000
+
+-- !orc_jni_current_complex --
+1      base-1  \N      100     1000
+3      add-3   \N      300     3000
+4      add-4   \N      400     4000
+5      rename-5        \N      500     5000
+7      readd-7 7000    700     7000
+
+-- !orc_jni_historical_complex --
+1      base-1  100     1000
+2      base-2  200     2000
+
+-- !orc_native_current_complex --
+1      base-1  \N      100     1000
+3      add-3   \N      300     3000
+4      add-4   \N      400     4000
+5      rename-5        \N      500     5000
+7      readd-7 7000    700     7000
+
+-- !orc_native_historical_complex --
+1      base-1  100     1000
+2      base-2  200     2000
+
diff --git 
a/regression-test/suites/external_table_p0/iceberg/test_iceberg_partition_evolution_equality_delete.groovy
 
b/regression-test/suites/external_table_p0/iceberg/test_iceberg_partition_evolution_equality_delete.groovy
new file mode 100644
index 00000000000..fab5dc7176d
--- /dev/null
+++ 
b/regression-test/suites/external_table_p0/iceberg/test_iceberg_partition_evolution_equality_delete.groovy
@@ -0,0 +1,302 @@
+// 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_iceberg_partition_evolution_equality_delete",
+        "p0,external,iceberg,external_docker,external_docker_iceberg") {
+    String enabled = context.config.otherConfigs.get("enableIcebergTest")
+    if (enabled == null || !enabled.equalsIgnoreCase("true")) {
+        logger.info("disable iceberg test")
+        return
+    }
+
+    String restPort = context.config.otherConfigs.get("iceberg_rest_uri_port")
+    String minioPort = context.config.otherConfigs.get("iceberg_minio_port")
+    String externalEnvIp = context.config.otherConfigs.get("externalEnvIp")
+    String catalogName = "test_iceberg_partition_evolution_equality_delete"
+    String dbName = "iceberg_partition_evolution_equality_delete_db"
+    String tableName = "equality_delete_evolved"
+
+    def stringRows = { String query ->
+        sql(query).collect { row ->
+            row.collect { value -> value == null ? null : value.toString() }
+        }
+    }
+    def latestSnapshotId = {
+        // The Java helper commits through the REST catalog directly, so 
invalidate Spark's
+        // cached table metadata before recording the snapshot used by 
time-travel assertions.
+        spark_iceberg """refresh table demo.${dbName}.${tableName}"""
+        return spark_iceberg("""
+            select snapshot_id
+            from demo.${dbName}.${tableName}.snapshots
+            order by committed_at desc
+            limit 1
+        """)[0][0].toString()
+    }
+    def executeCommand = { String command, int timeoutSeconds = 300 ->
+        StringBuilder stdout = new StringBuilder()
+        StringBuilder stderr = new StringBuilder()
+        def process = new ProcessBuilder("/bin/bash", "-c", command).start()
+        process.consumeProcessOutput(stdout, stderr)
+        process.waitForOrKill(timeoutSeconds * 1000)
+        assertEquals(0, process.exitValue(),
+                "Command failed\nstdout:\n${stdout}\nstderr:\n${stderr}")
+        return stdout.toString()
+    }
+    String dockerCommand = 
context.config.otherConfigs.get("externalDockerCommand") ?: "docker"
+    String sparkContainer = 
context.config.otherConfigs.get("icebergSparkContainer")
+    if (sparkContainer == null || sparkContainer.isEmpty()) {
+        String containers = executeCommand(
+                "${dockerCommand} ps --format '{{.ID}}\t{{.Names}}'", 30)
+        def matches = []
+        containers.readLines().each { String line ->
+            String containerId = line.split(/\t/, 2)[0]
+            String probe = "${dockerCommand} exec ${containerId} bash -lc " +
+                    "'test -f /mnt/SUCCESS && command -v spark-sql >/dev/null'"
+            try {
+                executeCommand(probe, 30)
+                matches.add(containerId)
+            } catch (Throwable ignored) {
+                // A shared external environment contains multiple services; 
only Spark has the
+                // Iceberg jars needed to construct a real equality-delete 
file.
+            }
+        }
+        assertEquals(1, matches.size(), "Expected exactly one usable Spark 
Iceberg container")
+        sparkContainer = matches[0]
+    }
+    def runInSparkContainer = { String command ->
+        executeCommand("${dockerCommand} exec ${sparkContainer} bash -lc 
'${command}'", 300)
+    }
+
+    String javaSource = '''
+import java.util.HashMap;
+import java.util.Map;
+import org.apache.iceberg.CatalogUtil;
+import org.apache.iceberg.DeleteFile;
+import org.apache.iceberg.PartitionSpec;
+import org.apache.iceberg.Schema;
+import org.apache.iceberg.Table;
+import org.apache.iceberg.catalog.Catalog;
+import org.apache.iceberg.catalog.TableIdentifier;
+import org.apache.iceberg.data.GenericRecord;
+import org.apache.iceberg.data.Record;
+import org.apache.iceberg.data.parquet.GenericParquetWriter;
+import org.apache.iceberg.deletes.EqualityDeleteWriter;
+import org.apache.iceberg.io.OutputFile;
+import org.apache.iceberg.parquet.Parquet;
+
+public class AppendEvolutionEqualityDelete {
+    public static void main(String[] args) throws Exception {
+        Map<String, String> props = new HashMap<>();
+        props.put("type", "rest");
+        props.put("uri", "http://rest:8181";);
+        props.put("warehouse", "s3://warehouse/wh/");
+        props.put("io-impl", "org.apache.iceberg.aws.s3.S3FileIO");
+        props.put("s3.endpoint", "http://minio:9000";);
+        props.put("s3.path-style-access", "true");
+        props.put("s3.region", "us-east-1");
+        Catalog catalog = CatalogUtil.buildIcebergCatalog("demo", props, null);
+        Table table = catalog.loadTable(TableIdentifier.of(args[0], args[1]));
+        if (!table.spec().isUnpartitioned()) {
+            throw new IllegalStateException("Equality-delete checkpoints must 
use an unpartitioned spec");
+        }
+        String fieldName = args[2];
+        Schema equalitySchema = table.schema().select(fieldName);
+        int fieldId = table.schema().findField(fieldName).fieldId();
+        OutputFile output = table.io().newOutputFile(
+                table.location() + "/data/evolution-equality-delete-" + args[3]
+                        + "-" + System.currentTimeMillis() + ".parquet");
+        EqualityDeleteWriter<Record> writer = Parquet.writeDeletes(output)
+                .forTable(table)
+                .rowSchema(equalitySchema)
+                .withSpec(PartitionSpec.unpartitioned())
+                .createWriterFunc(GenericParquetWriter::create)
+                .equalityFieldIds(fieldId)
+                .overwrite()
+                .buildEqualityWriter();
+        GenericRecord record = GenericRecord.create(equalitySchema);
+        record.setField(fieldName, Integer.valueOf(args[3]));
+        writer.write(record);
+        writer.close();
+        DeleteFile deleteFile = writer.toDeleteFile();
+        table.newRowDelta().addDeletes(deleteFile).commit();
+    }
+}
+'''
+    String encodedJava = javaSource.getBytes("UTF-8").encodeBase64().toString()
+
+    sql """drop catalog if exists ${catalogName}"""
+    sql """
+        create catalog ${catalogName} properties (
+            'type'='iceberg',
+            'iceberg.catalog.type'='rest',
+            'uri'='http://${externalEnvIp}:${restPort}',
+            's3.access_key'='admin',
+            's3.secret_key'='password',
+            's3.endpoint'='http://${externalEnvIp}:${minioPort}',
+            's3.region'='us-east-1',
+            'meta.cache.iceberg.table.ttl-second'='0'
+        )
+    """
+
+    try {
+        runInSparkContainer(
+                "echo ${encodedJava} | base64 -d 
>/tmp/AppendEvolutionEqualityDelete.java && "
+                        + "javac -cp \"/opt/spark/jars/*\" "
+                        + "/tmp/AppendEvolutionEqualityDelete.java")
+        spark_iceberg_multi """
+            create database if not exists demo.${dbName};
+            drop table if exists demo.${dbName}.${tableName};
+            create table demo.${dbName}.${tableName} (
+                id int,
+                category string,
+                code string,
+                event_time timestamp,
+                payload struct<metric:int, label:string>
+            ) using iceberg
+            tblproperties (
+                'format-version'='2',
+                'write.format.default'='parquet'
+            );
+            insert into demo.${dbName}.${tableName} values
+                (1, 'A', 'aa-1', timestamp '2026-01-01 01:00:00',
+                    named_struct('metric', 10, 'label', 'base-a')),
+                (2, 'A', 'aa-2', timestamp '2026-01-01 02:00:00',
+                    named_struct('metric', 20, 'label', 'base-delete')),
+                (3, 'B', 'bb-1', timestamp '2026-01-02 01:00:00',
+                    named_struct('metric', 30, 'label', 'base-b')),
+                (4, 'C', 'cc-1', timestamp '2026-01-03 01:00:00',
+                    named_struct('metric', 40, 'label', 'base-c'));
+        """
+        String baseSnapshot = latestSnapshotId()
+        sql """
+            alter table `${catalogName}`.`${dbName}`.`${tableName}`
+            create tag equality_base as of version ${baseSnapshot}
+        """
+
+        // Scenario PE-EQ01: an equality delete written under the original 
unpartitioned spec
+        // must remain effective after later partition specs are added.
+        runInSparkContainer(
+                "java -cp \"/tmp:/opt/spark/jars/*\" 
AppendEvolutionEqualityDelete "
+                        + "${dbName} ${tableName} id 2")
+        String firstDeleteSnapshot = latestSnapshotId()
+
+        // Scenario PE-EQ02: ADD identity/day/bucket fields and a nested child 
after the delete.
+        spark_iceberg_multi """
+            alter table demo.${dbName}.${tableName} add partition field 
category;
+            alter table demo.${dbName}.${tableName} add partition field 
days(event_time);
+            alter table demo.${dbName}.${tableName} add partition field 
bucket(8, id);
+            alter table demo.${dbName}.${tableName} add column payload.extra 
string;
+            insert into demo.${dbName}.${tableName} values
+                (5, 'A', 'aa-3', timestamp '2026-02-01 01:00:00',
+                    named_struct('metric', 50, 'label', 'added-a', 'extra', 
'new-child')),
+                (6, 'A', 'aa-4', timestamp '2026-02-01 02:00:00',
+                    named_struct('metric', 60, 'label', 'added-delete', 
'extra', 'new-child'));
+        """
+        String addedSnapshot = latestSnapshotId()
+
+        // Scenario PE-EQ03: REPLACE day -> month while renaming/promoting 
nested fields.
+        spark_iceberg_multi """
+            alter table demo.${dbName}.${tableName}
+                replace partition field days(event_time) with 
months(event_time);
+            alter table demo.${dbName}.${tableName}
+                replace partition field bucket(8, id) with truncate(2, code);
+            alter table demo.${dbName}.${tableName}
+                rename column payload.label to renamed_label;
+            alter table demo.${dbName}.${tableName}
+                alter column payload.metric type bigint;
+            insert into demo.${dbName}.${tableName} values
+                (7, 'A', 'aa-5', timestamp '2026-03-01 01:00:00',
+                    named_struct('metric', 7000000000,
+                        'renamed_label', 'replace-a', 'extra', 
'renamed-child')),
+                (8, 'A', 'aa-6', timestamp '2026-03-01 02:00:00',
+                    named_struct('metric', 80,
+                        'renamed_label', 'replace-delete', 'extra', 
'renamed-child'));
+        """
+
+        // Scenario PE-EQ04: return to an unpartitioned spec and write 
equality deletes after
+        // evolution. This covers delete files both before and after the 
multi-spec interval.
+        spark_iceberg_multi """
+            alter table demo.${dbName}.${tableName} drop partition field 
category;
+            alter table demo.${dbName}.${tableName} drop partition field 
months(event_time);
+            alter table demo.${dbName}.${tableName} drop partition field 
truncate(2, code);
+        """
+        runInSparkContainer(
+                "java -cp \"/tmp:/opt/spark/jars/*\" 
AppendEvolutionEqualityDelete "
+                        + "${dbName} ${tableName} id 6")
+        runInSparkContainer(
+                "java -cp \"/tmp:/opt/spark/jars/*\" 
AppendEvolutionEqualityDelete "
+                        + "${dbName} ${tableName} id 8")
+        String finalSnapshot = latestSnapshotId()
+        sql """
+            alter table `${catalogName}`.`${dbName}`.`${tableName}`
+            create tag equality_final as of version ${finalSnapshot}
+        """
+
+        sql """switch ${catalogName}"""
+        sql """use ${dbName}"""
+        sql """refresh catalog ${catalogName}"""
+
+        // Scenario PE-EQ05: partition-source filters honor equality deletes 
across every spec.
+        qt_current_filter """
+            select id from ${tableName} where category = 'A' order by id
+        """
+        qt_current_nested """
+            select id, payload.metric from ${tableName}
+            where event_time >= timestamp '2026-03-01 00:00:00'
+              and payload.metric > 5000000000
+            order by id
+        """
+
+        // Scenario PE-EQ06: numeric snapshots and tags select the matching 
delete/spec state.
+        qt_base_tag """
+            select id from ${tableName}@tag(equality_base)
+            where category = 'A' order by id
+        """
+        qt_first_delete_snapshot """
+            select id from ${tableName} for version as of 
${firstDeleteSnapshot}
+            where category = 'A' order by id
+        """
+        qt_added_snapshot """
+            select id from ${tableName} for version as of ${addedSnapshot}
+            where category = 'A' order by id
+        """
+        qt_final_tag """
+            select id from ${tableName}@tag(equality_final)
+            where category = 'A' order by id
+        """
+
+        // Scenario PE-EQ07: both scanner implementations apply the same 
equality deletes.
+        sql """set enable_file_scanner_v2=true"""
+        qt_scanner_v2 """
+            select id from ${tableName} where category = 'A' order by id
+        """
+        sql """set enable_file_scanner_v2=false"""
+        qt_scanner_v1 """
+            select id from ${tableName} where category = 'A' order by id
+        """
+
+        List<List<String>> equalityFiles = stringRows("""
+            select file_format from ${tableName}\$all_files
+            where content = 2 order by file_path
+        """)
+        assertEquals(3, equalityFiles.size(),
+                "${tableName} must contain all three equality-delete files")
+    } finally {
+        sql """set enable_file_scanner_v2=true"""
+        sql """drop catalog if exists ${catalogName}"""
+    }
+}
diff --git 
a/regression-test/suites/external_table_p0/iceberg/test_iceberg_partition_evolution_filter_refs.groovy
 
b/regression-test/suites/external_table_p0/iceberg/test_iceberg_partition_evolution_filter_refs.groovy
new file mode 100644
index 00000000000..bc31278706f
--- /dev/null
+++ 
b/regression-test/suites/external_table_p0/iceberg/test_iceberg_partition_evolution_filter_refs.groovy
@@ -0,0 +1,275 @@
+// 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_iceberg_partition_evolution_filter_refs",
+        "p0,external,iceberg,external_docker,external_docker_iceberg") {
+    String enabled = context.config.otherConfigs.get("enableIcebergTest")
+    if (enabled == null || !enabled.equalsIgnoreCase("true")) {
+        logger.info("disable iceberg test")
+        return
+    }
+
+    String restPort = context.config.otherConfigs.get("iceberg_rest_uri_port")
+    String minioPort = context.config.otherConfigs.get("iceberg_minio_port")
+    String externalEnvIp = context.config.otherConfigs.get("externalEnvIp")
+    String catalogName = "test_iceberg_partition_evolution_filter_refs"
+    String dbName = "iceberg_partition_evolution_filter_refs_db"
+    String identityTable = "identity_bucket_truncate_timeline"
+    String temporalTable = "temporal_transform_timeline"
+
+    def latestSnapshotId = { String tableName ->
+        List<List<Object>> rows = spark_iceberg """
+            select snapshot_id
+            from demo.${dbName}.${tableName}.snapshots
+            order by committed_at desc
+            limit 1
+        """
+        assertEquals(1, rows.size())
+        return rows[0][0].toString()
+    }
+
+    sql """drop catalog if exists ${catalogName}"""
+    sql """
+        create catalog ${catalogName} properties (
+            'type'='iceberg',
+            'iceberg.catalog.type'='rest',
+            'uri'='http://${externalEnvIp}:${restPort}',
+            's3.access_key'='admin',
+            's3.secret_key'='password',
+            's3.endpoint'='http://${externalEnvIp}:${minioPort}',
+            's3.region'='us-east-1',
+            'meta.cache.iceberg.table.ttl-second'='0',
+            'meta.cache.iceberg.schema.ttl-second'='0'
+        )
+    """
+
+    try {
+        spark_iceberg_multi """
+            create database if not exists demo.${dbName};
+            drop table if exists demo.${dbName}.${identityTable};
+            create table demo.${dbName}.${identityTable} (
+                id int,
+                category string,
+                code string,
+                event_time timestamp,
+                payload struct<metric:int, label:string>
+            ) using iceberg
+            partitioned by (category, days(event_time))
+            tblproperties (
+                'format-version'='2',
+                'write.format.default'='parquet'
+            );
+            insert into demo.${dbName}.${identityTable} values
+                (1, 'A', 'aa-1', timestamp '2026-01-01 01:00:00',
+                    named_struct('metric', 10, 'label', 'base-a')),
+                (2, 'B', 'bb-1', timestamp '2026-01-02 01:00:00',
+                    named_struct('metric', 20, 'label', 'base-b'));
+        """
+        String identityBase = latestSnapshotId(identityTable)
+        sql """
+            alter table `${catalogName}`.`${dbName}`.`${identityTable}`
+            create tag identity_base as of version ${identityBase}
+        """
+        sql """
+            alter table `${catalogName}`.`${dbName}`.`${identityTable}`
+            create branch identity_base_branch as of version ${identityBase}
+        """
+
+        // Scenario PE-I01: ADD bucket partition field and add a complex-type 
child in the same
+        // timeline. Filters must evaluate old files whose spec has no bucket 
field.
+        spark_iceberg_multi """
+            alter table demo.${dbName}.${identityTable} add partition field 
bucket(8, id);
+            alter table demo.${dbName}.${identityTable} add column 
payload.extra string;
+            insert into demo.${dbName}.${identityTable} values
+                (3, 'A', 'aa-2', timestamp '2026-02-01 01:00:00',
+                    named_struct('metric', 30, 'label', 'bucket-a', 'extra', 
'add-child')),
+                (4, 'C', 'cc-1', timestamp '2026-02-02 01:00:00',
+                    named_struct('metric', 40, 'label', 'bucket-c', 'extra', 
'add-child'));
+        """
+        String identityAdded = latestSnapshotId(identityTable)
+        sql """
+            alter table `${catalogName}`.`${dbName}`.`${identityTable}`
+            create tag identity_added as of version ${identityAdded}
+        """
+
+        // Scenario PE-I02: REPLACE bucket with truncate while 
renaming/promoting nested children.
+        // Predicates on both old and new partition source columns must scan 
every applicable spec.
+        spark_iceberg_multi """
+            alter table demo.${dbName}.${identityTable}
+                replace partition field bucket(8, id) with truncate(2, code);
+            alter table demo.${dbName}.${identityTable}
+                rename column payload.label to renamed_label;
+            alter table demo.${dbName}.${identityTable}
+                alter column payload.metric type bigint;
+            insert into demo.${dbName}.${identityTable} values
+                (5, 'A', 'aa-3', timestamp '2026-03-01 01:00:00',
+                    named_struct('metric', 5000000000, 'renamed_label', 
'truncate-a',
+                        'extra', 'renamed-child')),
+                (6, 'D', 'dd-1', timestamp '2026-03-02 01:00:00',
+                    named_struct('metric', 60, 'renamed_label', 'truncate-d',
+                        'extra', 'renamed-child'));
+        """
+        String identityReplaced = latestSnapshotId(identityTable)
+
+        // Scenario PE-I03: DROP identity and temporal fields, then 
drop/re-add a nested name.
+        // New unpartitioned-by-category files and old identity-partitioned 
files coexist.
+        spark_iceberg_multi """
+            alter table demo.${dbName}.${identityTable} drop partition field 
category;
+            alter table demo.${dbName}.${identityTable} drop partition field 
days(event_time);
+            alter table demo.${dbName}.${identityTable} drop column 
payload.extra;
+            alter table demo.${dbName}.${identityTable} add column 
payload.extra bigint;
+            insert into demo.${dbName}.${identityTable} values
+                (7, 'A', 'aa-4', timestamp '2026-04-01 01:00:00',
+                    named_struct('metric', 70, 'renamed_label', 
'dropped-partition',
+                        'extra', 7000)),
+                (8, 'E', 'ee-1', timestamp '2026-04-02 01:00:00',
+                    named_struct('metric', 80, 'renamed_label', 
'dropped-partition',
+                        'extra', 8000));
+        """
+        String identityDropped = latestSnapshotId(identityTable)
+        sql """
+            alter table `${catalogName}`.`${dbName}`.`${identityTable}`
+            create tag identity_dropped as of version ${identityDropped}
+        """
+
+        spark_iceberg_multi """
+            drop table if exists demo.${dbName}.${temporalTable};
+            create table demo.${dbName}.${temporalTable} (
+                id int,
+                event_time timestamp,
+                payload string
+            ) using iceberg
+            partitioned by (years(event_time))
+            tblproperties (
+                'format-version'='2',
+                'write.format.default'='orc'
+            );
+            insert into demo.${dbName}.${temporalTable} values
+                (11, timestamp '2024-01-01 01:00:00', 'year-2024'),
+                (12, timestamp '2025-01-01 01:00:00', 'year-2025');
+        """
+        String temporalYear = latestSnapshotId(temporalTable)
+        sql """
+            alter table `${catalogName}`.`${dbName}`.`${temporalTable}`
+            create tag temporal_year as of version ${temporalYear}
+        """
+
+        // Scenario PE-T01: REPLACE year -> month -> day -> hour across ORC 
files.
+        // Range and equality filters validate every temporal transform 
boundary.
+        spark_iceberg_multi """
+            alter table demo.${dbName}.${temporalTable}
+                replace partition field years(event_time) with 
months(event_time);
+            insert into demo.${dbName}.${temporalTable} values
+                (13, timestamp '2026-02-01 01:00:00', 'month-feb'),
+                (14, timestamp '2026-03-01 01:00:00', 'month-mar');
+            alter table demo.${dbName}.${temporalTable}
+                replace partition field months(event_time) with 
days(event_time);
+            insert into demo.${dbName}.${temporalTable} values
+                (15, timestamp '2026-04-03 01:00:00', 'day-03'),
+                (16, timestamp '2026-04-04 01:00:00', 'day-04');
+            alter table demo.${dbName}.${temporalTable}
+                replace partition field days(event_time) with 
hours(event_time);
+            insert into demo.${dbName}.${temporalTable} values
+                (17, timestamp '2026-05-01 08:00:00', 'hour-08'),
+                (18, timestamp '2026-05-01 09:00:00', 'hour-09');
+        """
+        String temporalHour = latestSnapshotId(temporalTable)
+        sql """
+            alter table `${catalogName}`.`${dbName}`.`${temporalTable}`
+            create tag temporal_hour as of version ${temporalHour}
+        """
+
+        sql """switch ${catalogName}"""
+        sql """use ${dbName}"""
+        sql """refresh catalog ${catalogName}"""
+
+        // Scenario PE-F01: equality/range/IN/NULL-safe source-column filters 
span four specs.
+        qt_current_identity_filter """
+            select id from ${identityTable} where category = 'A' order by id
+        """
+        qt_current_truncate_source_filter """
+            select id from ${identityTable}
+            where code in ('aa-1', 'aa-2', 'aa-3', 'aa-4')
+            order by id
+        """
+        qt_current_temporal_source_filter """
+            select id from ${identityTable}
+            where event_time >= timestamp '2026-03-01 00:00:00'
+            order by id
+        """
+        qt_current_readded_nested_field """
+            select id, payload.extra from ${identityTable}
+            where payload.extra is not null
+            order by id
+        """
+
+        // Scenario PE-R01: numeric snapshot, tag and branch retain their own 
data/spec timeline.
+        qt_identity_base_snapshot """
+            select id, category
+            from ${identityTable} for version as of ${identityBase}
+            where category in ('A', 'B')
+            order by id
+        """
+        qt_identity_base_tag """
+            select id, category from ${identityTable}@tag(identity_base)
+            where category in ('A', 'B') order by id
+        """
+        qt_identity_base_branch """
+            select id, category from 
${identityTable}@branch(identity_base_branch)
+            where category in ('A', 'B') order by id
+        """
+        qt_identity_added_snapshot """
+            select id from ${identityTable} for version as of ${identityAdded}
+            where category = 'A' order by id
+        """
+        qt_identity_replaced_snapshot """
+            select id from ${identityTable} for version as of 
${identityReplaced}
+            where category = 'A' order by id
+        """
+        qt_identity_dropped_tag """
+            select id from ${identityTable}@tag(identity_dropped)
+            where category = 'A' order by id
+        """
+
+        // Scenario PE-F02/PE-R02: temporal filters use the spec selected by 
numeric/tag refs.
+        qt_temporal_year_tag """
+            select id from ${temporalTable}@tag(temporal_year)
+            where event_time < timestamp '2026-01-01 00:00:00'
+            order by id
+        """
+        // This current predicate deliberately crosses files written by year, 
month, day and hour
+        // specs so losing any older transform boundary changes the fixed 
result contract.
+        qt_temporal_current_all_specs """
+            select id from ${temporalTable}
+            where event_time >= timestamp '2024-01-01 00:00:00'
+              and event_time < timestamp '2026-05-02 00:00:00'
+            order by id
+        """
+        qt_temporal_hour_snapshot """
+            select id from ${temporalTable} for version as of ${temporalHour}
+            where event_time >= timestamp '2026-05-01 00:00:00'
+              and event_time < timestamp '2026-05-02 00:00:00'
+            order by id
+        """
+        qt_temporal_hour_tag """
+            select id from ${temporalTable}@tag(temporal_hour)
+            where event_time = timestamp '2026-05-01 09:00:00'
+        """
+    } finally {
+        sql """drop catalog if exists ${catalogName}"""
+    }
+}
diff --git 
a/regression-test/suites/external_table_p0/iceberg/test_iceberg_partition_evolution_format_scanner.groovy
 
b/regression-test/suites/external_table_p0/iceberg/test_iceberg_partition_evolution_format_scanner.groovy
new file mode 100644
index 00000000000..7b492b67f0e
--- /dev/null
+++ 
b/regression-test/suites/external_table_p0/iceberg/test_iceberg_partition_evolution_format_scanner.groovy
@@ -0,0 +1,211 @@
+// 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_iceberg_partition_evolution_format_scanner",
+        "p0,external,iceberg,external_docker,external_docker_iceberg") {
+    String enabled = context.config.otherConfigs.get("enableIcebergTest")
+    if (enabled == null || !enabled.equalsIgnoreCase("true")) {
+        logger.info("disable iceberg test")
+        return
+    }
+
+    String restPort = context.config.otherConfigs.get("iceberg_rest_uri_port")
+    String minioPort = context.config.otherConfigs.get("iceberg_minio_port")
+    String externalEnvIp = context.config.otherConfigs.get("externalEnvIp")
+    String catalogName = "test_iceberg_partition_evolution_format_scanner"
+    String dbName = "iceberg_partition_evolution_format_scanner_db"
+
+    def latestSnapshotId = { String tableName ->
+        return spark_iceberg("""
+            select snapshot_id
+            from demo.${dbName}.${tableName}.snapshots
+            order by committed_at desc
+            limit 1
+        """)[0][0].toString()
+    }
+
+    sql """drop catalog if exists ${catalogName}"""
+    sql """
+        create catalog ${catalogName} properties (
+            'type'='iceberg',
+            'iceberg.catalog.type'='rest',
+            'uri'='http://${externalEnvIp}:${restPort}',
+            's3.access_key'='admin',
+            's3.secret_key'='password',
+            's3.endpoint'='http://${externalEnvIp}:${minioPort}',
+            's3.region'='us-east-1',
+            'meta.cache.iceberg.table.ttl-second'='0',
+            'meta.cache.iceberg.schema.ttl-second'='0'
+        )
+    """
+
+    try {
+        ["parquet", "orc"].each { String format ->
+            String identityTable = "identity_bucket_truncate_${format}"
+            String temporalTable = "temporal_transform_${format}"
+
+            // Scenario PE-X01: run the complete identity + bucket -> truncate 
+ drop timeline
+            // for each data format, including a nested 
add/rename/promotion/drop-readd sequence.
+            spark_iceberg_multi """
+                create database if not exists demo.${dbName};
+                drop table if exists demo.${dbName}.${identityTable};
+                create table demo.${dbName}.${identityTable} (
+                    id int,
+                    category string,
+                    code string,
+                    event_time timestamp,
+                    payload struct<metric:int, label:string>
+                ) using iceberg
+                partitioned by (category, days(event_time))
+                tblproperties (
+                    'format-version'='2',
+                    'write.format.default'='${format}'
+                );
+                insert into demo.${dbName}.${identityTable} values
+                    (1, 'A', 'aa-1', timestamp '2026-01-01 01:00:00',
+                        named_struct('metric', 10, 'label', 'base-a')),
+                    (2, 'B', 'bb-1', timestamp '2026-01-02 01:00:00',
+                        named_struct('metric', 20, 'label', 'base-b'));
+            """
+            String identityBase = latestSnapshotId(identityTable)
+            sql """
+                alter table `${catalogName}`.`${dbName}`.`${identityTable}`
+                create tag ${identityTable}_base as of version ${identityBase}
+            """
+            spark_iceberg_multi """
+                alter table demo.${dbName}.${identityTable} add partition 
field bucket(8, id);
+                alter table demo.${dbName}.${identityTable} add column 
payload.extra string;
+                insert into demo.${dbName}.${identityTable} values
+                    (3, 'A', 'aa-2', timestamp '2026-02-01 01:00:00',
+                        named_struct('metric', 30, 'label', 'bucket-a', 
'extra', 'added')),
+                    (4, 'C', 'cc-1', timestamp '2026-02-02 01:00:00',
+                        named_struct('metric', 40, 'label', 'bucket-c', 
'extra', 'added'));
+                alter table demo.${dbName}.${identityTable}
+                    replace partition field bucket(8, id) with truncate(2, 
code);
+                alter table demo.${dbName}.${identityTable}
+                    rename column payload.label to renamed_label;
+                alter table demo.${dbName}.${identityTable}
+                    alter column payload.metric type bigint;
+                insert into demo.${dbName}.${identityTable} values
+                    (5, 'A', 'aa-3', timestamp '2026-03-01 01:00:00',
+                        named_struct('metric', 5000000000, 'renamed_label', 
'truncate-a',
+                            'extra', 'renamed')),
+                    (6, 'D', 'dd-1', timestamp '2026-03-02 01:00:00',
+                        named_struct('metric', 60, 'renamed_label', 
'truncate-d',
+                            'extra', 'renamed'));
+                alter table demo.${dbName}.${identityTable} drop partition 
field category;
+                alter table demo.${dbName}.${identityTable} drop partition 
field days(event_time);
+                alter table demo.${dbName}.${identityTable} drop column 
payload.extra;
+                alter table demo.${dbName}.${identityTable} add column 
payload.extra bigint;
+                insert into demo.${dbName}.${identityTable} values
+                    (7, 'A', 'aa-4', timestamp '2026-04-01 01:00:00',
+                        named_struct('metric', 70, 'renamed_label', 'drop-a', 
'extra', 7000)),
+                    (8, 'E', 'ee-1', timestamp '2026-04-02 01:00:00',
+                        named_struct('metric', 80, 'renamed_label', 'drop-e', 
'extra', 8000));
+            """
+            String identityFinal = latestSnapshotId(identityTable)
+            sql """
+                alter table `${catalogName}`.`${dbName}`.`${identityTable}`
+                create tag ${identityTable}_final as of version 
${identityFinal}
+            """
+
+            // Scenario PE-X02: run the complete year -> month -> day -> hour 
replacement timeline
+            // in the same format so each transform boundary is observable 
under both scanners.
+            spark_iceberg_multi """
+                drop table if exists demo.${dbName}.${temporalTable};
+                create table demo.${dbName}.${temporalTable} (
+                    id int,
+                    event_time timestamp,
+                    payload string
+                ) using iceberg
+                partitioned by (years(event_time))
+                tblproperties (
+                    'format-version'='2',
+                    'write.format.default'='${format}'
+                );
+                insert into demo.${dbName}.${temporalTable} values
+                    (11, timestamp '2024-01-01 01:00:00', 'year-2024'),
+                    (12, timestamp '2025-01-01 01:00:00', 'year-2025');
+                alter table demo.${dbName}.${temporalTable}
+                    replace partition field years(event_time) with 
months(event_time);
+                insert into demo.${dbName}.${temporalTable} values
+                    (13, timestamp '2026-02-01 01:00:00', 'month-feb'),
+                    (14, timestamp '2026-03-01 01:00:00', 'month-mar');
+                alter table demo.${dbName}.${temporalTable}
+                    replace partition field months(event_time) with 
days(event_time);
+                insert into demo.${dbName}.${temporalTable} values
+                    (15, timestamp '2026-04-03 01:00:00', 'day-03'),
+                    (16, timestamp '2026-04-04 01:00:00', 'day-04');
+                alter table demo.${dbName}.${temporalTable}
+                    replace partition field days(event_time) with 
hours(event_time);
+                insert into demo.${dbName}.${temporalTable} values
+                    (17, timestamp '2026-05-01 08:00:00', 'hour-08'),
+                    (18, timestamp '2026-05-01 09:00:00', 'hour-09');
+            """
+            String temporalFinal = latestSnapshotId(temporalTable)
+            sql """
+                alter table `${catalogName}`.`${dbName}`.`${temporalTable}`
+                create tag ${temporalTable}_final as of version 
${temporalFinal}
+            """
+
+            sql """switch ${catalogName}"""
+            sql """use ${dbName}"""
+            sql """refresh catalog ${catalogName}"""
+
+            [true, false].each { boolean scannerV2 ->
+                String scanner = scannerV2 ? "v2" : "v1"
+                sql """set enable_file_scanner_v2=${scannerV2}"""
+
+                // Scenario PE-X03: every format/scanner cell reads current 
data spanning every
+                // identity/bucket/truncate/drop spec and preserves nested 
field IDs.
+                "qt_identity_${format}_${scanner}_current"("""
+                    select id, payload.extra from ${identityTable}
+                    where category = 'A' or code in ('bb-1', 'cc-1', 'dd-1', 
'ee-1')
+                    order by id
+                """)
+                "qt_identity_${format}_${scanner}_base_tag"("""
+                    select id, category, payload.label
+                    from ${identityTable}@tag(${identityTable}_base)
+                    order by id
+                """)
+                "qt_identity_${format}_${scanner}_final_tag"("""
+                    select id, payload.renamed_label
+                    from ${identityTable}@tag(${identityTable}_final)
+                    where category = 'A'
+                    order by id
+                """)
+
+                // Scenario PE-X04: the current predicate spans 
year/month/day/hour files and the
+                // historical tag is read by every format/scanner combination.
+                "qt_temporal_${format}_${scanner}_current"("""
+                    select id from ${temporalTable}
+                    where event_time >= timestamp '2024-01-01 00:00:00'
+                      and event_time < timestamp '2026-05-02 00:00:00'
+                    order by id
+                """)
+                "qt_temporal_${format}_${scanner}_final_tag"("""
+                    select id from ${temporalTable}@tag(${temporalTable}_final)
+                    where event_time >= timestamp '2026-02-01 00:00:00'
+                    order by id
+                """)
+            }
+        }
+    } finally {
+        sql """set enable_file_scanner_v2=true"""
+        sql """drop catalog if exists ${catalogName}"""
+    }
+}
diff --git 
a/regression-test/suites/external_table_p0/iceberg/test_iceberg_partition_evolution_position_dv.groovy
 
b/regression-test/suites/external_table_p0/iceberg/test_iceberg_partition_evolution_position_dv.groovy
new file mode 100644
index 00000000000..4ed60374d04
--- /dev/null
+++ 
b/regression-test/suites/external_table_p0/iceberg/test_iceberg_partition_evolution_position_dv.groovy
@@ -0,0 +1,318 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+import org.apache.doris.regression.action.ProfileAction
+
+suite("test_iceberg_partition_evolution_position_dv",
+        
"p0,external,iceberg,external_docker,external_docker_iceberg,nonConcurrent") {
+    String enabled = context.config.otherConfigs.get("enableIcebergTest")
+    if (enabled == null || !enabled.equalsIgnoreCase("true")) {
+        logger.info("disable iceberg test")
+        return
+    }
+
+    String restPort = context.config.otherConfigs.get("iceberg_rest_uri_port")
+    String minioPort = context.config.otherConfigs.get("iceberg_minio_port")
+    String externalEnvIp = context.config.otherConfigs.get("externalEnvIp")
+    String catalogName = "test_iceberg_partition_evolution_position_dv"
+    String dbName = "iceberg_partition_evolution_position_dv_db"
+
+    def stringRows = { String query ->
+        sql(query).collect { row ->
+            row.collect { value -> value == null ? null : value.toString() }
+        }
+    }
+    def latestSnapshotId = { String tableName ->
+        return spark_iceberg("""
+            select snapshot_id
+            from demo.${dbName}.${tableName}.snapshots
+            order by committed_at desc
+            limit 1
+        """)[0][0].toString()
+    }
+    def profileAction = new ProfileAction(context)
+    def profileCounterValues = { String profileText, String counterName ->
+        def values = []
+        def matcher = profileText =~ ("(?m)^\\s*(?:-\\s*)?"
+                + java.util.regex.Pattern.quote(counterName) + 
":\\s+([^\\n]+)")
+        while (matcher.find()) {
+            String valueText = matcher.group(1).toString()
+            def exact = valueText =~ /\(([0-9,]+)\)/
+            def number = valueText =~ /([0-9,]+)/
+            String rawValue = exact.find() ? exact.group(1) : (number.find() ? 
number.group(1) : null)
+            if (rawValue != null) {
+                values.add(Long.parseLong(rawValue.replace(",", "")))
+            }
+        }
+        return values
+    }
+    def assertRuntimeFilterPruned = { String tableName, String dimensionTable 
->
+        String token = UUID.randomUUID().toString()
+        List<List<String>> rows = stringRows("""
+            select /*+ SET_VAR(runtime_filter_type='IN_OR_BLOOM_FILTER') */
+                '${token}', f.id
+            from ${tableName} f
+            join ${dimensionTable} d on f.category = d.category
+            order by f.id
+        """)
+        String profile = profileAction.getProfileBySql(
+                token,
+                ["RuntimeFilterPartitionPrunedRangeNum"],
+                30000L,
+                500L)
+        long fileRangesPruned = profileCounterValues(
+                profile, "RuntimeFilterPartitionPrunedRangeNum").sum(0L)
+        long partitionsPruned = profileCounterValues(
+                profile, "PartitionsPrunedByRuntimeFilter").sum(0L)
+        assertTrue(fileRangesPruned + partitionsPruned > 0L,
+                "Runtime filter did not prune a delete-aware Iceberg 
partition/file range; "
+                        + profile.take(2000).replaceAll("\\s+", " "))
+        return rows.collect { row -> [row[1]] }
+    }
+
+    sql """drop catalog if exists ${catalogName}"""
+    sql """
+        create catalog ${catalogName} properties (
+            'type'='iceberg',
+            'iceberg.catalog.type'='rest',
+            'uri'='http://${externalEnvIp}:${restPort}',
+            's3.access_key'='admin',
+            's3.secret_key'='password',
+            's3.endpoint'='http://${externalEnvIp}:${minioPort}',
+            's3.region'='us-east-1',
+            'meta.cache.iceberg.table.ttl-second'='0'
+        )
+    """
+
+    try {
+        ["parquet", "orc"].each { String format ->
+            [2, 3].each { int formatVersion ->
+                String deleteKind = formatVersion == 2 ? "position" : "dv"
+                String tableName = "${deleteKind}_${format}_evolved"
+
+                spark_iceberg_multi """
+                    create database if not exists demo.${dbName};
+                    drop table if exists demo.${dbName}.${tableName};
+                    create table demo.${dbName}.${tableName} (
+                        id int,
+                        category string,
+                        code string,
+                        event_time timestamp,
+                        payload struct<metric:int, label:string>
+                    ) using iceberg
+                    partitioned by (category, days(event_time))
+                    tblproperties (
+                        'format-version'='${formatVersion}',
+                        'write.format.default'='${format}',
+                        'write.delete.mode'='merge-on-read',
+                        'write.update.mode'='merge-on-read',
+                        'write.merge.mode'='merge-on-read',
+                        'write.distribution-mode'='none'
+                    );
+                    insert into demo.${dbName}.${tableName}
+                    select /*+ coalesce(1) */ id, category, code, event_time, 
payload from values
+                        (1, 'A', 'aa-1', timestamp '2026-01-01 01:00:00',
+                            named_struct('metric', 10, 'label', 'base-a')),
+                        (2, 'A', 'aa-2', timestamp '2026-01-01 02:00:00',
+                            named_struct('metric', 20, 'label', 
'base-delete')),
+                        (3, 'B', 'bb-1', timestamp '2026-01-02 01:00:00',
+                            named_struct('metric', 30, 'label', 'base-b')),
+                        (4, 'C', 'cc-1', timestamp '2026-01-03 01:00:00',
+                            named_struct('metric', 40, 'label', 'base-c'))
+                        as t(id, category, code, event_time, payload);
+                """
+                String baseSnapshot = latestSnapshotId(tableName)
+                sql """
+                    alter table `${catalogName}`.`${dbName}`.`${tableName}`
+                    create tag ${tableName}_base as of version ${baseSnapshot}
+                """
+
+                // Scenario PE-D01: delete against the original spec before 
any evolution.
+                spark_iceberg """
+                    delete from demo.${dbName}.${tableName} where id = 2
+                """
+                String firstDeleteSnapshot = latestSnapshotId(tableName)
+
+                // Scenario PE-D02: ADD partition field and complex child, 
then delete a row written
+                // with the new spec. Old/new delete files must remain 
associated with their specs.
+                spark_iceberg_multi """
+                    alter table demo.${dbName}.${tableName} add partition 
field bucket(8, id);
+                    alter table demo.${dbName}.${tableName} add column 
payload.extra string;
+                    insert into demo.${dbName}.${tableName}
+                    select /*+ coalesce(1) */ id, category, code, event_time, 
payload from values
+                        (5, 'A', 'aa-3', timestamp '2026-02-01 01:00:00',
+                            named_struct('metric', 50, 'label', 'add-a', 
'extra', 'new-child')),
+                        (6, 'A', 'aa-4', timestamp '2026-02-01 02:00:00',
+                            named_struct('metric', 60, 'label', 'add-delete', 
'extra', 'new-child'))
+                        as t(id, category, code, event_time, payload);
+                    delete from demo.${dbName}.${tableName} where id = 6;
+                """
+                String addedDeleteSnapshot = latestSnapshotId(tableName)
+
+                // Scenario PE-D03: REPLACE temporal transform and 
rename/promote nested fields.
+                spark_iceberg_multi """
+                    alter table demo.${dbName}.${tableName}
+                        replace partition field days(event_time) with 
months(event_time);
+                    alter table demo.${dbName}.${tableName}
+                        replace partition field bucket(8, id) with truncate(2, 
code);
+                    alter table demo.${dbName}.${tableName}
+                        rename column payload.label to renamed_label;
+                    alter table demo.${dbName}.${tableName}
+                        alter column payload.metric type bigint;
+                    insert into demo.${dbName}.${tableName} values
+                        (7, 'A', 'aa-5', timestamp '2026-03-01 01:00:00',
+                            named_struct('metric', 7000000000,
+                                'renamed_label', 'replace-a', 'extra', 
'renamed-child')),
+                        (8, 'A', 'aa-6', timestamp '2026-03-01 02:00:00',
+                            named_struct('metric', 80,
+                                'renamed_label', 'replace-delete', 'extra', 
'renamed-child'));
+                """
+
+                // Scenario PE-D04: DROP identity field, write a victim with 
the resulting spec,
+                // then delete that victim. The data and delete artifacts must 
both use the spec
+                // that lacks category while older files still expose category 
partitions.
+                spark_iceberg_multi """
+                    alter table demo.${dbName}.${tableName} drop partition 
field category;
+                    delete from demo.${dbName}.${tableName} where id = 8;
+                    insert into demo.${dbName}.${tableName}
+                    select /*+ coalesce(1) */ id, category, code, event_time, 
payload from values
+                        (9, 'A', 'aa-7', timestamp '2026-04-01 01:00:00',
+                            named_struct('metric', 90,
+                                'renamed_label', 'drop-delete', 'extra', 
'new-spec')),
+                        (10, 'A', 'aa-8', timestamp '2026-04-01 02:00:00',
+                            named_struct('metric', 100,
+                                'renamed_label', 'drop-survivor', 'extra', 
'new-spec'))
+                        as t(id, category, code, event_time, payload);
+                    delete from demo.${dbName}.${tableName} where id = 9;
+                    drop table if exists demo.${dbName}.${tableName}_dimension;
+                    create table demo.${dbName}.${tableName}_dimension 
(category string)
+                        using iceberg tblproperties ('format-version'='2');
+                    insert into demo.${dbName}.${tableName}_dimension values 
('A');
+                """
+                List<List<Object>> dataSpecRows = spark_iceberg """
+                    select max(spec_id)
+                    from demo.${dbName}.${tableName}.all_files
+                    where content = 0
+                """
+                List<List<Object>> deleteSpecRows = spark_iceberg """
+                    select max(spec_id)
+                    from demo.${dbName}.${tableName}.all_files
+                    where content = 1
+                """
+                assertEquals(dataSpecRows[0][0], deleteSpecRows[0][0],
+                        "The post-drop victim data and delete artifacts must 
use the same spec")
+                String finalSnapshot = latestSnapshotId(tableName)
+                sql """
+                    alter table `${catalogName}`.`${dbName}`.`${tableName}`
+                    create tag ${tableName}_final as of version 
${finalSnapshot}
+                """
+
+                sql """switch ${catalogName}"""
+                sql """use ${dbName}"""
+                sql """refresh table ${tableName}"""
+                String actionSuffix = "${deleteKind}_${format}"
+
+                // Scenario PE-D05: static partition filters apply every 
delete exactly once.
+                List<List<String>> expectedCurrent = [["1"], ["5"], ["7"], 
["10"]]
+                "qt_${actionSuffix}_current_filter"("""
+                    select id from ${tableName} where category = 'A' order by 
id
+                """)
+                "qt_${actionSuffix}_current_nested"("""
+                    select id, payload.metric from ${tableName}
+                    where payload.metric > 5000000000 order by id
+                """)
+
+                // Scenario PE-D06: runtime filter on the dropped identity 
partition column returns
+                // the same delete-aware rows with pruning disabled, then 
proves that the enabled
+                // query retains an RF and prunes at least one partition/file 
range.
+                String rfQuery = """
+                    select /*+ 
SET_VAR(runtime_filter_type='IN_OR_BLOOM_FILTER') */ f.id
+                    from ${tableName} f
+                    join ${tableName}_dimension d on f.category = d.category
+                    order by f.id
+                """
+                sql """set runtime_filter_wait_infinitely=true"""
+                sql """set disable_join_reorder=true"""
+                sql """set enable_runtime_filter_prune=false"""
+                sql """set runtime_filter_mode=GLOBAL"""
+                sql """set parallel_pipeline_task_num=1"""
+                sql """set enable_profile=true"""
+                sql """set profile_level=2"""
+                sql """set enable_runtime_filter_partition_prune=false"""
+                "qt_${actionSuffix}_rf_disabled"(rfQuery)
+                sql """set enable_runtime_filter_partition_prune=true"""
+                assertEquals(expectedCurrent,
+                        assertRuntimeFilterPruned(tableName, 
"${tableName}_dimension"))
+
+                // Scenario PE-D07: numeric snapshots and tags preserve 
historical rows and deletes.
+                "qt_${actionSuffix}_base_snapshot"("""
+                    select id from ${tableName} for version as of 
${baseSnapshot}
+                    where category in ('A', 'B', 'C') order by id
+                """)
+                "qt_${actionSuffix}_base_tag"("""
+                    select id from ${tableName}@tag(${tableName}_base)
+                    where category = 'A' order by id
+                """)
+                "qt_${actionSuffix}_first_delete_snapshot"("""
+                    select id from ${tableName} for version as of 
${firstDeleteSnapshot}
+                    where category = 'A' order by id
+                """)
+                "qt_${actionSuffix}_added_delete_snapshot"("""
+                    select id from ${tableName} for version as of 
${addedDeleteSnapshot}
+                    where category = 'A' order by id
+                """)
+                "qt_${actionSuffix}_final_tag"("""
+                    select id from ${tableName}@tag(${tableName}_final)
+                    where category = 'A' order by id
+                """)
+
+                // Scenario PE-D08: legacy and V2 scanners must agree on 
multi-spec delete planning.
+                sql """set enable_file_scanner_v2=true"""
+                "qt_${actionSuffix}_scanner_v2"("""
+                    select id from ${tableName} where category = 'A' order by 
id
+                """)
+                sql """set enable_file_scanner_v2=false"""
+                "qt_${actionSuffix}_scanner_v1"("""
+                    select id from ${tableName} where category = 'A' order by 
id
+                """)
+                sql """set enable_file_scanner_v2=true"""
+
+                // Scenario PE-D09: verify the fixture really contains 
physical delete artifacts.
+                // Iceberg may replace or consolidate older artifacts across 
delete commits, while
+                // the snapshot assertions above still validate every 
pre/post-evolution phase.
+                List<List<String>> deleteFiles = stringRows("""
+                    select file_format
+                    from ${tableName}\$all_files
+                    where content = 1
+                    order by file_format
+                """)
+                assertTrue(deleteFiles.size() >= 1,
+                        "${tableName} must contain at least one physical 
delete artifact")
+                if (formatVersion == 3) {
+                    assertTrue(deleteFiles.any { row -> 
row[0].equalsIgnoreCase("PUFFIN") },
+                            "${tableName} must contain Iceberg deletion 
vectors")
+                }
+            }
+        }
+    } finally {
+        sql """set enable_file_scanner_v2=true"""
+        sql """set enable_runtime_filter_prune=true"""
+        sql """set enable_runtime_filter_partition_prune=true"""
+        sql """set disable_join_reorder=false"""
+        sql """drop catalog if exists ${catalogName}"""
+    }
+}
diff --git 
a/regression-test/suites/external_table_p0/iceberg/test_iceberg_partition_evolution_runtime_filter.groovy
 
b/regression-test/suites/external_table_p0/iceberg/test_iceberg_partition_evolution_runtime_filter.groovy
new file mode 100644
index 00000000000..729ba0418b5
--- /dev/null
+++ 
b/regression-test/suites/external_table_p0/iceberg/test_iceberg_partition_evolution_runtime_filter.groovy
@@ -0,0 +1,277 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+import org.apache.doris.regression.action.ProfileAction
+
+suite("test_iceberg_partition_evolution_runtime_filter",
+        
"p0,external,iceberg,external_docker,external_docker_iceberg,nonConcurrent") {
+    String enabled = context.config.otherConfigs.get("enableIcebergTest")
+    if (enabled == null || !enabled.equalsIgnoreCase("true")) {
+        logger.info("disable iceberg test")
+        return
+    }
+
+    String restPort = context.config.otherConfigs.get("iceberg_rest_uri_port")
+    String minioPort = context.config.otherConfigs.get("iceberg_minio_port")
+    String externalEnvIp = context.config.otherConfigs.get("externalEnvIp")
+    String catalogName = "test_iceberg_partition_evolution_runtime_filter"
+    String dbName = "iceberg_partition_evolution_runtime_filter_db"
+    String factTable = "evolved_fact"
+    String dimensionTable = "rf_dimension"
+
+    def stringRows = { String query ->
+        sql(query).collect { row ->
+            row.collect { value -> value == null ? null : value.toString() }
+        }
+    }
+    def latestSnapshotId = {
+        return spark_iceberg("""
+            select snapshot_id
+            from demo.${dbName}.${factTable}.snapshots
+            order by committed_at desc
+            limit 1
+        """)[0][0].toString()
+    }
+    def profileAction = new ProfileAction(context)
+    def profileCounterValues = { String profileText, String counterName ->
+        def values = []
+        def matcher = profileText =~ ("(?m)^\\s*(?:-\\s*)?"
+                + java.util.regex.Pattern.quote(counterName) + 
":\\s+([^\\n]+)")
+        while (matcher.find()) {
+            String valueText = matcher.group(1).toString()
+            def exact = valueText =~ /\(([0-9,]+)\)/
+            def number = valueText =~ /([0-9,]+)/
+            String rawValue = exact.find() ? exact.group(1) : (number.find() ? 
number.group(1) : null)
+            if (rawValue != null) {
+                values.add(Long.parseLong(rawValue.replace(",", "")))
+            }
+        }
+        return values
+    }
+    def assertRuntimeFilterPruned = { String queryBody ->
+        String token = UUID.randomUUID().toString()
+        List<List<String>> rows = stringRows("""
+            select /*+ SET_VAR(runtime_filter_type='IN_OR_BLOOM_FILTER') */
+                '${token}', f.id
+            ${queryBody}
+            order by f.id
+        """)
+        // Scanner counters can arrive after the profile list first reports 
COMPLETE.
+        String profile = profileAction.getProfileBySql(
+                token,
+                ["RuntimeFilterPartitionPrunedRangeNum"],
+                30000L,
+                500L)
+        long fileRangesPruned = profileCounterValues(
+                profile, "RuntimeFilterPartitionPrunedRangeNum").sum(0L)
+        long partitionsPruned = profileCounterValues(
+                profile, "PartitionsPrunedByRuntimeFilter").sum(0L)
+        assertTrue(fileRangesPruned + partitionsPruned > 0L,
+                "Runtime filter did not prune any evolved Iceberg 
partition/file range; "
+                        + profile.take(2000).replaceAll("\\s+", " "))
+        return rows.collect { row -> [row[1]] }
+    }
+
+    sql """drop catalog if exists ${catalogName}"""
+    sql """
+        create catalog ${catalogName} properties (
+            'type'='iceberg',
+            'iceberg.catalog.type'='rest',
+            'uri'='http://${externalEnvIp}:${restPort}',
+            's3.access_key'='admin',
+            's3.secret_key'='password',
+            's3.endpoint'='http://${externalEnvIp}:${minioPort}',
+            's3.region'='us-east-1',
+            'meta.cache.iceberg.table.ttl-second'='0'
+        )
+    """
+
+    try {
+        spark_iceberg_multi """
+            create database if not exists demo.${dbName};
+            drop table if exists demo.${dbName}.${factTable};
+            create table demo.${dbName}.${factTable} (
+                id int,
+                category string,
+                region string,
+                event_time timestamp,
+                payload struct<metric:int>
+            ) using iceberg
+            partitioned by (category, days(event_time))
+            tblproperties ('format-version'='2');
+            insert into demo.${dbName}.${factTable} values
+                (1, 'A', 'r1', timestamp '2026-01-01 01:00:00',
+                    named_struct('metric', 10)),
+                (2, 'B', 'r2', timestamp '2026-01-02 01:00:00',
+                    named_struct('metric', 20)),
+                (3, 'C', 'r3', timestamp '2026-01-03 01:00:00',
+                    named_struct('metric', 30));
+        """
+        String baseSnapshot = latestSnapshotId()
+        sql """
+            alter table `${catalogName}`.`${dbName}`.`${factTable}`
+            create tag rf_base as of version ${baseSnapshot}
+        """
+
+        // Scenario PE-RF01: add identity and bucket fields and evolve a 
nested payload between
+        // data files. Old files lack both partition values but still contain 
the source columns.
+        spark_iceberg_multi """
+            alter table demo.${dbName}.${factTable} add partition field region;
+            alter table demo.${dbName}.${factTable} add partition field 
bucket(8, id);
+            alter table demo.${dbName}.${factTable} add column payload.label 
string;
+            insert into demo.${dbName}.${factTable} values
+                (4, 'A', 'r1', timestamp '2026-02-01 01:00:00',
+                    named_struct('metric', 40, 'label', 'add-spec')),
+                (5, 'D', 'r4', timestamp '2026-02-02 01:00:00',
+                    named_struct('metric', 50, 'label', 'add-spec'));
+        """
+        String addedSnapshot = latestSnapshotId()
+
+        // Scenario PE-RF02: replace a temporal transform. Runtime filters on 
event_time must
+        // translate against the transform of each file's own spec.
+        spark_iceberg_multi """
+            alter table demo.${dbName}.${factTable}
+                replace partition field days(event_time) with 
months(event_time);
+            insert into demo.${dbName}.${factTable} values
+                (6, 'A', 'r1', timestamp '2026-03-01 01:00:00',
+                    named_struct('metric', 60, 'label', 'replace-spec')),
+                (7, 'E', 'r5', timestamp '2026-03-02 01:00:00',
+                    named_struct('metric', 70, 'label', 'replace-spec'));
+        """
+
+        // Scenario PE-RF03: drop the identity field. New files have no 
category partition value;
+        // runtime pruning may only discard older ranges and must still scan 
matching new rows.
+        spark_iceberg_multi """
+            alter table demo.${dbName}.${factTable} drop partition field 
category;
+            insert into demo.${dbName}.${factTable} values
+                (8, 'A', 'r1', timestamp '2026-04-01 01:00:00',
+                    named_struct('metric', 80, 'label', 'drop-spec')),
+                (9, 'F', 'r6', timestamp '2026-04-02 01:00:00',
+                    named_struct('metric', 90, 'label', 'drop-spec'));
+            drop table if exists demo.${dbName}.${dimensionTable};
+            create table demo.${dbName}.${dimensionTable} (
+                category string,
+                region string,
+                id int,
+                lower_time timestamp,
+                upper_time timestamp
+            ) using iceberg
+            tblproperties ('format-version'='2');
+            insert into demo.${dbName}.${dimensionTable} values
+                ('A', 'r1', 4,
+                    timestamp '2026-03-01 00:00:00', timestamp '2026-05-01 
00:00:00');
+        """
+        String droppedSnapshot = latestSnapshotId()
+        sql """
+            alter table `${catalogName}`.`${dbName}`.`${factTable}`
+            create tag rf_dropped as of version ${droppedSnapshot}
+        """
+
+        sql """switch ${catalogName}"""
+        sql """use ${dbName}"""
+        sql """refresh catalog ${catalogName}"""
+        sql """set enable_profile=true"""
+        sql """set profile_level=2"""
+        // Small fixture tables have no column statistics. Keep the generated 
RF so this suite
+        // validates scanner-side partition pruning instead of optimizer 
selectivity heuristics.
+        sql """set enable_runtime_filter_prune=false"""
+        sql """set runtime_filter_wait_infinitely=true"""
+        sql """set runtime_filter_mode=GLOBAL"""
+        sql """set parallel_pipeline_task_num=1"""
+        sql """set disable_join_reorder=true"""
+
+        String currentCategoryJoin = """
+            from ${factTable} f
+            join ${dimensionTable} d on f.category = d.category
+        """
+        String addedIdentityJoin = """
+            from ${factTable} f
+            join ${dimensionTable} d on f.region = d.region
+        """
+        String bucketSourceJoin = """
+            from ${factTable} f
+            join ${dimensionTable} d on f.id = d.id
+        """
+        String currentTemporalJoin = """
+            from ${factTable} f
+            join ${dimensionTable} d
+              on f.event_time >= d.lower_time and f.event_time < d.upper_time
+        """
+
+        // Scenario PE-RF04: result parity with RF disabled protects 
correctness.
+        sql """set enable_runtime_filter_partition_prune=false"""
+        qt_category_rf_disabled """
+            select f.id ${currentCategoryJoin} order by f.id
+        """
+        qt_added_identity_rf_disabled """
+            select f.id ${addedIdentityJoin} order by f.id
+        """
+        qt_bucket_source_rf_disabled """
+            select f.id ${bucketSourceJoin} order by f.id
+        """
+        qt_temporal_rf_disabled """
+            select f.id ${currentTemporalJoin} order by f.id
+        """
+
+        // Scenario PE-RF05: independently profile the original identity field 
that is later
+        // dropped and the identity field added after old files were written.
+        sql """set enable_runtime_filter_partition_prune=true"""
+        assertEquals([["1"], ["4"], ["6"], ["8"]],
+                assertRuntimeFilterPruned(currentCategoryJoin))
+        assertEquals([["1"], ["4"], ["6"], ["8"]],
+                assertRuntimeFilterPruned(addedIdentityJoin))
+
+        // Scenario PE-RF06: bucket and temporal source-column RFs retain 
result correctness.
+        // Scanner-side RF pruning currently consumes identity partition 
values only, so these
+        // cells intentionally do not claim a positive physical-pruning 
counter.
+        qt_bucket_source_rf_enabled """
+            select /*+ SET_VAR(runtime_filter_type='IN_OR_BLOOM_FILTER') */ 
f.id
+            ${bucketSourceJoin}
+            order by f.id
+        """
+        qt_temporal_rf_enabled """
+            select /*+ SET_VAR(runtime_filter_type='IN_OR_BLOOM_FILTER') */ 
f.id
+            ${currentTemporalJoin}
+            order by f.id
+        """
+
+        // Scenario PE-RF07: numeric snapshot and tag retain runtime-filter 
correctness.
+        qt_base_snapshot_rf """
+            select /*+ SET_VAR(runtime_filter_type='IN_OR_BLOOM_FILTER') */ 
f.id
+            from ${factTable} for version as of ${baseSnapshot} f
+            join ${dimensionTable} d on f.category = d.category
+            order by f.id
+        """
+        qt_added_snapshot_rf """
+            select /*+ SET_VAR(runtime_filter_type='IN_OR_BLOOM_FILTER') */ 
f.id
+            from ${factTable} for version as of ${addedSnapshot} f
+            join ${dimensionTable} d on f.region = d.region
+            order by f.id
+        """
+        qt_dropped_tag_rf """
+            select /*+ SET_VAR(runtime_filter_type='IN_OR_BLOOM_FILTER') */ 
f.id
+            from ${factTable}@tag(rf_dropped) f
+            join ${dimensionTable} d on f.category = d.category
+            order by f.id
+        """
+    } finally {
+        sql """set enable_runtime_filter_prune=true"""
+        sql """set enable_runtime_filter_partition_prune=true"""
+        sql """set disable_join_reorder=false"""
+        sql """drop catalog if exists ${catalogName}"""
+    }
+}
diff --git 
a/regression-test/suites/external_table_p0/iceberg_paimon_schema_time_travel_coverage.md
 
b/regression-test/suites/external_table_p0/iceberg_paimon_schema_time_travel_coverage.md
index 8bc1e9520b0..31a1cfd1ea2 100644
--- 
a/regression-test/suites/external_table_p0/iceberg_paimon_schema_time_travel_coverage.md
+++ 
b/regression-test/suites/external_table_p0/iceberg_paimon_schema_time_travel_coverage.md
@@ -11,6 +11,49 @@ The tests use explicit old/new field projections and 
negative bindings in
 addition to row-shape assertions. This prevents a rename with unchanged values
 from passing accidentally.
 
+The partition-evolution extension also distinguishes result correctness from
+physical pruning. Static predicates are validated across files written by
+different partition specs. Runtime filters on identity partition fields require
+equal results with pruning disabled/enabled and a positive pruning counter;
+bucket-source and temporal-transform joins assert result parity because those
+transform paths are not physically runtime-filter-prunable.
+
+## Partition evolution matrix
+
+### Iceberg
+
+| Partition operation / transform | Static filter | Runtime filter | 
Snapshot/tag/branch | Position delete | Equality delete | Deletion vector | 
Complex schema in same timeline | Format / reader |
+| --- | --- | --- | --- | --- | --- | --- | --- | --- |
+| Add identity field | Covered | Covered | Covered | Covered | Covered | 
Covered | Covered | Parquet/ORC, V1/V2 |
+| Drop identity field | Covered | Covered | Covered | Covered | Covered | 
Covered | Covered | Parquet/ORC, V1/V2 |
+| Add/drop bucket | Covered | Result contract; source transform is not 
RF-prunable | Covered | Covered | Covered | Covered | Covered | Parquet/ORC, 
V1/V2 |
+| Replace bucket with truncate | Covered | Result contract; transform is not 
RF-prunable | Covered | Covered by delete timeline | Covered by delete timeline 
| Covered by delete timeline | Covered | Parquet/ORC, V1/V2 |
+| Year → month → day → hour | Covered by one current predicate spanning all 
four specs | Result contract; temporal transforms are not RF-prunable | Covered 
| Day → month covered | Day → month covered | Day → month covered | Covered | 
Parquet/ORC, V1/V2 |
+| Old spec lacks newly added field | Covered | Covered | Covered | Covered | 
Covered | Covered | Covered | Parquet/ORC, V1/V2 |
+| New spec lacks dropped field | Covered | Covered | Covered | Covered | 
Covered | Covered | Covered | Parquet/ORC, V1/V2 |
+| Partition-field alias replacement | Covered by existing position-delete 
suite | Not RF-prunable separately | Existing metadata coverage | Covered | N/A 
| N/A | N/A | Parquet |
+
+The Iceberg delete timelines write deletes both before and after spec changes.
+Queries then combine partition predicates, runtime filters and historical
+references so deleted rows cannot be resurrected or over-deleted when Doris
+plans files from several specs.
+
+### Paimon
+
+Paimon does not support changing the partition-key set after table creation.
+Its P0 contract therefore combines supported schema reordering and payload
+evolution with fixed partition keys, and verifies that unsupported key
+mutations fail atomically.
+
+| Paimon partition contract | Static filter | Runtime filter | 
Snapshot/tag/branch | Delete/upsert/DV | Complex schema | Format / reader |
+| --- | --- | --- | --- | --- | --- | --- |
+| Reorder partition columns in schema | Covered | N/A | Covered by pre-reorder 
tag | N/A | Add payload field | Parquet |
+| Rename partition key | Rejected atomically | N/A | Historical tag unchanged 
| Data unchanged | Schema count unchanged | Parquet |
+| Change partition-key type | Rejected atomically | N/A | Historical tag 
unchanged | Data unchanged | Schema count unchanged | Parquet |
+| Drop partition key | Rejected atomically | N/A | Historical tag unchanged | 
Data unchanged | Schema count unchanged | Parquet |
+| Fixed key + STRUCT/MAP/ARRAY evolution | Covered | Covered with positive 
pruning counter | Covered | Append timeline | Current and historical nested 
projections plus wrong-schema negatives | Parquet/ORC; JNI and native split 
paths asserted |
+| Partitioned PK table | Covered | Covered with positive pruning counter | 
Covered | Upsert/delete/compaction plus physical DV artifact | Current and 
historical evolved fields, including drop/re-add NULL isolation | Parquet/ORC; 
JNI and native split paths asserted |
+
 ## Schema operations
 
 | ID | Operation | Iceberg | Paimon | P0 contract |
@@ -62,7 +105,7 @@ from passing accidentally.
 | Equality delete | Rename, promotion, drop/re-add, old/new snapshots | N/A |
 | Deletion vector | v3, Parquet and ORC, before/after evolution | PK-table DV 
path |
 | Row operations | Delete visibility around every checkpoint | Upsert, delete 
and compaction |
-| Readers | File scanner V1/V2 | JNI/native/CPP-supported paths |
+| Readers | Complete identity/bucket/truncate/drop and year/month/day/hour 
timelines under file scanner V1/V2, for both Parquet and ORC | JNI and native 
paths are proved by split statistics for current and historical projections |
 | Cache | REST cache on/off | Filesystem metadata cache on/off |
 | Catalog smoke | REST full matrix and JDBC rename/time-travel | Filesystem 
full matrix and JDBC rename/time-travel |
 | Cluster topology | External endpoints are cluster-reachable; JDBC drivers 
are installed on every FE/BE | External endpoints are cluster-reachable; JDBC 
drivers are installed on every FE/BE |
@@ -82,10 +125,18 @@ explicit rename plus old snapshot/tag smoke path.
 | `iceberg/test_iceberg_schema_position_dv_time_travel.groovy` | Position 
delete and DV × top-level/nested evolution, Parquet/ORC |
 | `iceberg/test_iceberg_schema_ref_actions_matrix.groovy` | Rollback, 
cherry-pick, fast-forward and branch action semantics |
 | `iceberg/test_iceberg_schema_metadata_atomicity_matrix.groovy` | 
Comment/default/nullability/narrowing/map-key atomicity |
+| `iceberg/test_iceberg_partition_evolution_filter_refs.groovy` | 
Add/drop/replace specs, all transform families, static filters and 
snapshot/tag/branch |
+| `iceberg/test_iceberg_partition_evolution_runtime_filter.groovy` | 
Runtime-filter pruning across specs with missing/new partition fields |
+| `iceberg/test_iceberg_partition_evolution_position_dv.groovy` | Position 
delete and deletion vector before/after spec changes, filters and refs |
+| `iceberg/test_iceberg_partition_evolution_equality_delete.groovy` | Real 
equality-delete files before/after add/replace/drop specs |
+| `iceberg/test_iceberg_partition_evolution_format_scanner.groovy` | Full 
partition-transform timeline for Parquet/ORC under scanner V1/V2, current and 
tagged refs |
 | `paimon/test_paimon_schema_time_travel_matrix.groovy` | S01-S18 × T00-T14, 
PK upsert/delete/DV, cache/readers |
 | `paimon/test_paimon_schema_dual_relation_matrix.groovy` | Dual-snapshot 
join/UNION/CTE/subquery negative contracts |
 | `paimon/test_paimon_schema_branch_partition_matrix.groovy` | Independent 
branch evolution, fast-forward and partition restrictions |
 | `paimon/test_paimon_schema_metadata_atomicity_matrix.groovy` | 
Comment/default/nullability/narrowing atomicity |
+| `paimon/test_paimon_partition_schema_filter_refs.groovy` | Fixed partition 
key with complex evolution, pruning and historical refs |
+| `paimon/test_paimon_partition_pk_delete_refs.groovy` | Partitioned PK 
upsert/delete/DV/compaction with filters and refs |
+| `paimon/test_paimon_partition_mutation_atomicity.groovy` | Supported reorder 
plus atomic rejection of key rename/type/drop |
 | `iceberg/test_iceberg_jdbc_catalog.groovy` | JDBC catalog rename × numeric 
snapshot/tag smoke |
 | `paimon/test_paimon_jdbc_catalog.groovy` | JDBC catalog rename × numeric 
snapshot/tag smoke |
 
@@ -105,15 +156,25 @@ back to the exact suite, scenario and file location from 
Jira instead.
 
 ## Validation status
 
-- The ten REST/filesystem matrix suites pass with no failed, fatal or skipped
+- All eight partition-evolution extension suites pass against master
+  `f1460f89230441bc1b6b1872d66bd32a526b25b1`: no failed, fatal or skipped
   suite.
-- The two JDBC catalog suites pass with no failed, fatal or skipped suite.
-- The validation covers current and historical schema binding, nested
-  evolution, deletes, branches, tags, dual historical relations, metadata
-  atomicity, reader/cache variants, catalog variants and distributed cluster
-  scheduling.
-
-The requested schema-change × historical-operation correctness matrix has no
-unimplemented P0 cell. Unsupported format operations and currently incorrect
-Doris behavior are represented by stable negative regression contracts rather
-than being marked as missing.
+- The matrix contains twenty P0 suites: ten original REST/filesystem suites,
+  two JDBC catalog suites and eight partition-evolution extension suites.
+- Independent REST/filesystem suites support parallel execution. The four
+  profile-dependent partition extensions are marked `nonConcurrent` so profile
+  lookup and session-counter assertions cannot consume another suite's query;
+  the two existing JDBC catalog suites are validated separately because they
+  initialize shared external catalog fixtures.
+- Distributed validation used one FE and two live BEs. The cases use
+  cluster-reachable external endpoints and contain no fixed backend,
+  backend-local path or single-node scheduling assumption.
+- No additional Doris product issue was reproduced by the partition-evolution
+  extension. Test-fixture and Runtime Filter session preconditions found during
+  development were corrected before the final run and were not recorded as
+  product defects.
+
+The requested schema-change × partition-operation × historical-reference
+matrix has no unimplemented P0 cell. Unsupported format operations and
+currently incorrect Doris behavior are represented by stable negative
+regression contracts rather than being marked as missing.
diff --git 
a/regression-test/suites/external_table_p0/paimon/test_paimon_partition_mutation_atomicity.groovy
 
b/regression-test/suites/external_table_p0/paimon/test_paimon_partition_mutation_atomicity.groovy
new file mode 100644
index 00000000000..e86c5af0e9d
--- /dev/null
+++ 
b/regression-test/suites/external_table_p0/paimon/test_paimon_partition_mutation_atomicity.groovy
@@ -0,0 +1,148 @@
+// 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_paimon_partition_mutation_atomicity",
+        "p0,external,paimon,external_docker,external_docker_paimon") {
+    String enabled = context.config.otherConfigs.get("enablePaimonTest")
+    if (enabled == null || !enabled.equalsIgnoreCase("true")) {
+        logger.info("disable paimon test")
+        return
+    }
+
+    String minioPort = context.config.otherConfigs.get("iceberg_minio_port")
+    String externalEnvIp = context.config.otherConfigs.get("externalEnvIp")
+    String catalogName = "test_paimon_partition_mutation_atomicity"
+    String dbName = "paimon_partition_mutation_atomicity_db"
+    String tableName = "partition_contract"
+
+    def schemaCount = {
+        return spark_paimon("""
+            select count(*) from paimon.${dbName}.`${tableName}\$schemas`
+        """)[0][0].toString().toInteger()
+    }
+    def assertSparkRejected = { String statement, String operation, String 
expectedMessage ->
+        String error = null
+        try {
+            spark_paimon(statement)
+        } catch (Exception e) {
+            error = e.getMessage()
+        }
+        assertNotNull(error, "Paimon must reject partition-key ${operation}")
+        assertTrue(error.contains(expectedMessage),
+                "Partition-key ${operation} should report 
'${expectedMessage}': ${error}")
+    }
+
+    sql """drop catalog if exists ${catalogName}"""
+    sql """
+        create catalog ${catalogName} properties (
+            'type'='paimon',
+            'warehouse'='s3://warehouse/wh',
+            's3.endpoint'='http://${externalEnvIp}:${minioPort}',
+            's3.access_key'='admin',
+            's3.secret_key'='password',
+            's3.path.style.access'='true',
+            'meta.cache.paimon.table.ttl-second'='0'
+        )
+    """
+
+    try {
+        spark_paimon_multi """
+            create database if not exists paimon.${dbName};
+            drop table if exists paimon.${dbName}.${tableName};
+            create table paimon.${dbName}.${tableName} (
+                id int,
+                part_a string,
+                payload string,
+                part_b int
+            ) using paimon
+            partitioned by (part_a, part_b)
+            tblproperties ('file.format'='parquet');
+            insert into paimon.${dbName}.${tableName} values
+                (1, 'A', 'base-a1', 1),
+                (2, 'A', 'base-a2', 2),
+                (3, 'B', 'base-b1', 1);
+            call paimon.sys.create_tag(
+                table => '${dbName}.${tableName}',
+                tag => 'partition_contract_base'
+            );
+        """
+
+        // Scenario PM-M01-positive: partition columns may move in schema 
order without changing
+        // their partition identity, values, tag or filter semantics.
+        spark_paimon_multi """
+            alter table paimon.${dbName}.${tableName} alter column part_b 
first;
+            alter table paimon.${dbName}.${tableName} alter column part_a 
after id;
+            alter table paimon.${dbName}.${tableName} add column added_payload 
string after payload;
+            insert into paimon.${dbName}.${tableName}
+                (part_b, id, part_a, payload, added_payload)
+                values (3, 4, 'A', 'after-reorder', 'added');
+        """
+        int schemasAfterSupportedChanges = schemaCount()
+
+        // Scenario PM-M02-negative: rename, type change and drop of either 
partition key must be
+        // rejected atomically. These are format restrictions, not missing 
Doris capabilities.
+        assertSparkRejected("""
+            alter table paimon.${dbName}.${tableName}
+                rename column part_a to renamed_part_a
+        """, "rename", "Cannot rename partition column")
+        assertSparkRejected("""
+            alter table paimon.${dbName}.${tableName}
+                alter column part_b type bigint
+        """, "type change", "Cannot update partition column")
+        assertSparkRejected("""
+            alter table paimon.${dbName}.${tableName} drop column part_a
+        """, "drop", "Cannot drop partition key or primary key")
+        assertSparkRejected("""
+            alter table paimon.${dbName}.${tableName} drop column part_b
+        """, "drop", "Cannot drop partition key or primary key")
+        assertEquals(schemasAfterSupportedChanges, schemaCount(),
+                "Rejected partition mutations must not create Paimon schemas")
+
+        sql """switch ${catalogName}"""
+        sql """use ${dbName}"""
+        sql """refresh table ${tableName}"""
+
+        // Scenario PM-M03: current data and static partition filtering 
survive accepted reorder
+        // and all rejected mutations.
+        qt_current_partition_filter """
+            select id from ${tableName}
+            where part_a = 'A' and part_b in (1, 2, 3)
+            order by id
+        """
+        qt_current_added_payload """
+            select id, added_payload from ${tableName}
+            where part_a = 'A' and part_b = 3
+        """
+
+        // Scenario PM-M04: the pre-reorder tag retains its original schema 
and partitions.
+        qt_base_tag_partition_filter """
+            select id, part_a, part_b, payload
+            from ${tableName}@tag(partition_contract_base)
+            where part_a in ('A', 'B')
+            order by id
+        """
+        test {
+            sql """
+                select added_payload
+                from ${tableName}@tag(partition_contract_base)
+            """
+            exception "Unknown column 'added_payload'"
+        }
+    } finally {
+        sql """drop catalog if exists ${catalogName}"""
+    }
+}
diff --git 
a/regression-test/suites/external_table_p0/paimon/test_paimon_partition_pk_delete_refs.groovy
 
b/regression-test/suites/external_table_p0/paimon/test_paimon_partition_pk_delete_refs.groovy
new file mode 100644
index 00000000000..85007d147ba
--- /dev/null
+++ 
b/regression-test/suites/external_table_p0/paimon/test_paimon_partition_pk_delete_refs.groovy
@@ -0,0 +1,346 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+import org.apache.doris.regression.action.ProfileAction
+
+suite("test_paimon_partition_pk_delete_refs",
+        
"p0,external,paimon,external_docker,external_docker_paimon,nonConcurrent") {
+    String enabled = context.config.otherConfigs.get("enablePaimonTest")
+    if (enabled == null || !enabled.equalsIgnoreCase("true")) {
+        logger.info("disable paimon test")
+        return
+    }
+
+    String minioPort = context.config.otherConfigs.get("iceberg_minio_port")
+    String externalEnvIp = context.config.otherConfigs.get("externalEnvIp")
+    String catalogName = "test_paimon_partition_pk_delete_refs"
+    String dbName = "paimon_partition_pk_delete_refs_db"
+
+    def stringRows = { String query ->
+        sql(query).collect { row ->
+            row.collect { value -> value == null ? null : value.toString() }
+        }
+    }
+    def latestSnapshotId = { String tableName ->
+        return spark_paimon("""
+            select snapshot_id
+            from paimon.${dbName}.`${tableName}\$snapshots`
+            order by snapshot_id desc
+            limit 1
+        """)[0][0].toString()
+    }
+    def createTag = { String tableName, String tagName ->
+        spark_paimon """
+            call paimon.sys.create_tag(
+                table => '${dbName}.${tableName}',
+                tag => '${tagName}'
+            )
+        """
+    }
+    def profileAction = new ProfileAction(context)
+    def profileCounterValues = { String profileText, String counterName ->
+        def values = []
+        def matcher = profileText =~ ("(?m)^\\s*(?:-\\s*)?"
+                + java.util.regex.Pattern.quote(counterName) + 
":\\s+([^\\n]+)")
+        while (matcher.find()) {
+            String valueText = matcher.group(1).toString()
+            def exact = valueText =~ /\(([0-9,]+)\)/
+            def number = valueText =~ /([0-9,]+)/
+            String rawValue = exact.find() ? exact.group(1) : (number.find() ? 
number.group(1) : null)
+            if (rawValue != null) {
+                values.add(Long.parseLong(rawValue.replace(",", "")))
+            }
+        }
+        return values
+    }
+    def assertRuntimeFilterPruned = { String tableName, String dimensionTable 
->
+        String token = UUID.randomUUID().toString()
+        List<List<String>> rows = stringRows("""
+            select /*+ SET_VAR(runtime_filter_type='IN_OR_BLOOM_FILTER') */
+                '${token}', f.id, f.full_name
+            from ${tableName} f
+            join ${dimensionTable} d on f.part = d.part
+            order by f.id
+        """)
+        String profile = profileAction.getProfileBySql(
+                token,
+                ["RuntimeFilterPartitionPrunedRangeNum"],
+                30000L,
+                500L)
+        long fileRangesPruned = profileCounterValues(
+                profile, "RuntimeFilterPartitionPrunedRangeNum").sum(0L)
+        long partitionsPruned = profileCounterValues(
+                profile, "PartitionsPrunedByRuntimeFilter").sum(0L)
+        assertTrue(fileRangesPruned + partitionsPruned > 0L,
+                "Runtime filter did not prune a Paimon PK partition/file 
range; "
+                        + profile.take(2000).replaceAll("\\s+", " "))
+        return rows.collect { row -> [row[1], row[2]] }
+    }
+    def getExplainText = { String query ->
+        return sql("explain verbose ${query}").collect { row -> 
row[0].toString() }.join("\n")
+    }
+    def assertNativePath = { String query, String label ->
+        String explainText = getExplainText(query)
+        def splitMatcher = (explainText =~ 
/paimonNativeReadSplits=(\d+)\/(\d+)/)
+        assertTrue(splitMatcher.find(), "Expected paimonNativeReadSplits for 
${label}")
+        long nativeSplits = Long.parseLong(splitMatcher.group(1))
+        long totalSplits = Long.parseLong(splitMatcher.group(2))
+        assertTrue(totalSplits > 0 && nativeSplits > 0,
+                "Expected native splits for ${label}, native=${nativeSplits}, 
total=${totalSplits}")
+        assertTrue(explainText.contains("SplitStat [type=NATIVE"),
+                "Expected a NATIVE split for ${label}")
+    }
+    def assertJniPath = { String query, String label ->
+        String explainText = getExplainText(query)
+        def splitMatcher = (explainText =~ 
/paimonNativeReadSplits=(\d+)\/(\d+)/)
+        assertTrue(splitMatcher.find(), "Expected paimonNativeReadSplits for 
${label}")
+        long nativeSplits = Long.parseLong(splitMatcher.group(1))
+        long totalSplits = Long.parseLong(splitMatcher.group(2))
+        assertTrue(totalSplits > 0 && nativeSplits == 0,
+                "Expected JNI-only splits for ${label}, 
native=${nativeSplits}, total=${totalSplits}")
+        assertTrue(explainText.contains("SplitStat [type=JNI"),
+                "Expected a JNI split for ${label}")
+    }
+
+    sql """drop catalog if exists ${catalogName}"""
+    sql """
+        create catalog ${catalogName} properties (
+            'type'='paimon',
+            'warehouse'='s3://warehouse/wh',
+            's3.endpoint'='http://${externalEnvIp}:${minioPort}',
+            's3.access_key'='admin',
+            's3.secret_key'='password',
+            's3.path.style.access'='true',
+            'meta.cache.paimon.table.ttl-second'='0'
+        )
+    """
+
+    try {
+        ["parquet", "orc"].each { String format ->
+            String tableName = "partition_pk_dv_${format}"
+            String dimensionTable = "${tableName}_dimension"
+            spark_paimon_multi """
+                create database if not exists paimon.${dbName};
+                drop table if exists paimon.${dbName}.${tableName};
+                create table paimon.${dbName}.${tableName} (
+                    id int not null,
+                    part string not null,
+                    old_name string,
+                    note string,
+                    payload struct<metric:int, label:string>
+                ) using paimon
+                partitioned by (part)
+                tblproperties (
+                    'bucket'='1',
+                    'primary-key'='part,id',
+                    'file.format'='${format}',
+                    'deletion-vectors.enabled'='true'
+                );
+                insert into paimon.${dbName}.${tableName} values
+                    (1, 'p1', 'alpha', 'old-note-1',
+                        named_struct('metric', 10, 'label', 'base-1')),
+                    (2, 'p1', 'beta', 'old-note-2',
+                        named_struct('metric', 20, 'label', 'base-delete')),
+                    (3, 'p2', 'gamma', 'old-note-3',
+                        named_struct('metric', 30, 'label', 'base-p2')),
+                    (90, 'p_dv', 'dv-victim', 'old-note-90',
+                        named_struct('metric', 90, 'label', 'dv-victim'));
+            """
+            String baseSnapshot = latestSnapshotId(tableName)
+            createTag(tableName, "${tableName}_base")
+
+            // Scenario PM-D01: add/rename fields, upsert one PK and delete 
another inside p1.
+            spark_paimon_multi """
+                alter table paimon.${dbName}.${tableName} add column 
payload.extra string;
+                alter table paimon.${dbName}.${tableName} rename column 
old_name to full_name;
+                insert into paimon.${dbName}.${tableName}
+                    (id, part, full_name, note, payload) values
+                    (1, 'p1', 'alpha-updated', 'new-note-1',
+                        named_struct('metric', 11, 'label', 'updated-1', 
'extra', 'extra-1')),
+                    (4, 'p1', 'delta', 'delete-later',
+                        named_struct('metric', 40, 'label', 'insert-4', 
'extra', 'extra-4'));
+                delete from paimon.${dbName}.${tableName}
+                    where part = 'p1' and id = 2;
+            """
+            String firstDeleteSnapshot = latestSnapshotId(tableName)
+            createTag(tableName, "${tableName}_first_delete")
+
+            // Scenario PM-D02: nested rename/type promotion and drop/re-add 
combine with another
+            // delete, insert and full compaction while the partition key 
stays fixed.
+            spark_paimon_multi """
+                alter table paimon.${dbName}.${tableName}
+                    rename column payload.label to renamed_label;
+                alter table paimon.${dbName}.${tableName}
+                    alter column payload.metric type bigint;
+                alter table paimon.${dbName}.${tableName} drop column note;
+                alter table paimon.${dbName}.${tableName} add column note 
bigint;
+                delete from paimon.${dbName}.${tableName}
+                    where part = 'p1' and id = 4;
+                insert into paimon.${dbName}.${tableName}
+                    (id, part, full_name, payload, note) values
+                    (5, 'p1', 'epsilon',
+                        named_struct('metric', 5000000000,
+                            'renamed_label', 'insert-5', 'extra', 'extra-5'),
+                        5000);
+                call paimon.sys.compact(
+                    table => '${dbName}.${tableName}',
+                    compact_strategy => 'full'
+                );
+                call paimon.sys.create_tag(
+                    table => '${dbName}.${tableName}',
+                    tag => '${tableName}_pre_dv'
+                );
+                -- Prevent synchronous batch compaction from materializing the 
sacrificial
+                -- delete, so the fixture retains a physical DV artifact for 
reader checks.
+                alter table paimon.${dbName}.${tableName}
+                    set tblproperties ('write-only'='true');
+                delete from paimon.${dbName}.${tableName}
+                    where part = 'p_dv' and id = 90;
+                drop table if exists paimon.${dbName}.${dimensionTable};
+                create table paimon.${dbName}.${dimensionTable} (part string)
+                    using paimon tblproperties ('file.format'='parquet');
+                insert into paimon.${dbName}.${dimensionTable} values ('p1');
+            """
+            long deletedRowsInFiles = spark_paimon("""
+                select coalesce(sum(deleteRowCount), 0)
+                from paimon.${dbName}.`${tableName}\$files`
+            """)[0][0].toString().toLong()
+            assertTrue(deletedRowsInFiles > 0,
+                    "${tableName} must retain a physical deletion-vector row")
+            String finalSnapshot = latestSnapshotId(tableName)
+            createTag(tableName, "${tableName}_final")
+
+            sql """switch ${catalogName}"""
+            sql """use ${dbName}"""
+            sql """refresh table ${tableName}"""
+            String actionSuffix = format
+
+            // Scenario PM-D03: static partition filters apply PK upserts, 
deletes and DV state.
+            List<List<String>> expectedCurrent = [["1", "alpha-updated"], 
["5", "epsilon"]]
+            "qt_${actionSuffix}_current_partition_filter"("""
+                select id, full_name from ${tableName}
+                where part = 'p1' order by id
+            """)
+            "qt_${actionSuffix}_current_promoted_and_readded"("""
+                select id, payload.metric, note from ${tableName}
+                where part = 'p1' and payload.metric > 1000000000
+                order by id
+            """)
+            // Surviving pre-readd row 1 must see NULL for the new BIGINT note 
field, while the
+            // nested rename and added child stay bound by field ID across the 
delete timeline.
+            "qt_${actionSuffix}_current_evolved_fields"("""
+                select id, payload.renamed_label, payload.extra, note
+                from ${tableName}
+                where part = 'p1'
+                order by id
+            """)
+            "qt_${actionSuffix}_pre_dv_old_row_null"("""
+                select id, payload.renamed_label, payload.extra, note
+                from ${tableName}@tag(${tableName}_pre_dv)
+                where part = 'p1' and id = 1
+            """)
+            test {
+                sql """select payload.label from ${tableName} where part = 
'p1'"""
+                exception "label"
+            }
+
+            // Scenario PM-D04: runtime-filter pruning on the partition key 
remains delete-aware.
+            String rfQuery = """
+                select /*+ SET_VAR(runtime_filter_type='IN_OR_BLOOM_FILTER') */
+                    f.id, f.full_name
+                from ${tableName} f
+                join ${dimensionTable} d on f.part = d.part
+                order by f.id
+            """
+            sql """set runtime_filter_wait_infinitely=true"""
+            sql """set disable_join_reorder=true"""
+            sql """set enable_runtime_filter_prune=false"""
+            sql """set runtime_filter_mode=GLOBAL"""
+            sql """set parallel_pipeline_task_num=1"""
+            sql """set enable_profile=true"""
+            sql """set profile_level=2"""
+            sql """set enable_runtime_filter_partition_prune=false"""
+            "qt_${actionSuffix}_rf_disabled"(rfQuery)
+            sql """set enable_runtime_filter_partition_prune=true"""
+            assertEquals(expectedCurrent, assertRuntimeFilterPruned(tableName, 
dimensionTable))
+
+            // Scenario PM-D05: numeric snapshots and tags preserve the 
matching schema/delete set.
+            "qt_${actionSuffix}_base_snapshot"("""
+                select id, old_name, note, payload.label
+                from ${tableName} for version as of ${baseSnapshot}
+                where part = 'p1' order by id
+            """)
+            "qt_${actionSuffix}_base_tag"("""
+                select id, old_name, note, payload.label
+                from ${tableName}@tag(${tableName}_base)
+                where part = 'p1' order by id
+            """)
+            "qt_${actionSuffix}_first_delete_snapshot"("""
+                select id, full_name, note, payload.label, payload.extra
+                from ${tableName} for version as of ${firstDeleteSnapshot}
+                where part = 'p1' order by id
+            """)
+            "qt_${actionSuffix}_final_tag"("""
+                select id, full_name, note, payload.renamed_label, 
payload.extra
+                from ${tableName}@tag(${tableName}_final)
+                where part = 'p1' order by id
+            """)
+            test {
+                sql """
+                    select payload.renamed_label
+                    from ${tableName} for version as of ${baseSnapshot}
+                """
+                exception "renamed_label"
+            }
+
+            // Scenario PM-D06: JNI/native readers agree for current DV state 
and historical
+            // projections, and explain must prove the requested reader path 
rather than fallback.
+            String currentReaderQuery = """
+                select id, part, full_name, note, payload.renamed_label, 
payload.extra
+                from ${tableName}
+                where part in ('p1', 'p2')
+                order by part, id
+            """
+            String historicalReaderQuery = """
+                select id, part, old_name, note, payload.label
+                from ${tableName} for version as of ${baseSnapshot}
+                where part in ('p1', 'p2')
+                order by part, id
+            """
+            sql """set enable_paimon_cpp_reader=false"""
+            sql """set force_jni_scanner=true"""
+            assertJniPath(currentReaderQuery, "${tableName} current DV")
+            assertJniPath(historicalReaderQuery, "${tableName} historical")
+            "qt_${actionSuffix}_jni_current_dv"(currentReaderQuery)
+            "qt_${actionSuffix}_jni_historical"(historicalReaderQuery)
+            sql """set force_jni_scanner=false"""
+            sql """set enable_paimon_cpp_reader=true"""
+            assertNativePath(currentReaderQuery, "${tableName} current DV")
+            assertNativePath(historicalReaderQuery, "${tableName} historical")
+            "qt_${actionSuffix}_native_current_dv"(currentReaderQuery)
+            "qt_${actionSuffix}_native_historical"(historicalReaderQuery)
+            assertEquals(finalSnapshot, latestSnapshotId(tableName))
+        }
+    } finally {
+        sql """set enable_paimon_cpp_reader=false"""
+        sql """set force_jni_scanner=false"""
+        sql """set enable_runtime_filter_prune=true"""
+        sql """set enable_runtime_filter_partition_prune=true"""
+        sql """set disable_join_reorder=false"""
+        sql """drop catalog if exists ${catalogName}"""
+    }
+}
diff --git 
a/regression-test/suites/external_table_p0/paimon/test_paimon_partition_schema_filter_refs.groovy
 
b/regression-test/suites/external_table_p0/paimon/test_paimon_partition_schema_filter_refs.groovy
new file mode 100644
index 00000000000..979fbb7784c
--- /dev/null
+++ 
b/regression-test/suites/external_table_p0/paimon/test_paimon_partition_schema_filter_refs.groovy
@@ -0,0 +1,366 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+import org.apache.doris.regression.action.ProfileAction
+
+suite("test_paimon_partition_schema_filter_refs",
+        
"p0,external,paimon,external_docker,external_docker_paimon,nonConcurrent") {
+    String enabled = context.config.otherConfigs.get("enablePaimonTest")
+    if (enabled == null || !enabled.equalsIgnoreCase("true")) {
+        logger.info("disable paimon test")
+        return
+    }
+
+    String minioPort = context.config.otherConfigs.get("iceberg_minio_port")
+    String externalEnvIp = context.config.otherConfigs.get("externalEnvIp")
+    String catalogName = "test_paimon_partition_schema_filter_refs"
+    String dbName = "paimon_partition_schema_filter_refs_db"
+
+    def stringRows = { String query ->
+        sql(query).collect { row ->
+            row.collect { value -> value == null ? null : value.toString() }
+        }
+    }
+    def latestSnapshotId = { String tableName ->
+        return spark_paimon("""
+            select snapshot_id
+            from paimon.${dbName}.`${tableName}\$snapshots`
+            order by snapshot_id desc
+            limit 1
+        """)[0][0].toString()
+    }
+    def createTag = { String tableName, String tagName ->
+        spark_paimon """
+            call paimon.sys.create_tag(
+                table => '${dbName}.${tableName}',
+                tag => '${tagName}'
+            )
+        """
+    }
+    def profileAction = new ProfileAction(context)
+    def profileCounterValues = { String profileText, String counterName ->
+        def values = []
+        def matcher = profileText =~ ("(?m)^\\s*(?:-\\s*)?"
+                + java.util.regex.Pattern.quote(counterName) + 
":\\s+([^\\n]+)")
+        while (matcher.find()) {
+            def number = matcher.group(1).toString() =~ /([0-9,]+)/
+            if (number.find()) {
+                values.add(Long.parseLong(number.group(1).replace(",", "")))
+            }
+        }
+        return values
+    }
+    def assertRuntimeFilterPruned = { String tableName, String dimensionTable,
+                                      List<List<String>> expectedRows ->
+        String token = UUID.randomUUID().toString()
+        assertEquals(expectedRows, stringRows("""
+            select /*+ SET_VAR(runtime_filter_type='IN_OR_BLOOM_FILTER') */
+                '${token}', f.id
+            from ${tableName} f join ${dimensionTable} d on f.part = d.part
+            order by f.id
+        """).collect { row -> [row[1]] })
+        // Scanner counters can arrive after the profile list first reports 
COMPLETE.
+        String profile = profileAction.getProfileBySql(
+                token,
+                ["RuntimeFilterPartitionPrunedRangeNum"],
+                30000L,
+                500L)
+        long fileRangesPruned = profileCounterValues(
+                profile, "RuntimeFilterPartitionPrunedRangeNum").sum(0L)
+        long partitionsPruned = profileCounterValues(
+                profile, "PartitionsPrunedByRuntimeFilter").sum(0L)
+        assertTrue(fileRangesPruned + partitionsPruned > 0L,
+                "Runtime filter did not prune any Paimon partition/file range; 
"
+                        + profile.take(2000).replaceAll("\\s+", " "))
+    }
+    def getExplainText = { String query ->
+        return sql("explain verbose ${query}").collect { row -> 
row[0].toString() }.join("\n")
+    }
+    def assertNativePath = { String query, String label ->
+        String explainText = getExplainText(query)
+        def splitMatcher = (explainText =~ 
/paimonNativeReadSplits=(\d+)\/(\d+)/)
+        assertTrue(splitMatcher.find(), "Expected paimonNativeReadSplits for 
${label}")
+        long nativeSplits = Long.parseLong(splitMatcher.group(1))
+        long totalSplits = Long.parseLong(splitMatcher.group(2))
+        assertTrue(totalSplits > 0 && nativeSplits > 0,
+                "Expected native splits for ${label}, native=${nativeSplits}, 
total=${totalSplits}")
+        assertTrue(explainText.contains("SplitStat [type=NATIVE"),
+                "Expected a NATIVE split for ${label}")
+    }
+    def assertJniPath = { String query, String label ->
+        String explainText = getExplainText(query)
+        def splitMatcher = (explainText =~ 
/paimonNativeReadSplits=(\d+)\/(\d+)/)
+        assertTrue(splitMatcher.find(), "Expected paimonNativeReadSplits for 
${label}")
+        long nativeSplits = Long.parseLong(splitMatcher.group(1))
+        long totalSplits = Long.parseLong(splitMatcher.group(2))
+        assertTrue(totalSplits > 0 && nativeSplits == 0,
+                "Expected JNI-only splits for ${label}, 
native=${nativeSplits}, total=${totalSplits}")
+        assertTrue(explainText.contains("SplitStat [type=JNI"),
+                "Expected a JNI split for ${label}")
+    }
+
+    sql """drop catalog if exists ${catalogName}"""
+    sql """
+        create catalog ${catalogName} properties (
+            'type'='paimon',
+            'warehouse'='s3://warehouse/wh',
+            's3.endpoint'='http://${externalEnvIp}:${minioPort}',
+            's3.access_key'='admin',
+            's3.secret_key'='password',
+            's3.path.style.access'='true',
+            'meta.cache.paimon.table.ttl-second'='0'
+        )
+    """
+
+    try {
+        ["parquet", "orc"].each { String format ->
+            String tableName = "partition_schema_${format}"
+            String dimensionTable = "${tableName}_dimension"
+            spark_paimon_multi """
+                create database if not exists paimon.${dbName};
+                drop table if exists paimon.${dbName}.${tableName};
+                create table paimon.${dbName}.${tableName} (
+                    id int,
+                    part string,
+                    payload struct<metric:int, label:string>,
+                    attrs map<string, struct<code:int>>,
+                    events array<struct<score:int>>
+                ) using paimon
+                partitioned by (part)
+                tblproperties ('file.format'='${format}');
+                insert into paimon.${dbName}.${tableName} values
+                    (1, 'p1', named_struct('metric', 10, 'label', 'base-1'),
+                        map('k', named_struct('code', 100)),
+                        array(named_struct('score', 1000))),
+                    (2, 'p2', named_struct('metric', 20, 'label', 'base-2'),
+                        map('k', named_struct('code', 200)),
+                        array(named_struct('score', 2000)));
+            """
+            String baseSnapshot = latestSnapshotId(tableName)
+            createTag(tableName, "${tableName}_base")
+            spark_paimon """
+                call paimon.sys.create_branch(
+                    '${dbName}.${tableName}',
+                    '${tableName}_base_branch',
+                    '${tableName}_base'
+                )
+            """
+
+            // Scenario PM-PE01: Paimon keeps a fixed partition key while 
STRUCT, MAP-value
+            // STRUCT and ARRAY-element STRUCT children are added.
+            spark_paimon_multi """
+                alter table paimon.${dbName}.${tableName} add column 
payload.extra string;
+                alter table paimon.${dbName}.${tableName} add column 
attrs.value.extra int;
+                alter table paimon.${dbName}.${tableName} add column 
events.element.extra int;
+                insert into paimon.${dbName}.${tableName} values
+                    (3, 'p1',
+                        named_struct('metric', 30, 'label', 'add-3', 'extra', 
'payload-extra'),
+                        map('k', named_struct('code', 300, 'extra', 301)),
+                        array(named_struct('score', 3000, 'extra', 3001))),
+                    (4, 'p3',
+                        named_struct('metric', 40, 'label', 'add-4', 'extra', 
'payload-extra'),
+                        map('k', named_struct('code', 400, 'extra', 401)),
+                        array(named_struct('score', 4000, 'extra', 4001)));
+            """
+            String addedSnapshot = latestSnapshotId(tableName)
+
+            // Scenario PM-PE02: rename and promote complex children without 
changing partition
+            // identity. Old snapshot/tag/branch schemas must remain 
independently readable.
+            spark_paimon_multi """
+                alter table paimon.${dbName}.${tableName}
+                    rename column payload.label to renamed_label;
+                alter table paimon.${dbName}.${tableName}
+                    rename column attrs.value.code to renamed_code;
+                alter table paimon.${dbName}.${tableName}
+                    rename column events.element.score to renamed_score;
+                alter table paimon.${dbName}.${tableName}
+                    alter column payload.metric type bigint;
+                insert into paimon.${dbName}.${tableName} values
+                    (5, 'p1',
+                        named_struct('metric', 5000000000,
+                            'renamed_label', 'rename-5', 'extra', 
'payload-extra'),
+                        map('k', named_struct('renamed_code', 500, 'extra', 
501)),
+                        array(named_struct('renamed_score', 5000, 'extra', 
5001))),
+                    (6, 'p4',
+                        named_struct('metric', 60,
+                            'renamed_label', 'rename-6', 'extra', 
'payload-extra'),
+                        map('k', named_struct('renamed_code', 600, 'extra', 
601)),
+                        array(named_struct('renamed_score', 6000, 'extra', 
6001)));
+            """
+
+            // Scenario PM-PE03: drop/re-add a nested name to verify field IDs 
never leak values
+            // through partition-filtered scans.
+            spark_paimon_multi """
+                alter table paimon.${dbName}.${tableName} drop column 
payload.extra;
+                alter table paimon.${dbName}.${tableName} add column 
payload.extra bigint;
+                insert into paimon.${dbName}.${tableName} values
+                    (7, 'p1',
+                        named_struct('metric', 70,
+                            'renamed_label', 'readd-7', 'extra', 7000),
+                        map('k', named_struct('renamed_code', 700, 'extra', 
701)),
+                        array(named_struct('renamed_score', 7000, 'extra', 
7001)));
+                drop table if exists paimon.${dbName}.${dimensionTable};
+                create table paimon.${dbName}.${dimensionTable} (part string)
+                    using paimon tblproperties ('file.format'='parquet');
+                insert into paimon.${dbName}.${dimensionTable} values ('p1');
+            """
+            String finalSnapshot = latestSnapshotId(tableName)
+            createTag(tableName, "${tableName}_final")
+
+            sql """switch ${catalogName}"""
+            sql """use ${dbName}"""
+            sql """refresh table ${tableName}"""
+            String actionSuffix = format
+
+            // Scenario PM-F01: static partition filtering combines every 
complex-schema version.
+            "qt_${actionSuffix}_current_partition_filter"("""
+                select id from ${tableName} where part = 'p1' order by id
+            """)
+            "qt_${actionSuffix}_current_promoted_metric"("""
+                select id, payload.metric from ${tableName}
+                where part = 'p1' and payload.metric > 1000000000
+                order by id
+            """)
+            "qt_${actionSuffix}_current_readded_payload"("""
+                select id, payload.extra from ${tableName}
+                where part = 'p1' and payload.extra is not null
+                order by id
+            """)
+            // Project every evolved STRUCT/MAP/ARRAY child so a reader cannot 
pass by decoding
+            // only the top-level partition and payload fields.
+            "qt_${actionSuffix}_current_all_complex_children"("""
+                select id, payload.renamed_label, payload.extra,
+                       element_at(attrs, 'k').renamed_code,
+                       element_at(attrs, 'k').extra,
+                       events[1].renamed_score,
+                       events[1].extra
+                from ${tableName}
+                where part = 'p1'
+                order by id
+            """)
+
+            // Scenario PM-RF01: runtime-filter partition pruning stays 
result-equivalent across
+            // complex schema versions.
+            String rfQuery = """
+                select /*+ SET_VAR(runtime_filter_type='IN_OR_BLOOM_FILTER') 
*/ f.id
+                from ${tableName} f join ${dimensionTable} d on f.part = d.part
+                order by f.id
+            """
+            sql """set runtime_filter_wait_infinitely=true"""
+            // Small fixture tables have no column statistics. Keep the 
generated RF so this
+            // suite validates scanner-side partition pruning rather than RF 
selectivity pruning.
+            sql """set enable_runtime_filter_prune=false"""
+            sql """set runtime_filter_mode=GLOBAL"""
+            sql """set parallel_pipeline_task_num=1"""
+            sql """set disable_join_reorder=true"""
+            sql """set enable_profile=true"""
+            sql """set profile_level=2"""
+            sql """set enable_runtime_filter_partition_prune=false"""
+            "qt_${actionSuffix}_rf_disabled"(rfQuery)
+            sql """set enable_runtime_filter_partition_prune=true"""
+            assertRuntimeFilterPruned(
+                    tableName, dimensionTable, [["1"], ["3"], ["5"], ["7"]])
+
+            // Scenario PM-R01: numeric snapshot, tag and branch bind their 
historical schemas.
+            "qt_${actionSuffix}_base_snapshot"("""
+                select id, part, payload.label
+                from ${tableName} for version as of ${baseSnapshot}
+                where part in ('p1', 'p2') order by id
+            """)
+            "qt_${actionSuffix}_base_tag"("""
+                select id, part, payload.label
+                from ${tableName}@tag(${tableName}_base)
+                where part in ('p1', 'p2') order by id
+            """)
+            "qt_${actionSuffix}_base_branch"("""
+                select id, part, payload.label
+                from ${tableName}@branch(${tableName}_base_branch)
+                where part in ('p1', 'p2') order by id
+            """)
+            "qt_${actionSuffix}_added_snapshot_complex"("""
+                select id, payload.label,
+                       element_at(attrs, 'k').code,
+                       element_at(attrs, 'k').extra,
+                       events[1].score,
+                       events[1].extra
+                from ${tableName} for version as of ${addedSnapshot}
+                where part = 'p1'
+                order by id
+            """)
+            "qt_${actionSuffix}_final_tag"("""
+                select id from ${tableName}@tag(${tableName}_final)
+                where part = 'p1' order by id
+            """)
+            "qt_${actionSuffix}_base_snapshot_complex"("""
+                select id, element_at(attrs, 'k').code, events[1].score
+                from ${tableName} for version as of ${baseSnapshot}
+                order by id
+            """)
+            test {
+                sql """
+                    select element_at(attrs, 'k').renamed_code
+                    from ${tableName} for version as of ${baseSnapshot}
+                """
+                exception "renamed_code"
+            }
+            test {
+                sql """
+                    select events[1].score
+                    from ${tableName}@tag(${tableName}_final)
+                """
+                exception "score"
+            }
+
+            // Scenario PM-RD01: JNI and native readers agree for 
partition-filtered historical
+            // and current complex projections, and the explain plan proves 
each requested path.
+            String currentReaderQuery = """
+                select id, payload.renamed_label, payload.extra,
+                       element_at(attrs, 'k').renamed_code,
+                       events[1].renamed_score
+                from ${tableName}
+                where part in ('p1', 'p3')
+                order by id
+            """
+            String historicalReaderQuery = """
+                select id, payload.label, element_at(attrs, 'k').code, 
events[1].score
+                from ${tableName} for version as of ${baseSnapshot}
+                where part in ('p1', 'p2')
+                order by id
+            """
+            sql """set enable_paimon_cpp_reader=false"""
+            sql """set force_jni_scanner=true"""
+            assertJniPath(currentReaderQuery, "${tableName} current")
+            assertJniPath(historicalReaderQuery, "${tableName} historical")
+            "qt_${actionSuffix}_jni_current_complex"(currentReaderQuery)
+            "qt_${actionSuffix}_jni_historical_complex"(historicalReaderQuery)
+            sql """set force_jni_scanner=false"""
+            sql """set enable_paimon_cpp_reader=true"""
+            assertNativePath(currentReaderQuery, "${tableName} current")
+            assertNativePath(historicalReaderQuery, "${tableName} historical")
+            "qt_${actionSuffix}_native_current_complex"(currentReaderQuery)
+            
"qt_${actionSuffix}_native_historical_complex"(historicalReaderQuery)
+            assertEquals(finalSnapshot, latestSnapshotId(tableName))
+        }
+    } finally {
+        sql """set enable_paimon_cpp_reader=false"""
+        sql """set force_jni_scanner=false"""
+        sql """set enable_runtime_filter_prune=true"""
+        sql """set enable_runtime_filter_partition_prune=true"""
+        sql """set disable_join_reorder=false"""
+        sql """drop catalog if exists ${catalogName}"""
+    }
+}


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


Reply via email to