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 7d16729d1fc [fix](paimon) Preserve schema options across renamed-field 
reads (#69545)
7d16729d1fc is described below

commit 7d16729d1fc89dabf0ca898d621baa1d1ea0b30e
Author: Gabriel <[email protected]>
AuthorDate: Sun Oct 25 22:33:45 2026 +0800

    [fix](paimon) Preserve schema options across renamed-field reads (#69545)
    
    ### What problem does this PR solve?
    
    After renaming a Paimon sequence or aggregation field, current and
    historical reads can fail with a missing-field error. Paimon's table
    copy selects the historical columns but overlays current table options,
    so `sequence.field` and `fields.<column>.aggregate-function` can
    reference names that do not exist in the selected schema.
    
    JNI transport also rebuilds tables without their catalog loader. The SDK
    factory can reapply a snapshot selector during that rebuild, reverting a
    schema-only rename before the bound schema is restored. Build with
    selectors temporarily removed and then restore the complete bound
    schema.
    
    Restore the selected schema and its physical options together before
    applying query options. Preserve the resolved table's read-state keys
    and branch policy, including absent keys, so historical options cannot
    resurrect a removed scan mode or change fallback priority during
    parallelism normalization. Preserve explicit catalog overrides and the
    SDK's fallback-branch snapshot translation. Apply the same logic to scan
    planning, metadata, statistics, and the source used to rebuild
    system-table wrappers, so binding and JNI serialization agree.
    
    Add real-table tests for snapshot, timestamp, tag, and OPTIONS reads
    after renames, current reads after schema-only renames, catalog-loader
    removal and serialization, catalog override precedence, fallback
    branches, empty-table timestamps, and read-optimized system tables. Add
    an end-to-end regression covering sequence semantics and repeated
    aggregation-field renames with `force_jni_scanner=false` and `true`.
    
    ### Release note
    
    Fix Paimon current and historical reads after renaming sequence or
    aggregation fields.
    
    ### Check List (For Author)
    
    - Test:
    - Baseline: 8 historical-read cases fail with missing-field errors. Two
    additional catalog-backed current-read cases fail during loader removal.
    Review regressions reproduce a removed startup mode being restored and
    different branch rows at capacities 64 and 128.
    - Full Paimon connector reactor install: 866 tests, 0 failures, 0
    errors, 1 existing skipped; all 21 new tests pass.
    - FE Checkstyle: passed. FE/BE build and connector packaging: passed.
    - End-to-end regression: all 26 query checkpoints passed on a running
    FE/BE cluster; generated expected output was reviewed and a second
    comparison run passed (1 suite, 0 failures, 0 skips).
    - Behavior changed: Yes. Current reads retain their statement-bound
    schema during JNI transport, and historical reads use field-dependent
    options from the selected schema.
    - Does this need documentation: No.
---
 .../doris/connector/paimon/PaimonCatalogOps.java   |   2 +-
 .../connector/paimon/PaimonConnectorMetadata.java  |  38 +-
 .../connector/paimon/PaimonReaderOptions.java      |  10 +-
 .../doris/connector/paimon/PaimonScanParams.java   |  86 +++-
 .../connector/paimon/PaimonScanPlanProvider.java   |  31 +-
 .../connector/paimon/PaimonTableDecorators.java    |  17 +
 .../paimon/PaimonHistoricalSchemaOptionsTest.java  | 452 +++++++++++++++++++++
 .../test_paimon_renamed_field_time_travel.out      | 107 +++++
 .../test_paimon_renamed_field_time_travel.groovy   | 207 ++++++++++
 9 files changed, 915 insertions(+), 35 deletions(-)

diff --git 
a/fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/PaimonCatalogOps.java
 
b/fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/PaimonCatalogOps.java
index 48a69bf114f..bd605d69691 100644
--- 
a/fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/PaimonCatalogOps.java
+++ 
b/fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/PaimonCatalogOps.java
@@ -312,7 +312,7 @@ public interface PaimonCatalogOps {
             // so this must not invalidate: REFRESH, Doris writes and DDL 
invalidate through CatalogMetaCache.
             Table table = catalog.getTable(identifier);
             Map<String, String> optionsForCopy = 
PaimonTableOptions.forCopy(tableOptions);
-            return optionsForCopy.isEmpty() ? table : 
table.copy(optionsForCopy);
+            return optionsForCopy.isEmpty() ? table : 
PaimonScanParams.copyWithHistoricalSchema(table, optionsForCopy);
         }
 
         @Override
diff --git 
a/fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/PaimonConnectorMetadata.java
 
b/fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/PaimonConnectorMetadata.java
index 79ecda41202..c9de0bba921 100644
--- 
a/fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/PaimonConnectorMetadata.java
+++ 
b/fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/PaimonConnectorMetadata.java
@@ -420,15 +420,11 @@ public class PaimonConnectorMetadata implements 
ConnectorMetadata {
                 ? paimonHandle
                 : (PaimonTableHandle) applySnapshot(session, paimonHandle, 
snapshot);
         Map<String, String> scanOptions = pinned.getScanOptions();
-        if (PaimonScanParams.preservesBoundSchema(scanOptions)) {
+        if (PaimonScanParams.preservesBoundSchema(scanOptions) || 
PaimonScanParams.isOptionsPin(scanOptions)) {
             // Schema-derived wrappers must be built over the bound source 
before exposing their fields.
             return new PaimonScanPlanProvider(catalogProperties, catalogOps, 
context).resolveScanTable(pinned);
         }
-        Table table = resolveTable(pinned);
-        if (scanOptions != null && !scanOptions.isEmpty() && 
PaimonScanParams.isOptionsPin(scanOptions)) {
-            return PaimonScanParams.applyOptions(table, scanOptions);
-        }
-        return table;
+        return resolveTable(pinned);
     }
 
     /**
@@ -870,7 +866,8 @@ public class PaimonConnectorMetadata implements 
ConnectorMetadata {
                     resolved = PaimonScanParams.pinOptionsToSnapshot(
                             spec.getOptions(), 
spec.getLatestSnapshotFence().getAsLong());
                 } else {
-                    resolved = PaimonScanParams.resolveOptions(table, 
spec.getOptions());
+                    resolved = PaimonScanParams.resolveOptions(table, 
spec.getOptions(),
+                            
PaimonTableOptions.extractCompatible(catalogProperties.getRaw()));
                 }
                 String pinnedTag = 
resolved.get(CoreOptions.SCAN_TAG_NAME.key());
                 if (pinnedTag != null) {
@@ -1755,6 +1752,13 @@ public class PaimonConnectorMetadata implements 
ConnectorMetadata {
      * the partition columns and escapes path-special characters in the name 
via the Paimon SDK.
      */
     private List<ConnectorPartitionInfo> collectPartitions(PaimonTableHandle 
paimonHandle) {
+        if (paimonHandle.getScanOptions().isEmpty()) {
+            return collectPartitionsAuthenticated(paimonHandle);
+        }
+        return readMetadataAuthenticated(() -> 
collectPartitionsAuthenticated(paimonHandle));
+    }
+
+    private List<ConnectorPartitionInfo> 
collectPartitionsAuthenticated(PaimonTableHandle paimonHandle) {
         if (PaimonScanParams.isPinnedEmptyScan(paimonHandle.getScanOptions())) 
{
             // Do not reopen latest metadata after the statement fenced an 
empty table.
             return Collections.emptyList();
@@ -1767,7 +1771,8 @@ public class PaimonConnectorMetadata implements 
ConnectorMetadata {
         }
 
         Table resolvedTable = resolveTable(paimonHandle);
-        Map<String, String> scanOptions = paimonHandle.getScanOptions();
+        Map<String, String> scanOptions = PaimonScanParams.withCatalogOptions(
+                paimonHandle.getScanOptions(), 
PaimonTableOptions.extractCompatible(catalogProperties.getRaw()));
         boolean optionsPin = PaimonScanParams.isOptionsPin(scanOptions);
         Table table;
         if (optionsPin) {
@@ -1778,8 +1783,9 @@ public class PaimonConnectorMetadata implements 
ConnectorMetadata {
             // partitions so EXPLAIN and block-rule accounting describe the 
same version as the scan.
             Table partitionTable = snapshotId == null
                     ? resolvedTable
-                    : resolvedTable.copy(Collections.singletonMap(
-                            CoreOptions.SCAN_SNAPSHOT_ID.key(), snapshotId));
+                    : PaimonScanParams.copyWithHistoricalSchema(resolvedTable, 
PaimonScanParams.withCatalogOptions(
+                            
Collections.singletonMap(CoreOptions.SCAN_SNAPSHOT_ID.key(), snapshotId),
+                            
PaimonTableOptions.extractCompatible(catalogProperties.getRaw())));
             // Partition projection never opens a data reader, so reader-only 
settings must not
             // invalidate metadata that a later relation-scoped override can 
make safe.
             // Metadata planning can also touch Paimon's global manifest 
executor, so apply the
@@ -1949,6 +1955,11 @@ public class PaimonConnectorMetadata implements 
ConnectorMetadata {
         if (snapshot == null) {
             return getTableStatistics(session, handle);
         }
+        return readMetadataAuthenticated(() -> 
getTableStatisticsAtSnapshot(session, handle, snapshot));
+    }
+
+    private Optional<ConnectorTableStatistics> getTableStatisticsAtSnapshot(
+            ConnectorSession session, ConnectorTableHandle handle, 
ConnectorMvccSnapshot snapshot) {
         long rowCount;
         try {
             PaimonTableHandle pinned = (PaimonTableHandle) 
applySnapshot(session, handle, snapshot);
@@ -1957,17 +1968,18 @@ public class PaimonConnectorMetadata implements 
ConnectorMetadata {
                 // first commit even though execution is still required to 
scan zero rows.
                 return Optional.empty();
             }
-            Map<String, String> scanOptions = pinned.getScanOptions();
+            Map<String, String> scanOptions = 
PaimonScanParams.withCatalogOptions(
+                    pinned.getScanOptions(), 
PaimonTableOptions.extractCompatible(catalogProperties.getRaw()));
             // applyOptions removes this Doris-only marker, but execution 
still filters files by it.
             // This also covers scan.creation-time-millis when it resolves to 
a file-creation scan.
             if 
(PaimonScanParams.getPinnedFileCreationTime(scanOptions).isPresent()) {
                 return Optional.empty();
             }
             Table table = resolveTable(pinned);
-            if (scanOptions != null && !scanOptions.isEmpty()) {
+            if (scanOptions != null && !scanOptions.isEmpty() && 
!pinned.isSystemTable()) {
                 table = PaimonScanParams.isOptionsPin(scanOptions)
                         ? PaimonScanParams.applyOptions(table, scanOptions)
-                        : table.copy(scanOptions);
+                        : PaimonScanParams.copyWithHistoricalSchema(table, 
scanOptions);
             }
             table = PaimonReaderOptions.runtimeSafeTable(table);
             table = runtimeSafeSystemTable(pinned, table, scanOptions);
diff --git 
a/fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/PaimonReaderOptions.java
 
b/fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/PaimonReaderOptions.java
index 8907dc38ed0..053eac694db 100644
--- 
a/fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/PaimonReaderOptions.java
+++ 
b/fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/PaimonReaderOptions.java
@@ -189,6 +189,14 @@ public final class PaimonReaderOptions {
             }
             return rebuilt;
         }
+        // Non-file sources cannot be rebuilt through SystemTableLoader; 
retain their wrapper's
+        // own copy semantics after independently validating the source's 
effective options.
+        if (scanOptions != null && !scanOptions.isEmpty()) {
+            systemTable = PaimonScanParams.isOptionsPin(scanOptions)
+                    ? PaimonScanParams.applyOptions(systemTable, scanOptions)
+                    : PaimonScanParams.copyWithHistoricalSchema(systemTable,
+                            
PaimonIncrementalScanParams.applyResetsIfIncremental(scanOptions));
+        }
         return runtimeSafeTable(systemTable);
     }
 
@@ -207,7 +215,7 @@ public final class PaimonReaderOptions {
         if (scanOptions != null && !scanOptions.isEmpty()) {
             // Incremental ranges are relation state too; rebuilding a capped 
system wrapper from the
             // undecorated source must not silently turn @incr back into a 
latest scan.
-            effectiveSource = sourceTable.copy(
+            effectiveSource = 
PaimonScanParams.copyWithHistoricalSchema(sourceTable,
                     
PaimonIncrementalScanParams.applyResetsIfIncremental(scanOptions));
         }
         return runtimeSafeTable(effectiveSource);
diff --git 
a/fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/PaimonScanParams.java
 
b/fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/PaimonScanParams.java
index 809e371aeac..a05adb64f03 100644
--- 
a/fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/PaimonScanParams.java
+++ 
b/fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/PaimonScanParams.java
@@ -24,7 +24,9 @@ import org.apache.paimon.CoreOptions;
 import org.apache.paimon.Snapshot;
 import org.apache.paimon.options.ConfigOption;
 import org.apache.paimon.options.FallbackKey;
+import org.apache.paimon.options.Options;
 import org.apache.paimon.schema.TableSchema;
+import org.apache.paimon.table.ChainGroupReadTable;
 import org.apache.paimon.table.FallbackReadFileStoreTable;
 import org.apache.paimon.table.FileStoreTable;
 import org.apache.paimon.table.Table;
@@ -103,6 +105,13 @@ public final class PaimonScanParams {
 
     private static final Set<String> INHERITED_READ_STATE_KEYS = 
buildInheritedReadStateKeys();
 
+    private static final Set<String> SCHEMA_INDEPENDENT_READ_KEYS = 
ImmutableSet.<String>builder()
+            .addAll(INHERITED_READ_STATE_KEYS)
+            .add(CoreOptions.BRANCH.key())
+            .add(CoreOptions.SCAN_PRIMARY_BRANCH.key())
+            .add(CoreOptions.SCAN_FALLBACK_BRANCH.key())
+            .build();
+
     private static final Set<String> TIME_TRAVEL_SELECTOR_KEYS = 
ImmutableSet.of(
             CoreOptions.SCAN_TIMESTAMP_MILLIS.key(),
             CoreOptions.SCAN_WATERMARK.key(),
@@ -204,13 +213,60 @@ public final class PaimonScanParams {
         }
         // Apply the relation override first, then normalize each physical 
planning branch. A
         // single combined copy would flatten independent fallback values into 
one sibling's cap.
-        Table effectiveTable = 
PaimonReaderOptions.runtimeSafeTable(table.copy(isolatedOptions));
+        Table effectiveTable = PaimonReaderOptions.runtimeSafeTable(
+                copyWithHistoricalSchema(table, isolatedOptions, options));
         // Validate after every copy so relation options participate in the 
documented
         // relation > catalog > physical precedence before the effective value 
is judged.
         PaimonReaderOptions.validateEffectiveTable(effectiveTable);
         return effectiveTable;
     }
 
+    static Table copyWithHistoricalSchema(Table table, Map<String, String> 
options) {
+        return copyWithHistoricalSchema(table, userOptions(options), options);
+    }
+
+    private static Table copyWithHistoricalSchema(
+            Table table, Map<String, String> dynamicOptions, Map<String, 
String> provenance) {
+        if (!(table instanceof FileStoreTable) || table instanceof 
ChainGroupReadTable
+                || PaimonTableDecorators.isChainTable(table)) {
+            // Chain groups have their own option filtering and are not 
ordinary fallback readers.
+            return table.copy(dynamicOptions);
+        }
+        if (table instanceof FallbackReadFileStoreTable) {
+            FallbackReadFileStoreTable pair = (FallbackReadFileStoreTable) 
table;
+            // Each branch has independent snapshot IDs. Keep the SDK's 
timestamp translation
+            // before restoring either schema; copying one historical schema 
onto the pair is invalid.
+            return PaimonTableDecorators.rebuildFallbackPair(pair,
+                    (FileStoreTable) copyWithHistoricalSchema(pair.wrapped(), 
dynamicOptions, provenance),
+                    (FileStoreTable) copyWithHistoricalSchema(pair.other(),
+                            PaimonTableDecorators.otherOptions(pair, 
dynamicOptions), provenance));
+        }
+        FileStoreTable dataTable = (FileStoreTable) table;
+        Map<String, String> effectiveOptions = new HashMap<>(table.options());
+        dynamicOptions.forEach((key, value) -> {
+            if (value == null) {
+                effectiveOptions.remove(key);
+            } else {
+                effectiveOptions.put(key, value);
+            }
+        });
+        Optional<Snapshot> snapshot;
+        try {
+            snapshot = 
TimeTravelUtil.tryTravelToSnapshot(Options.fromMap(effectiveOptions),
+                    dataTable.snapshotManager(), dataTable.tagManager());
+        } catch (Exception e) {
+            // Match the SDK's schema-selection fallback (for example a 
timestamp on an empty table).
+            // Its normal copy below still owns option validation and the 
eventual scan error.
+            return table.copy(dynamicOptions);
+        }
+        if (snapshot.isPresent() && snapshot.get().schemaId() != 
dataTable.schema().id()) {
+            // Paimon overlays current options onto historical columns. 
Restore the matching
+            // sequence/aggregation options first, or renamed fields fail 
before scan planning.
+            dataTable = restoreBoundSchema(dataTable, 
snapshot.get().schemaId(), provenance, "");
+        }
+        return dataTable.copy(dynamicOptions);
+    }
+
     public static FileStoreTable applyOptionsWithoutTimeTravel(
             FileStoreTable table, Map<String, String> options) {
         Map<String, String> tableOptions = userOptions(options);
@@ -282,6 +338,17 @@ public final class PaimonScanParams {
                 merged.put(key, value);
             }
         });
+        // Read selectors and branch topology belong to the resolved table, 
not its historical schema.
+        // Preserve absent keys too: reviving an old mode can invalidate a 
snapshot selector, while
+        // reviving an old branch policy can change fallback priority when 
reader caps rebuild the pair.
+        SCHEMA_INDEPENDENT_READ_KEYS.forEach(key -> {
+            String value = table.options().get(key);
+            if (value == null) {
+                merged.remove(key);
+            } else {
+                merged.put(key, value);
+            }
+        });
         // Explicit catalog overrides can equal old physical values; equality 
is not provenance.
         options.forEach((key, value) -> {
             if (key.startsWith(INTERNAL_PREFIX + "catalog-option.")) {
@@ -353,6 +420,11 @@ public final class PaimonScanParams {
     }
 
     public static Map<String, String> resolveOptions(Table table, Map<String, 
String> options) {
+        return resolveOptions(table, options, 
java.util.Collections.emptyMap());
+    }
+
+    static Map<String, String> resolveOptions(
+            Table table, Map<String, String> options, Map<String, String> 
catalogOptions) {
         validateOptions(options);
         if (!hasStartupOptions(options)) {
             return options;
@@ -388,7 +460,7 @@ public final class PaimonScanParams {
             return resolveFileCreationTime(options, fileStoreTable, 
creationTime);
         }
 
-        Table selectedTable = applyOptions(table, options);
+        Table selectedTable = applyOptions(table, withCatalogOptions(options, 
catalogOptions));
         if 
("compacted-full".equalsIgnoreCase(options.get(CoreOptions.SCAN_MODE.key()))) {
             Long snapshotId = compactedFullSnapshotId((FileStoreTable) 
selectedTable);
             return snapshotId == null
@@ -508,9 +580,13 @@ public final class PaimonScanParams {
     }
 
     private static Map<String, String> userOptions(Map<String, String> 
options) {
-        return options.entrySet().stream()
-                .filter(entry -> !entry.getKey().startsWith(INTERNAL_PREFIX))
-                .collect(Collectors.toMap(Map.Entry::getKey, 
Map.Entry::getValue));
+        Map<String, String> result = new HashMap<>();
+        options.forEach((key, value) -> {
+            if (!key.startsWith(INTERNAL_PREFIX)) {
+                result.put(key, value);
+            }
+        });
+        return result;
     }
 
     /**
diff --git 
a/fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/PaimonScanPlanProvider.java
 
b/fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/PaimonScanPlanProvider.java
index 6c5493765e1..548907b5249 100644
--- 
a/fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/PaimonScanPlanProvider.java
+++ 
b/fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/PaimonScanPlanProvider.java
@@ -361,10 +361,11 @@ public class PaimonScanPlanProvider implements 
ConnectorScanPlanProvider {
     }
 
     private <T> T withBoundSchemaAuthentication(PaimonTableHandle handle, 
Supplier<T> action) {
-        if (context == null || 
!PaimonScanParams.preservesBoundSchema(handle.getScanOptions())) {
+        if (context == null || (handle.getScanOptions().isEmpty()
+                && 
PaimonTableOptions.extractCompatible(catalogProps.getRaw()).isEmpty())) {
             return action.get();
         }
-        // Restoring a bound schema can read FileIO after table resolution has 
left the authenticated scope.
+        // Historical and statement schema restoration read FileIO after table 
resolution leaves its scope.
         try {
             return context.executeAuthenticated(action::get);
         } catch (RuntimeException e) {
@@ -376,20 +377,17 @@ public class PaimonScanPlanProvider implements 
ConnectorScanPlanProvider {
 
     private Map<String, String> effectiveScanOptions(PaimonTableHandle handle) 
{
         Map<String, String> options = handle.getScanOptions();
-        if (PaimonScanParams.preservesBoundSchema(options)) {
-            // Replayed catalogs already ignored legacy options during 
binding; scans must use the same policy.
-            return PaimonScanParams.withCatalogOptions(options,
-                    
PaimonTableOptions.extractCompatible(catalogProps.getRaw()));
-        }
-        return options;
+        // An explicit catalog value may equal latest's value but differ from 
the historical schema.
+        return PaimonScanParams.withCatalogOptions(options,
+                PaimonTableOptions.extractCompatible(catalogProps.getRaw()));
     }
 
     private Table applyScanOptions(PaimonTableHandle paimonHandle, Table 
table) {
         Map<String, String> scanOptions = effectiveScanOptions(paimonHandle);
         Table finalTable = table;
-        if (scanOptions != null && !scanOptions.isEmpty()
-                && !(paimonHandle.isSystemTable() && 
PaimonScanParams.preservesBoundSchema(scanOptions))) {
-            // Statement-fenced system wrappers are rebuilt below from their 
schema-bound source.
+        if (scanOptions != null && !scanOptions.isEmpty() && 
!paimonHandle.isSystemTable()) {
+            // System wrappers are rebuilt below from their schema-bound 
source. Copying the wrapper
+            // first would time-travel inside the SDK before historical field 
options can be restored.
             if (table instanceof FileStoreTable
                     && PaimonScanParams.preservesBoundSchema(scanOptions)) {
                 // A statement fence owns data visibility, not schema time 
travel. Reusing Table.copy
@@ -406,7 +404,8 @@ public class PaimonScanPlanProvider implements 
ConnectorScanPlanProvider {
                 // scan.snapshot-id/scan.mode here (the single Table.copy 
chokepoint shared by both the
                 // native/JNI scan path and the JNI serialized-table path) so 
a stale persisted pin on the
                 // base table cannot hijack incremental-between. 
Non-incremental pins pass through unchanged.
-                finalTable = 
table.copy(PaimonIncrementalScanParams.applyResetsIfIncremental(scanOptions));
+                finalTable = PaimonScanParams.copyWithHistoricalSchema(table,
+                        
PaimonIncrementalScanParams.applyResetsIfIncremental(scanOptions));
             }
         }
         finalTable = PaimonReaderOptions.runtimeSafeTable(finalTable);
@@ -1799,10 +1798,12 @@ public class PaimonScanPlanProvider implements 
ConnectorScanPlanProvider {
     }
 
     private static FileStoreTable rebuildWithoutCatalogLoader(FileStoreTable 
branch) {
-        // The factory evaluates scan.snapshot-id and can rewind a schema-only 
ALTER. Restore
-        // the already-bound schema after removing the loader so JNI reads the 
planned field ids.
+        // The factory invokes copy(Map) before returning, so selectors can 
rewind a schema-only
+        // ALTER and fail field-option validation before the bound schema can 
be restored.
+        Map<String, String> factoryOptions = new 
HashMap<>(branch.schema().options());
+        
PaimonScanParams.inheritedReadStateKeys().forEach(factoryOptions::remove);
         return FileStoreTableFactory.createWithoutFallbackBranch(
-                branch.fileIO(), branch.location(), branch.schema(), new 
Options(),
+                branch.fileIO(), branch.location(), 
branch.schema().copy(factoryOptions), new Options(),
                 CatalogEnvironment.empty()).copy(branch.schema());
     }
 
diff --git 
a/fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/PaimonTableDecorators.java
 
b/fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/PaimonTableDecorators.java
index 48e2e11ad94..f6f17e34b3c 100644
--- 
a/fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/PaimonTableDecorators.java
+++ 
b/fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/PaimonTableDecorators.java
@@ -23,6 +23,8 @@ import org.apache.paimon.table.FallbackReadFileStoreTable;
 import org.apache.paimon.table.FileStoreTable;
 import org.apache.paimon.table.Table;
 
+import java.util.Map;
+
 /**
  * The one place that knows how Paimon stacks {@link DelegatedFileStoreTable} 
decorators on a loaded
  * table, and how far they may be peeled off.
@@ -45,6 +47,21 @@ final class PaimonTableDecorators {
         return new FallbackReadFileStoreTable(wrapped, other, 
PaimonReaderOptions.isWrappedFirst(pair));
     }
 
+    static Map<String, String> otherOptions(FallbackReadFileStoreTable pair, 
Map<String, String> options) {
+        return new FallbackOptions(pair).rewriteOtherOptions(options);
+    }
+
+    private static final class FallbackOptions extends 
FallbackReadFileStoreTable {
+        private FallbackOptions(FallbackReadFileStoreTable pair) {
+            super(pair.wrapped(), pair.other(), 
PaimonReaderOptions.isWrappedFirst(pair));
+        }
+
+        @Override
+        public Map<String, String> rewriteOtherOptions(Map<String, String> 
options) {
+            return super.rewriteOtherOptions(options);
+        }
+    }
+
     /**
      * Whether {@code table} reads as a chain table: a fallback pair whose 
other branch is a
      * {@link ChainGroupReadTable}. A branch of a chain table read on its own 
is a plain table.
diff --git 
a/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonHistoricalSchemaOptionsTest.java
 
b/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonHistoricalSchemaOptionsTest.java
new file mode 100644
index 00000000000..3ab8f855e38
--- /dev/null
+++ 
b/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonHistoricalSchemaOptionsTest.java
@@ -0,0 +1,452 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package org.apache.doris.connector.paimon;
+
+import org.apache.doris.connector.spi.mvcc.ConnectorMvccSnapshot;
+import org.apache.doris.connector.spi.mvcc.ConnectorTimeTravelSpec;
+
+import org.apache.paimon.catalog.Catalog;
+import org.apache.paimon.catalog.FileSystemCatalog;
+import org.apache.paimon.catalog.Identifier;
+import org.apache.paimon.data.GenericRow;
+import org.apache.paimon.data.InternalRow;
+import org.apache.paimon.fs.local.LocalFileIO;
+import org.apache.paimon.options.Options;
+import org.apache.paimon.reader.RecordReader;
+import org.apache.paimon.schema.Schema;
+import org.apache.paimon.schema.SchemaChange;
+import org.apache.paimon.table.CatalogEnvironment;
+import org.apache.paimon.table.FallbackReadFileStoreTable;
+import org.apache.paimon.table.FileStoreTable;
+import org.apache.paimon.table.FileStoreTableFactory;
+import org.apache.paimon.table.Table;
+import org.apache.paimon.table.sink.BatchTableCommit;
+import org.apache.paimon.table.sink.BatchTableWrite;
+import org.apache.paimon.table.sink.BatchWriteBuilder;
+import org.apache.paimon.table.source.Split;
+import org.apache.paimon.table.system.SystemTableLoader;
+import org.apache.paimon.types.DataTypes;
+import org.apache.paimon.utils.InstantiationUtil;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+public class PaimonHistoricalSchemaOptionsTest {
+    @Test
+    public void snapshotReadDoesNotRestoreRemovedStartupMode(@TempDir Path 
warehouse) throws Exception {
+        try (Catalog catalog = new FileSystemCatalog(LocalFileIO.create(),
+                new org.apache.paimon.fs.Path(warehouse.toUri()))) {
+            catalog.createDatabase("db", false);
+            Identifier id = Identifier.create("db", "removed_startup_mode");
+            catalog.createTable(id, Schema.newBuilder().column("id", 
DataTypes.INT().notNull())
+                    .column("value", DataTypes.INT()).primaryKey("id")
+                    .option("bucket", "1").option("file.format", "parquet")
+                    .option("scan.mode", "latest-full").build(), false);
+            append((FileStoreTable) catalog.getTable(id), GenericRow.of(1, 
10));
+            catalog.alterTable(id, 
Collections.singletonList(SchemaChange.removeOption("scan.mode")), false);
+            Table latest = catalog.getTable(id);
+            Assertions.assertFalse(latest.options().containsKey("scan.mode"));
+            Map<String, String> selector = 
Collections.singletonMap("scan.snapshot-id", "1");
+            // Removing a startup mode must remain effective when a historical 
schema is restored.
+            Assertions.assertEquals(Collections.singletonList("1:10"), 
readRows(latest.copy(selector)));
+            Table historical = 
PaimonScanParams.copyWithHistoricalSchema(latest, selector);
+            Assertions.assertEquals(Collections.singletonList("1:10"), 
readRows(historical));
+        }
+    }
+
+    @Test
+    public void historicalFallbackPriorityIsStableAt64(@TempDir Path 
warehouse) throws Exception {
+        checkHistoricalFallbackPriority(warehouse, 64);
+    }
+
+    @Test
+    public void historicalFallbackPriorityIsStableAt128(@TempDir Path 
warehouse) throws Exception {
+        checkHistoricalFallbackPriority(warehouse, 128);
+    }
+
+    private void checkHistoricalFallbackPriority(Path warehouse, int capacity) 
throws Exception {
+        try (Catalog catalog = new FileSystemCatalog(LocalFileIO.create(),
+                new org.apache.paimon.fs.Path(warehouse.toUri()))) {
+            catalog.createDatabase("db", false);
+            Identifier id = Identifier.create("db", "branch_priority_history");
+            catalog.createTable(id, Schema.newBuilder().column("id", 
DataTypes.INT().notNull())
+                    .column("value", 
DataTypes.INT().notNull()).primaryKey("id", "value").partitionKeys("id")
+                    .option("bucket", "1").option("file.format", 
"parquet").build(), false);
+            FileStoreTable original = (FileStoreTable) catalog.getTable(id);
+            original.createBranch("backup");
+            Identifier backupId = new Identifier("db", 
"branch_priority_history", "backup");
+            append((FileStoreTable) catalog.getTable(backupId), 
GenericRow.of(1, 20));
+            catalog.alterTable(id, 
Arrays.asList(SchemaChange.setOption("scan.primary-branch", "backup"),
+                    SchemaChange.setOption("scan.manifest.parallelism", 
"128")), false);
+            FallbackReadFileStoreTable primary = (FallbackReadFileStoreTable) 
catalog.getTable(id);
+            append(primary.wrapped(), GenericRow.of(1, 10));
+            Assertions.assertEquals(Collections.singletonList("1:20"), 
readRows(primary));
+            catalog.alterTable(id, 
Arrays.asList(SchemaChange.removeOption("scan.primary-branch"),
+                    SchemaChange.setOption("scan.fallback-branch", "backup")), 
false);
+            FallbackReadFileStoreTable latest = (FallbackReadFileStoreTable) 
catalog.getTable(id);
+            Assertions.assertEquals(Collections.singletonList("1:10"), 
readRows(latest));
+            Table historical = 
PaimonScanParams.copyWithHistoricalSchema(latest,
+                    Collections.singletonMap("scan.snapshot-id", "1"));
+            Assertions.assertEquals("backup", 
historical.options().get("scan.fallback-branch"));
+            
Assertions.assertFalse(historical.options().containsKey("scan.primary-branch"));
+            // The same overlapping partition must choose the same branch on 
different FE capacities.
+            Table normalized = 
PaimonReaderOptions.runtimeSafeTable(historical, capacity);
+            Assertions.assertEquals(Integer.toString(capacity), 
normalized.options().get("scan.manifest.parallelism"));
+            Assertions.assertEquals("backup", 
normalized.options().get("scan.fallback-branch"));
+            
Assertions.assertFalse(normalized.options().containsKey("scan.primary-branch"));
+            Assertions.assertEquals(Collections.singletonList("1:10"), 
readRows(normalized));
+        }
+    }
+
+    @Test
+    public void snapshotUsesHistoricalSequenceFieldAfterRename(@TempDir Path 
warehouse) throws Exception {
+        checkRenamedField(warehouse, false, "snapshot");
+    }
+
+    @Test
+    public void timestampUsesHistoricalSequenceFieldAfterRename(@TempDir Path 
warehouse) throws Exception {
+        checkRenamedField(warehouse, false, "timestamp");
+    }
+
+    @Test
+    public void tagUsesHistoricalSequenceFieldAfterRename(@TempDir Path 
warehouse) throws Exception {
+        checkRenamedField(warehouse, false, "tag");
+    }
+
+    @Test
+    public void optionsUsesHistoricalSequenceFieldAfterRename(@TempDir Path 
warehouse) throws Exception {
+        checkRenamedField(warehouse, false, "options");
+    }
+
+    @Test
+    public void currentReadUsesRenamedSequenceFieldFromWarmTable(@TempDir Path 
warehouse) throws Exception {
+        checkRenamedField(warehouse, false, "current");
+    }
+
+    @Test
+    public void snapshotUsesHistoricalAggregationFieldAfterRename(@TempDir 
Path warehouse) throws Exception {
+        checkRenamedField(warehouse, true, "snapshot");
+    }
+
+    @Test
+    public void timestampUsesHistoricalAggregationFieldAfterRename(@TempDir 
Path warehouse) throws Exception {
+        checkRenamedField(warehouse, true, "timestamp");
+    }
+
+    @Test
+    public void tagUsesHistoricalAggregationFieldAfterRename(@TempDir Path 
warehouse) throws Exception {
+        checkRenamedField(warehouse, true, "tag");
+    }
+
+    @Test
+    public void optionsUsesHistoricalAggregationFieldAfterRename(@TempDir Path 
warehouse) throws Exception {
+        checkRenamedField(warehouse, true, "options");
+    }
+
+    @Test
+    public void currentReadUsesRenamedAggregationFieldFromWarmTable(@TempDir 
Path warehouse) throws Exception {
+        checkRenamedField(warehouse, true, "current");
+    }
+
+    @Test
+    public void systemOptionsUsesHistoricalSequenceFieldAfterRename(@TempDir 
Path warehouse) throws Exception {
+        checkRenamedField(warehouse, false, "system");
+    }
+
+    @Test
+    public void historicalSchemaKeepsExplicitCatalogReaderOption(@TempDir Path 
warehouse) throws Exception {
+        try (Catalog catalog = new FileSystemCatalog(LocalFileIO.create(),
+                new org.apache.paimon.fs.Path(warehouse.toUri()))) {
+            catalog.createDatabase("db", false);
+            Identifier id = Identifier.create("db", "reader_history");
+            catalog.createTable(id, Schema.newBuilder().column("id", 
DataTypes.INT().notNull())
+                    .column("value", 
DataTypes.INT().notNull()).primaryKey("id", "value").partitionKeys("id")
+                    .option("bucket", "1").option("sequence.field", "value")
+                    .option("read.batch-size", "64").option("file.format", 
"parquet").build(), false);
+            FileStoreTable original = (FileStoreTable) catalog.getTable(id);
+            append(original, GenericRow.of(1, 10));
+            catalog.alterTable(id, 
Arrays.asList(SchemaChange.renameColumn("value", "renamed_value"),
+                    SchemaChange.setOption("read.batch-size", "32")), false);
+            Map<String, String> properties = 
Collections.singletonMap("paimon.table-option.read.batch-size", "32");
+            PaimonCatalogOps ops = new 
PaimonCatalogOps.CatalogBackedPaimonCatalogOps(
+                    catalog, Collections.singletonMap("read.batch-size", 
"32"));
+            PaimonCatalogProperties catalogProperties = 
PaimonCatalogProperties.of(properties);
+            PaimonConnectorMetadata metadata = new PaimonConnectorMetadata(
+                    ops, catalogProperties, new RecordingConnectorContext());
+            PaimonTableHandle handle = (PaimonTableHandle) 
metadata.getTableHandle(null, "db", "reader_history").get();
+            ConnectorMvccSnapshot snapshot = metadata.resolveTimeTravel(null, 
handle,
+                    ConnectorTimeTravelSpec.snapshotId("1")).get();
+            PaimonTableHandle pinned = (PaimonTableHandle) 
metadata.applySnapshot(null, handle, snapshot);
+            Assertions.assertEquals(Collections.singletonList("id=1"), 
metadata.listPartitionNames(null, pinned));
+            Assertions.assertTrue(metadata.getTableStatistics(null, handle, 
snapshot).isPresent());
+            Table scan = new PaimonScanPlanProvider(catalogProperties, 
ops).resolveScanTable(pinned);
+            // Equal current/catalog values still carry an explicit override 
of the historical default.
+            Assertions.assertEquals("32", 
scan.options().get("read.batch-size"));
+            Assertions.assertEquals(Collections.singletonList("1:10"), 
readRows(scan));
+        }
+    }
+
+    @Test
+    public void fallbackBranchesRestoreIndependentHistoricalOptions(@TempDir 
Path warehouse) throws Exception {
+        try (Catalog catalog = new FileSystemCatalog(LocalFileIO.create(),
+                new org.apache.paimon.fs.Path(warehouse.toUri()))) {
+            catalog.createDatabase("db", false);
+            Identifier id = Identifier.create("db", "fallback_history");
+            catalog.createTable(id, Schema.newBuilder().column("id", 
DataTypes.INT().notNull())
+                    .column("value", DataTypes.INT()).primaryKey("id")
+                    .option("bucket", "1").option("sequence.field", "value")
+                    .option("file.format", "parquet").build(), false);
+            FileStoreTable original = (FileStoreTable) catalog.getTable(id);
+            original.createBranch("backup");
+            Identifier backupId = new Identifier("db", "fallback_history", 
"backup");
+            FileStoreTable backup = (FileStoreTable) 
catalog.getTable(backupId);
+            append(backup, GenericRow.of(2, 8));
+            append(original, GenericRow.of(1, 10));
+            catalog.alterTable(backupId, Collections.singletonList(
+                    SchemaChange.renameColumn("value", "renamed_value")), 
false);
+            catalog.alterTable(id, Collections.singletonList(
+                    SchemaChange.renameColumn("value", "renamed_value")), 
false);
+            FileStoreTable latest = (FileStoreTable) catalog.getTable(id);
+            FileStoreTable latestBackup = (FileStoreTable) 
catalog.getTable(backupId);
+            append(latestBackup, GenericRow.of(2, 18));
+            append(latest, GenericRow.of(1, 20));
+            FallbackReadFileStoreTable pair = new 
FallbackReadFileStoreTable(latest, latestBackup, false);
+            Map<String, String> options = new HashMap<>();
+            options.put("scan.snapshot-id", "1");
+            options.put("branch", "main");
+            FallbackReadFileStoreTable historical = 
(FallbackReadFileStoreTable)
+                    PaimonScanParams.copyWithHistoricalSchema(pair, options);
+            Assertions.assertEquals("value", 
historical.wrapped().options().get("sequence.field"));
+            Assertions.assertEquals("value", 
historical.other().options().get("sequence.field"));
+            Assertions.assertEquals("backup", 
historical.other().coreOptions().branch());
+            Assertions.assertSame(latest.catalogEnvironment(), 
historical.wrapped().catalogEnvironment());
+            Assertions.assertSame(latestBackup.catalogEnvironment(), 
historical.other().catalogEnvironment());
+            Assertions.assertEquals(Collections.singletonList("1:10"), 
readRows(historical.wrapped()));
+            Assertions.assertEquals(Collections.singletonList("2:8"), 
readRows(historical.other()));
+        }
+    }
+
+    @Test
+    public void timestampOnEmptyTableKeepsSdkBehavior(@TempDir Path warehouse) 
throws Exception {
+        try (Catalog catalog = new FileSystemCatalog(LocalFileIO.create(),
+                new org.apache.paimon.fs.Path(warehouse.toUri()))) {
+            catalog.createDatabase("db", false);
+            Identifier id = Identifier.create("db", "empty_history");
+            catalog.createTable(id, Schema.newBuilder().column("id", 
DataTypes.INT()).build(), false);
+            Table table = catalog.getTable(id);
+            Map<String, String> options = 
Collections.singletonMap("scan.timestamp-millis", "1");
+            Table selected = PaimonScanParams.copyWithHistoricalSchema(table, 
options);
+            Assertions.assertEquals(table.copy(options).rowType(), 
selected.rowType());
+            IllegalArgumentException expected = Assertions.assertThrows(
+                    IllegalArgumentException.class, () -> 
readRows(table.copy(options)));
+            IllegalArgumentException actual = Assertions.assertThrows(
+                    IllegalArgumentException.class, () -> readRows(selected));
+            Assertions.assertEquals(expected.getMessage(), 
actual.getMessage());
+        }
+    }
+
+    @Test
+    public void 
catalogBackedSchemaOnlyRenameKeepsStatementSchemaOnBackend(@TempDir Path 
warehouse)
+            throws Exception {
+        checkCatalogBackedTransport(warehouse, true, false);
+    }
+
+    @Test
+    public void 
catalogBackedHistoricalReadKeepsHistoricalSchemaOnBackend(@TempDir Path 
warehouse)
+            throws Exception {
+        checkCatalogBackedTransport(warehouse, false, false);
+    }
+
+    @Test
+    public void 
catalogBackedAggregationRenameKeepsStatementSchemaOnBackend(@TempDir Path 
warehouse)
+            throws Exception {
+        checkCatalogBackedTransport(warehouse, true, true);
+    }
+
+    @Test
+    public void 
catalogBackedAggregationHistoryKeepsHistoricalSchemaOnBackend(@TempDir Path 
warehouse)
+            throws Exception {
+        checkCatalogBackedTransport(warehouse, false, true);
+    }
+
+    private void checkCatalogBackedTransport(Path warehouse, boolean current, 
boolean aggregate) throws Exception {
+        try (Catalog catalog = new FileSystemCatalog(LocalFileIO.create(),
+                new org.apache.paimon.fs.Path(warehouse.toUri()))) {
+            catalog.createDatabase("db", false);
+            Identifier id = Identifier.create("db", "transport_history");
+            Schema.Builder schema = Schema.newBuilder().column("id", 
DataTypes.INT().notNull())
+                    .column("value", DataTypes.INT()).primaryKey("id")
+                    .option("bucket", "1").option("file.format", "parquet");
+            if (aggregate) {
+                schema.option("merge-engine", 
"aggregation").option("fields.value.aggregate-function", "sum");
+            } else {
+                schema.option("sequence.field", "value");
+            }
+            catalog.createTable(id, schema.build(), false);
+            FileStoreTable original = (FileStoreTable) catalog.getTable(id);
+            append(original, GenericRow.of(1, 10));
+            catalog.alterTable(id, Collections.singletonList(
+                    SchemaChange.renameColumn("value", "renamed_value")), 
false);
+            FileStoreTable latest = (FileStoreTable) catalog.getTable(id);
+            CatalogEnvironment environment = new CatalogEnvironment(id, null, 
() -> catalog,
+                    null, null, null, false, false);
+            latest = 
FileStoreTableFactory.createWithoutFallbackBranch(latest.fileIO(), 
latest.location(),
+                    latest.schema(), new Options(), environment);
+            PaimonCatalogOps ops = new 
PaimonCatalogOps.CatalogBackedPaimonCatalogOps(catalog);
+            PaimonCatalogProperties properties = 
PaimonCatalogProperties.of(Collections.emptyMap());
+            PaimonConnectorMetadata metadata = new PaimonConnectorMetadata(
+                    ops, properties, new RecordingConnectorContext());
+            PaimonTableHandle handle = new PaimonTableHandle("db", 
"transport_history",
+                    Collections.emptyList(), Collections.emptyList());
+            handle.setPaimonTable(latest);
+            ConnectorMvccSnapshot snapshot = current ? 
metadata.beginQuerySnapshot(null, handle).get()
+                    : metadata.resolveTimeTravel(null, handle, 
ConnectorTimeTravelSpec.snapshotId("1")).get();
+            PaimonTableHandle pinned = (PaimonTableHandle) 
metadata.applySnapshot(null, handle, snapshot);
+            PaimonScanPlanProvider provider = new 
PaimonScanPlanProvider(properties, ops);
+            FileStoreTable scan = (FileStoreTable) 
provider.resolveScanTable(pinned);
+            String expectedName = current ? "renamed_value" : "value";
+            
Assertions.assertNotNull(scan.catalogEnvironment().catalogLoader());
+            Assertions.assertEquals(Arrays.asList("id", expectedName), 
scan.rowType().getFieldNames());
+            // A filesystem-only fixture skips loader removal; serialize the 
catalog-backed path too.
+            FileStoreTable backend = InstantiationUtil.deserializeObject(
+                    
InstantiationUtil.serializeObject(provider.tableForBackend(pinned, scan)),
+                    getClass().getClassLoader());
+            
Assertions.assertNull(backend.catalogEnvironment().catalogLoader());
+            Assertions.assertEquals(scan.schema(), backend.schema());
+            Assertions.assertEquals("1", 
backend.options().get("scan.snapshot-id"));
+            if (aggregate) {
+                Assertions.assertEquals("sum", backend.options().get("fields." 
+ expectedName + ".aggregate-function"));
+            } else {
+                Assertions.assertEquals(expectedName, 
backend.options().get("sequence.field"));
+            }
+            Assertions.assertEquals(Collections.singletonList("1:10"), 
readRows(backend));
+        }
+    }
+
+    private void checkRenamedField(Path warehouse, boolean aggregate, String 
selector) throws Exception {
+        try (Catalog catalog = new FileSystemCatalog(LocalFileIO.create(),
+                new org.apache.paimon.fs.Path(warehouse.toUri()))) {
+            catalog.createDatabase("db", false);
+            Identifier id = Identifier.create("db", "field_history");
+            Schema.Builder schema = Schema.newBuilder().column("id", 
DataTypes.INT().notNull())
+                    .column("value", DataTypes.INT()).primaryKey("id")
+                    .option("bucket", "1").option("file.format", "parquet");
+            if (aggregate) {
+                schema.option("merge-engine", "aggregation")
+                        .option("fields.value.aggregate-function", "sum");
+            } else {
+                schema.option("sequence.field", "value");
+            }
+            catalog.createTable(id, schema.build(), false);
+            FileStoreTable warm = (FileStoreTable) catalog.getTable(id);
+            append(warm, GenericRow.of(1, 10));
+            // A second value proves that the configured merge rule remains 
effective during reads.
+            append(warm, GenericRow.of(1, 5));
+            long snapshotId = warm.snapshotManager().latestSnapshotId();
+            long timestamp = 
warm.snapshotManager().snapshot(snapshotId).timeMillis();
+            warm.createTag("before_rename", snapshotId);
+            // Read-optimized views can omit un-compacted files; preserve 
their real SDK baseline.
+            List<String> systemBaseline = "system".equals(selector)
+                    ? readRows(SystemTableLoader.load("ro", warm)) : 
Collections.emptyList();
+            catalog.alterTable(id, Collections.singletonList(
+                    SchemaChange.renameColumn("value", "renamed_value")), 
false);
+            FileStoreTable latest = (FileStoreTable) catalog.getTable(id);
+            append(latest, GenericRow.of(1, 20));
+            String historicalRow = aggregate ? "1:15" : "1:10";
+            String latestRow = aggregate ? "1:35" : "1:20";
+            Assertions.assertEquals(Collections.singletonList(latestRow), 
readRows(latest));
+
+            PaimonCatalogOps ops = new 
PaimonCatalogOps.CatalogBackedPaimonCatalogOps(catalog);
+            PaimonCatalogProperties properties = 
PaimonCatalogProperties.of(Collections.emptyMap());
+            PaimonConnectorMetadata metadata = new PaimonConnectorMetadata(
+                    ops, properties, new RecordingConnectorContext());
+            PaimonTableHandle handle = new PaimonTableHandle("db", 
"field_history",
+                    Collections.emptyList(), Collections.emptyList());
+            boolean current = "current".equals(selector);
+            handle.setPaimonTable(current ? warm : latest);
+            // Current reads rebind a warm table; explicit selectors must 
restore historical field options.
+            ConnectorMvccSnapshot snapshot = current
+                    ? metadata.beginQuerySnapshot(null, handle).get()
+                    : metadata.resolveTimeTravel(null, handle, 
selector(selector, snapshotId, timestamp)).get();
+            if ("system".equals(selector)) {
+                handle = (PaimonTableHandle) metadata.getSysTableHandle(null, 
handle, "ro").get();
+            }
+            Assertions.assertEquals(current ? "renamed_value" : "value",
+                    metadata.getTableSchema(null, handle, 
snapshot).getColumns().get(1).getName());
+            PaimonTableHandle pinned = (PaimonTableHandle) 
metadata.applySnapshot(null, handle, snapshot);
+            PaimonScanPlanProvider provider = new 
PaimonScanPlanProvider(properties, ops);
+            Table scan = provider.resolveScanTable(pinned);
+            String expectedName = current ? "renamed_value" : "value";
+            String expectedRow = current ? latestRow : historicalRow;
+            Assertions.assertEquals(Arrays.asList("id", expectedName), 
scan.rowType().getFieldNames());
+            if (aggregate) {
+                Assertions.assertEquals("sum", scan.options().get("fields." + 
expectedName + ".aggregate-function"));
+            } else {
+                Assertions.assertEquals(expectedName, 
scan.options().get("sequence.field"));
+            }
+            List<String> expectedRows = "system".equals(selector)
+                    ? systemBaseline : Collections.singletonList(expectedRow);
+            Assertions.assertEquals(expectedRows, readRows(scan));
+            Assertions.assertEquals(expectedRows, 
readRows(provider.tableForBackend(pinned, scan)));
+        }
+    }
+
+    private static ConnectorTimeTravelSpec selector(String selector, long 
snapshotId, long timestamp) {
+        switch (selector) {
+            case "snapshot":
+                return 
ConnectorTimeTravelSpec.snapshotId(Long.toString(snapshotId));
+            case "timestamp":
+                return 
ConnectorTimeTravelSpec.timestamp(Long.toString(timestamp), true);
+            case "tag":
+                return ConnectorTimeTravelSpec.tag("before_rename");
+            case "system":
+            case "options":
+                return ConnectorTimeTravelSpec.options(
+                        Collections.singletonMap("scan.snapshot-id", 
Long.toString(snapshotId)));
+            default:
+                throw new IllegalArgumentException("Unexpected test selector: 
" + selector);
+        }
+    }
+
+    private static void append(FileStoreTable table, GenericRow row) throws 
Exception {
+        BatchWriteBuilder builder = table.newBatchWriteBuilder();
+        try (BatchTableWrite write = builder.newWrite(); BatchTableCommit 
commit = builder.newCommit()) {
+            write.write(row);
+            commit.commit(write.prepareCommit());
+        }
+    }
+
+    private static List<String> readRows(Table table) throws Exception {
+        List<String> rows = new ArrayList<>();
+        for (Split split : table.newReadBuilder().newScan().plan().splits()) {
+            try (RecordReader<InternalRow> reader = 
table.newReadBuilder().newRead().createReader(split)) {
+                reader.forEachRemaining(row -> rows.add(row.getInt(0) + ":" + 
row.getInt(1)));
+            }
+        }
+        Collections.sort(rows);
+        return rows;
+    }
+}
diff --git 
a/regression-test/data/external_table_p0/paimon/test_paimon_renamed_field_time_travel.out
 
b/regression-test/data/external_table_p0/paimon/test_paimon_renamed_field_time_travel.out
new file mode 100644
index 00000000000..881f364cfa0
--- /dev/null
+++ 
b/regression-test/data/external_table_p0/paimon/test_paimon_renamed_field_time_travel.out
@@ -0,0 +1,107 @@
+-- This file is automatically generated. You should know what you did if you 
want to edit this
+-- !sequence_schema_only_false --
+1      10      base
+2      20      retained
+
+-- !sequence_schema_only_true --
+1      10      base
+2      20      retained
+
+-- !aggregation_schema_only_false --
+1      10
+2      20
+
+-- !aggregation_schema_only_true --
+1      10
+2      20
+
+-- !sequence_latest_false --
+1      30      updated
+2      20      retained
+3      15      new
+
+-- !sequence_old_snapshot_false --
+1      10      base
+2      20      retained
+
+-- !sequence_new_snapshot_false --
+1      30      updated
+2      20      retained
+3      15      new
+
+-- !sequence_options_false --
+1      10      base
+2      20      retained
+
+-- !sequence_tag_false --
+1      10      base
+2      20      retained
+
+-- !aggregation_latest_false --
+1      22
+2      27
+
+-- !aggregation_old_snapshot_false --
+1      10
+2      20
+
+-- !aggregation_intermediate_snapshot_false --
+1      15
+2      23
+
+-- !aggregation_options_false --
+1      10
+2      20
+
+-- !aggregation_tag_false --
+1      10
+2      20
+
+-- !removed_scan_mode_false --
+1      10
+
+-- !sequence_latest_true --
+1      30      updated
+2      20      retained
+3      15      new
+
+-- !sequence_old_snapshot_true --
+1      10      base
+2      20      retained
+
+-- !sequence_new_snapshot_true --
+1      30      updated
+2      20      retained
+3      15      new
+
+-- !sequence_options_true --
+1      10      base
+2      20      retained
+
+-- !sequence_tag_true --
+1      10      base
+2      20      retained
+
+-- !aggregation_latest_true --
+1      22
+2      27
+
+-- !aggregation_old_snapshot_true --
+1      10
+2      20
+
+-- !aggregation_intermediate_snapshot_true --
+1      15
+2      23
+
+-- !aggregation_options_true --
+1      10
+2      20
+
+-- !aggregation_tag_true --
+1      10
+2      20
+
+-- !removed_scan_mode_true --
+1      10
+
diff --git 
a/regression-test/suites/external_table_p0/paimon/test_paimon_renamed_field_time_travel.groovy
 
b/regression-test/suites/external_table_p0/paimon/test_paimon_renamed_field_time_travel.groovy
new file mode 100644
index 00000000000..5151cc45934
--- /dev/null
+++ 
b/regression-test/suites/external_table_p0/paimon/test_paimon_renamed_field_time_travel.groovy
@@ -0,0 +1,207 @@
+// 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_renamed_field_time_travel", "p0,external,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_renamed_field_time_travel"
+    String dbName = "paimon_renamed_field_time_travel_db"
+
+    def latestSnapshotId = { String tableName ->
+        def rows = spark_paimon """
+            SELECT snapshot_id FROM paimon.${dbName}.`${tableName}\$snapshots`
+            ORDER BY snapshot_id DESC LIMIT 1
+        """
+        assertEquals(1, rows.size())
+        return rows[0][0].toString()
+    }
+
+    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'
+        )
+    """
+
+    try {
+        spark_paimon_multi """
+            CREATE DATABASE IF NOT EXISTS paimon.${dbName};
+            DROP TABLE IF EXISTS paimon.${dbName}.sequence_timeline;
+            CREATE TABLE paimon.${dbName}.sequence_timeline (
+                id INT, old_sequence BIGINT, payload STRING
+            ) USING paimon
+            TBLPROPERTIES (
+                'primary-key'='id',
+                'bucket'='1',
+                'file.format'='parquet',
+                'sequence.field'='old_sequence'
+            );
+            INSERT INTO paimon.${dbName}.sequence_timeline
+                VALUES (1, 10, 'base'), (2, 20, 'retained');
+        """
+        String sequenceBase = latestSnapshotId("sequence_timeline")
+        spark_paimon_multi """
+            CALL paimon.sys.create_tag(
+                table => '${dbName}.sequence_timeline', tag => 'sequence_base'
+            );
+            ALTER TABLE paimon.${dbName}.sequence_timeline
+                RENAME COLUMN old_sequence TO current_sequence;
+        """
+
+        sql """switch ${catalogName}"""
+        sql """use ${dbName}"""
+        sql """refresh table sequence_timeline"""
+        // A schema-only rename updates sequence.field without creating a data 
snapshot.
+        // Latest reads must keep those two pieces of metadata from the same 
schema.
+        assertEquals(sequenceBase, latestSnapshotId("sequence_timeline"))
+        [false, true].each { forceJni ->
+            sql """set force_jni_scanner=${forceJni}"""
+            "order_qt_sequence_schema_only_${forceJni}" """
+                SELECT id, current_sequence, payload FROM sequence_timeline 
ORDER BY id
+            """
+        }
+
+        spark_paimon """
+            INSERT INTO paimon.${dbName}.sequence_timeline VALUES
+                (1, 30, 'updated'), (2, 5, 'stale'), (3, 15, 'new')
+        """
+        String sequenceCurrent = latestSnapshotId("sequence_timeline")
+        sql """refresh table sequence_timeline"""
+
+        spark_paimon_multi """
+            DROP TABLE IF EXISTS paimon.${dbName}.aggregation_timeline;
+            CREATE TABLE paimon.${dbName}.aggregation_timeline (
+                id INT, original_total BIGINT
+            ) USING paimon
+            TBLPROPERTIES (
+                'primary-key'='id',
+                'bucket'='1',
+                'file.format'='parquet',
+                'merge-engine'='aggregation',
+                'fields.original_total.aggregate-function'='sum'
+            );
+            INSERT INTO paimon.${dbName}.aggregation_timeline VALUES (1, 10), 
(2, 20);
+        """
+        String aggregationBase = latestSnapshotId("aggregation_timeline")
+        spark_paimon_multi """
+            CALL paimon.sys.create_tag(
+                table => '${dbName}.aggregation_timeline', tag => 
'aggregation_base'
+            );
+            ALTER TABLE paimon.${dbName}.aggregation_timeline
+                RENAME COLUMN original_total TO intermediate_total;
+        """
+        sql """refresh catalog ${catalogName}"""
+        assertEquals(aggregationBase, latestSnapshotId("aggregation_timeline"))
+        [false, true].each { forceJni ->
+            sql """set force_jni_scanner=${forceJni}"""
+            "order_qt_aggregation_schema_only_${forceJni}" """
+                SELECT id, intermediate_total FROM aggregation_timeline ORDER 
BY id
+            """
+        }
+        spark_paimon """
+            INSERT INTO paimon.${dbName}.aggregation_timeline VALUES (1, 5), 
(2, 3)
+        """
+        String aggregationIntermediate = 
latestSnapshotId("aggregation_timeline")
+        spark_paimon_multi """
+            ALTER TABLE paimon.${dbName}.aggregation_timeline
+                RENAME COLUMN intermediate_total TO current_total;
+            INSERT INTO paimon.${dbName}.aggregation_timeline VALUES (1, 7), 
(2, 4);
+        """
+        sql """refresh catalog ${catalogName}"""
+
+        spark_paimon_multi """
+            DROP TABLE IF EXISTS paimon.${dbName}.scan_state_history;
+            CREATE TABLE paimon.${dbName}.scan_state_history (
+                id INT, value BIGINT
+            ) USING paimon
+            TBLPROPERTIES (
+                'primary-key'='id', 'bucket'='1', 'file.format'='parquet',
+                'scan.mode'='latest-full'
+            );
+            INSERT INTO paimon.${dbName}.scan_state_history VALUES (1, 10);
+        """
+        String scanStateSnapshot = latestSnapshotId("scan_state_history")
+        spark_paimon """
+            ALTER TABLE paimon.${dbName}.scan_state_history UNSET 
TBLPROPERTIES ('scan.mode')
+        """
+        sql """refresh catalog ${catalogName}"""
+
+        // Historical fields must use their historical sequence/aggregation 
options;
+        // latest options reference names that do not exist in the old schema.
+        [false, true].each { forceJni ->
+            sql """set force_jni_scanner=${forceJni}"""
+            "order_qt_sequence_latest_${forceJni}" """
+                SELECT id, current_sequence, payload FROM sequence_timeline 
ORDER BY id
+            """
+            "order_qt_sequence_old_snapshot_${forceJni}" """
+                SELECT id, old_sequence, payload
+                FROM sequence_timeline FOR VERSION AS OF ${sequenceBase} ORDER 
BY id
+            """
+            "order_qt_sequence_new_snapshot_${forceJni}" """
+                SELECT id, current_sequence, payload
+                FROM sequence_timeline FOR VERSION AS OF ${sequenceCurrent} 
ORDER BY id
+            """
+            "order_qt_sequence_options_${forceJni}" """
+                SELECT id, old_sequence, payload
+                FROM 
sequence_timeline@options('scan.snapshot-id'='${sequenceBase}')
+                ORDER BY id
+            """
+            "order_qt_sequence_tag_${forceJni}" """
+                SELECT id, old_sequence, payload
+                FROM sequence_timeline FOR VERSION AS OF 'sequence_base' ORDER 
BY id
+            """
+            "order_qt_aggregation_latest_${forceJni}" """
+                SELECT id, current_total FROM aggregation_timeline ORDER BY id
+            """
+            "order_qt_aggregation_old_snapshot_${forceJni}" """
+                SELECT id, original_total
+                FROM aggregation_timeline FOR VERSION AS OF ${aggregationBase} 
ORDER BY id
+            """
+            "order_qt_aggregation_intermediate_snapshot_${forceJni}" """
+                SELECT id, intermediate_total
+                FROM aggregation_timeline FOR VERSION AS OF 
${aggregationIntermediate} ORDER BY id
+            """
+            "order_qt_aggregation_options_${forceJni}" """
+                SELECT id, original_total
+                FROM 
aggregation_timeline@options('scan.snapshot-id'='${aggregationBase}')
+                ORDER BY id
+            """
+            "order_qt_aggregation_tag_${forceJni}" """
+                SELECT id, original_total
+                FROM aggregation_timeline@tag(aggregation_base) ORDER BY id
+            """
+            // Historical schema restoration must not revive a removed startup 
mode.
+            "order_qt_removed_scan_mode_${forceJni}" """
+                SELECT id, value FROM scan_state_history
+                FOR VERSION AS OF ${scanStateSnapshot} ORDER BY id
+            """
+        }
+    } finally {
+        sql """set force_jni_scanner=false"""
+    }
+}


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

Reply via email to