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

CalvinKirs 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 687500b3358 [fix](paimon) Read Paimon tables on BE without 
reconstructing the catalog metastore (#65867)
687500b3358 is described below

commit 687500b3358a3bcc01ca095e1371a8d1f9a51fd4
Author: Mingyu Chen (Rayner) <[email protected]>
AuthorDate: Fri Jul 31 09:48:55 2026 +0800

    [fix](paimon) Read Paimon tables on BE without reconstructing the catalog 
metastore (#65867)
    
    ### What problem does this PR solve?
    
    Reading a Paimon table over a metastore-backed or REST catalog (HMS /
    DLF) fails on
    BE whenever the JNI reader is used — most visibly system tables (e.g.
    `$snapshots`,
    `$files`), which always go through JNI, and also branch / time-travel /
    incremental
    reads. Two failures were observed, both caused by the BE rebuilding a
    catalog it does
    not need:
    
    ```
    # HMS catalog
    [JNI_ERROR] NoClassDefFoundError: org/apache/hadoop/hive/conf/HiveConf
    # DLF (REST) catalog
    [JNI_ERROR] ClassNotFoundException: 
com.aliyun.datalake.metastore.hive2.ProxyMetaStoreClient
    ```
    
    **Root cause.** A table loaded from a metastore-backed Paimon catalog
    carries a Paimon
    `CatalogLoader` (e.g. `HiveCatalogLoader`) in its `CatalogEnvironment`.
    When FE
    serializes that table to BE, `SnapshotManager#latestSnapshotId` resolves
    the latest
    snapshot through the catalog's `SnapshotLoader`, which on BE
    reconstructs the catalog's
    metastore client and its whole Hive / DLF-REST stack — even though the
    BE only reads
    (via FE-resolved splits and the object store) and the snapshots already
    live there.
    This was previously masked by `java-udf`'s ~122MB `hive-catalog-shade`
    on BE's shared
    classpath; #65733 replaced it with the slim `hive-udf-shade`, exposing
    the dependency.
    
    **Fix 1 — serialize a catalog-less table to the BE (`PaimonScanNode`).**
    - data table: rebuild via `FileStoreTableFactory` with an empty
    `CatalogEnvironment`.
    A `FileStoreTable` is fully defined by fileIO / location / schema, and
    its dynamic
    options (time travel, incremental) are already merged into the schema by
    `copy(...)`,
      so nothing is lost except the catalog loader.
    - system table: rebuild it over such a catalog-less data table via
    `SystemTableLoader`.
    
    With no catalog loader, `SnapshotManager#latestSnapshotId` lists the
    snapshot directory
    on the filesystem instead of calling the metastore, so the BE never
    reconstructs the
    catalog and no longer needs any Hive / metastore classes.
    
    **Fix 2 — co-locate the Paimon FileIO plugins with `paimon-scanner`
    (packaging).**
    Reading the snapshot from the filesystem makes the BE materialize the
    object-store
    `FileIO` lazily via `FileIO.get()` →
    `ServiceLoader.load(FileIOLoader.class, ...)`,
    which eagerly instantiates every registered provider. The OSS/S3 plugins
    (`paimon-s3` → `S3Loader`, `paimon-jindo` → `JindoLoader`) were bundled
    in
    `preload-extensions`, on BE's JVM system (app) classpath, but the
    `org.apache.paimon.fs.FileIOLoader` interface they implement ships in
    `paimon-common`,
    bundled only in paimon-scanner's own `JniScannerClassLoader`. That
    loader is
    parent-first, so the app classloader defines `S3Loader` / `JindoLoader`
    and cannot
    resolve the child-only `FileIOLoader` → `NoClassDefFoundError:
    FileIOLoader`, which
    aborts discovery. Moving the two plugins into `paimon-scanner` puts the
    whole FileIO
    SPI (interface + all providers) in one classloader; the Jindo SDK stays
    on the app
    classpath (`start_be.sh` adds `jindofs` to `DORIS_CLASSPATH`) and is
    still reachable
    via parent delegation.
    
    Regression tests to re-run:
    `io.trino.tests.product.paimon.TestPaimonSparkCompatibility`
    (system-table reads) and
    `external_table_p2/paimon/test_paimon_dlf_rest_catalog`.
    
    ### Release note
    
    Fix Paimon reads (system tables, branch / time-travel / incremental)
    failing on BE over
    metastore-backed or REST catalogs (HMS / DLF) with
    `NoClassDefFoundError`
    (`HiveConf` / `FileIOLoader`) or `ClassNotFoundException:
    ProxyMetaStoreClient`.
    
    ### Check List (For Author)
    
    - Test <!-- At least one of them must be included. -->
        - [ ] Regression test
        - [ ] Unit Test
        - [x] Manual test (add detailed scripts or steps below)
        - [ ] No need to test or manual test. Explain why:
    
    - Behavior changed:
        - [x] No.
        - [ ] Yes.
    
    - Does this need documentation?
        - [x] No.
        - [ ] Yes.
    
    ---------
    
    Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
---
 build.sh                                           |  31 +
 fe/be-java-extensions/paimon-scanner/pom.xml       |  80 +--
 fe/be-java-extensions/preload-extensions/pom.xml   |  23 +-
 .../connector/paimon/PaimonConnectorMetadata.java  |  68 +-
 .../paimon/PaimonIncrementalScanParams.java        |  88 +++
 .../doris/connector/paimon/PaimonScanParams.java   |  27 +
 .../connector/paimon/PaimonScanPlanProvider.java   | 253 ++++++-
 .../connector/paimon/PaimonTableDecorators.java    |  63 ++
 .../doris/connector/paimon/PaimonTableHandle.java  |  37 +
 .../paimon/PaimonBackendBoundTableTest.java        | 762 +++++++++++++++++++++
 10 files changed, 1342 insertions(+), 90 deletions(-)

diff --git a/build.sh b/build.sh
index d5e2f65178c..36f72ef5622 100755
--- a/build.sh
+++ b/build.sh
@@ -1260,6 +1260,37 @@ EOF
         fi
     done        
 
+    # Guard the Paimon FileIO SPI classloader boundary on the built artifacts. 
The FileIOLoader
+    # interface (paimon-common) and every provider implementing it must sit in 
the same jar:
+    # JniScannerClassLoader delegates parent-first, so a provider left on the 
shared
+    # preload-extensions (JVM app) classpath cannot resolve the child-only 
interface and
+    # ServiceLoader discovery aborts at runtime with "NoClassDefFoundError: 
FileIOLoader". Unit
+    # tests all run in one classloader and cannot reproduce that, so assert it 
on the packages.
+    
PAIMON_SCANNER_JAR="${BE_JAVA_EXTENSIONS_DIR}/paimon-scanner/paimon-scanner-jar-with-dependencies.jar"
+    
PRELOAD_JAR="${BE_JAVA_EXTENSIONS_DIR}/preload-extensions/preload-extensions-jar-with-dependencies.jar"
+    if [[ -f "${PAIMON_SCANNER_JAR}" ]]; then
+        # Tolerate a missing entry (unzip exits 11) so the message below is 
what the build prints.
+        FILE_IO_SERVICES="$(unzip -p "${PAIMON_SCANNER_JAR}" \
+            META-INF/services/org.apache.paimon.fs.FileIOLoader 2>/dev/null || 
true)"
+        for loader in org.apache.paimon.s3.S3Loader 
org.apache.paimon.jindo.JindoLoader; do
+            if ! echo "${FILE_IO_SERVICES}" | grep -q -x "${loader}"; then
+                echo "ERROR: ${loader} is missing from 
META-INF/services/org.apache.paimon.fs.FileIOLoader"
+                echo "       in ${PAIMON_SCANNER_JAR}. Paimon object-store 
reads on BE would fail;"
+                echo "       keep the FileIO plugins bundled in 
paimon-scanner."
+                exit 1
+            fi
+        done
+    fi
+    # No "grep -q" here: it exits on the first match and SIGPIPEs unzip 
halfway through this jar's
+    # 120k-entry listing, which under "set -o pipefail" makes the pipeline 141 
and silently skips
+    # the error below - exactly when a provider did leak in. Let grep consume 
the whole listing.
+    if [[ -f "${PRELOAD_JAR}" ]] &&
+        unzip -l "${PRELOAD_JAR}" | grep -E 'org/apache/paimon/(s3|jindo)/' 
>/dev/null; then
+        echo "ERROR: ${PRELOAD_JAR} bundles a Paimon FileIOLoader provider. It 
would be defined by"
+        echo "       the JVM app classloader, which carries no FileIOLoader 
interface."
+        exit 1
+    fi
+
     # Third-party filesystem jars (JuiceFS, JindoFS) are packaged by 
post-build.sh
     bash "${DORIS_HOME}/post-build.sh" --be --output "${DORIS_OUTPUT}"
 
diff --git a/fe/be-java-extensions/paimon-scanner/pom.xml 
b/fe/be-java-extensions/paimon-scanner/pom.xml
index 33b46aa0704..fa7c27e4e98 100644
--- a/fe/be-java-extensions/paimon-scanner/pom.xml
+++ b/fe/be-java-extensions/paimon-scanner/pom.xml
@@ -56,74 +56,34 @@ under the License.
             <artifactId>paimon-hive-connector-3.1</artifactId>
         </dependency>
 
-        <!--
-          Hive runtime classes required by the Paimon Hive connector on the BE 
side.
-          paimon-hive-connector-3.1 marks hive-exec as `provided`, and only 
bundles
-          relocated hive classes under org.apache.paimon.shade.*; the real
-          org.apache.hadoop.hive.conf.HiveConf (referenced by
-          org.apache.paimon.hive.SerializableHiveConf when a HMS-backed Paimon 
table
-          is deserialized, and by HiveCatalog.createHiveConf) is NOT packaged.
-          It used to be provided accidentally by java-udf's hive-catalog-shade 
on the
-          shared BE classpath, but #65733 replaced that with the slim 
hive-udf-shade,
-          so paimon-scanner must now carry HiveConf itself. Version is pinned 
to
-          ${hive.version} (3.1.x) to match the Hive 3.1 the connector was 
built against.
-          The transitive trees (jetty/orc/ant/log4j bridges/hadoop/...) are 
pruned:
-          HiveConf's construction closure lives entirely inside hive-common, 
shims/Utils
-          (used only by the lazy HiveConf#getUser) lives in hive-shims-common, 
and hadoop
-          is already on BE's shared classpath.
-        -->
         <dependency>
-            <groupId>org.apache.hive</groupId>
-            <artifactId>hive-common</artifactId>
-            <version>${hive.version}</version>
-            <exclusions>
-                <exclusion>
-                    <groupId>*</groupId>
-                    <artifactId>*</artifactId>
-                </exclusion>
-            </exclusions>
-        </dependency>
-        <dependency>
-            <groupId>org.apache.hive.shims</groupId>
-            <artifactId>hive-shims-common</artifactId>
-            <version>${hive.version}</version>
-            <exclusions>
-                <exclusion>
-                    <groupId>*</groupId>
-                    <artifactId>*</artifactId>
-                </exclusion>
-            </exclusions>
+            <groupId>org.apache.paimon</groupId>
+            <artifactId>paimon-format</artifactId>
         </dependency>
+
         <!--
-          For the same reason (hive-catalog-shade no longer on the shared 
classpath),
-          paimon-scanner must also carry the Hive metastore API classes. When 
BE
-          deserializes an HMS-backed Paimon table, 
org.apache.paimon.hive.HiveCatalogLoader
-          links org.apache.paimon.hive.HiveCatalog, which references a broad 
slice of
-          org.apache.hadoop.hive.metastore.api.* (Table, Database, Partition,
-          StorageDescriptor, SerDeInfo, FieldSchema, NoSuchObjectException,
-          UnknownTableException, ...). In Hive 3.x that whole thrift-generated 
`api`
-          package lives in hive-standalone-metastore (the `hive-metastore` 
artifact is a
-          thin shim and does NOT contain it). Transitives are pruned like the 
two jars
-          above: the api classes only extend org.apache.thrift.* (provided by 
java-common's
-          libthrift on the shared classpath, exactly as under the old 
hive-catalog-shade)
-          and java stdlib; datanucleus/orc/hadoop/etc. are only needed by the 
metastore
-          server/client, which BE never runs (it reads the already-resolved 
table).
+          Paimon OSS/S3 FileIO plugins. These register 
org.apache.paimon.fs.FileIOLoader
+          service providers (paimon-s3 -> S3Loader, paimon-jindo -> 
JindoLoader) used to read
+          object-store-backed Paimon tables (e.g. DLF REST catalogs over OSS). 
They MUST be
+          bundled here, alongside the FileIOLoader interface that lives in 
paimon-common: when a
+          BE-side read resolves the latest snapshot from the filesystem, 
RESTTokenFileIO.fileIO()
+          calls FileIO.get() -> ServiceLoader.load(FileIOLoader.class, 
FileIOLoader's classloader),
+          which eagerly instantiates every registered provider. A provider and 
the FileIOLoader
+          interface it implements must be reachable from the same classloader. 
These plugins used
+          to sit on the shared preload-extensions (JVM app) classpath, which 
does NOT carry
+          paimon-common's FileIOLoader; since JniScannerClassLoader is 
parent-first, the app-loaded
+          S3Loader/JindoLoader could not resolve the child-only interface -> 
NoClassDefFoundError:
+          FileIOLoader. Co-locating them here keeps the whole FileIO SPI in 
one classloader; the
+          assembly's metaInf-services handler merges their service files with 
paimon-common's
+          (local/hadoop) into one complete provider list.
         -->
         <dependency>
-            <groupId>org.apache.hive</groupId>
-            <artifactId>hive-standalone-metastore</artifactId>
-            <version>${hive.version}</version>
-            <exclusions>
-                <exclusion>
-                    <groupId>*</groupId>
-                    <artifactId>*</artifactId>
-                </exclusion>
-            </exclusions>
+            <groupId>org.apache.paimon</groupId>
+            <artifactId>paimon-s3</artifactId>
         </dependency>
-
         <dependency>
             <groupId>org.apache.paimon</groupId>
-            <artifactId>paimon-format</artifactId>
+            <artifactId>paimon-jindo</artifactId>
         </dependency>
 
     </dependencies>
diff --git a/fe/be-java-extensions/preload-extensions/pom.xml 
b/fe/be-java-extensions/preload-extensions/pom.xml
index b359ce15944..0b32dbed8e2 100644
--- a/fe/be-java-extensions/preload-extensions/pom.xml
+++ b/fe/be-java-extensions/preload-extensions/pom.xml
@@ -112,16 +112,19 @@ under the License.
                 </exclusion>
             </exclusions>
         </dependency>
-        <!-- For BE Paimon OSS/S3 Access -->
-        <dependency>
-            <groupId>org.apache.paimon</groupId>
-            <artifactId>paimon-s3</artifactId>
-        </dependency>
-
-        <dependency>
-            <groupId>org.apache.paimon</groupId>
-            <artifactId>paimon-jindo</artifactId>
-        </dependency>
+        <!--
+          NOTE: the paimon OSS/S3 FileIO plugins (paimon-s3 / paimon-jindo) 
are intentionally
+          NOT here. They register org.apache.paimon.fs.FileIOLoader service 
providers
+          (S3Loader / JindoLoader), but the FileIOLoader interface itself 
lives in paimon-common,
+          which is bundled in paimon-scanner, NOT on this shared/preload 
classpath. Because each
+          JniScannerClassLoader delegates parent-first to the JVM app 
classloader (where this
+          preload jar sits), a provider defined here could not resolve the 
child-only FileIOLoader
+          interface, so ServiceLoader discovery in paimon-scanner's 
FileIO.get() failed with
+          NoClassDefFoundError: FileIOLoader. The plugins are therefore 
co-located with the
+          FileIOLoader interface in paimon-scanner (its only BE consumer) so 
the whole FileIO SPI
+          lives in one classloader. Do not move them back here without also 
putting paimon-common
+          on this classpath.
+        -->
         <!-- For Avro and Hudi Scanner PreLoad -->
         <dependency>
             <groupId>org.apache.hadoop</groupId>
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 e785391776a..386c0aac2fc 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
@@ -47,6 +47,7 @@ import org.apache.paimon.catalog.Identifier;
 import org.apache.paimon.partition.Partition;
 import org.apache.paimon.schema.Schema;
 import org.apache.paimon.table.DataTable;
+import org.apache.paimon.table.FileStoreTable;
 import org.apache.paimon.table.Table;
 import org.apache.paimon.table.system.SystemTableLoader;
 import org.apache.paimon.types.DataField;
@@ -457,25 +458,55 @@ public class PaimonConnectorMetadata implements 
ConnectorMetadata {
         // (identical equals/hashCode/toString and the same sys Identifier). 
The support check above
         // stays case-insensitive; only the canonical stored name is 
lowercased.
         String sys = sysName.toLowerCase(java.util.Locale.ROOT);
-        Identifier sysId = new Identifier(
-                base.getDatabaseName(), base.getTableName(), "main", sys);
-        // M-11: wrap the remote getTable in executeAuthenticated (D-052). 
TableNotExistException is
-        // caught INSIDE the lambda (Kerberos UGI.doAs would wrap it 
otherwise) and signalled out as a
-        // null Table so this method can still short-circuit to 
Optional.empty().
-        Table sysTable;
-        try {
-            sysTable = context.executeAuthenticated(() -> {
-                try {
-                    return catalogOps.getTable(sysId);
-                } catch (Catalog.TableNotExistException e) {
-                    return null;
-                }
-            });
-        } catch (Exception e) {
-            throw new RuntimeException("Failed to load Paimon system table: " 
+ sysId, e);
-        }
+        // Build the wrapper over the base Table this handle ALREADY carries, 
exactly the way the
+        // Paimon catalog builds it ({@code CatalogUtils#createSystemTable}), 
and keep that base on
+        // the sys handle. Loading the wrapper from the catalog instead would 
give it its own schema
+        // generation, independent of any base a consumer resolves later: the 
FE would then plan on
+        // one generation while PaimonScanPlanProvider serializes to the BE a 
system table rebuilt
+        // over the other one, and a system table whose row type follows the 
base schema
+        // ($audit_log, $binlog, $ro) could reach the BE without the columns 
the FE asked for.
+        //
+        // Strictly the ALREADY-RESOLVED reference, never a reload: 
getTableHandle stashes it one
+        // call earlier 
(PluginDrivenSysExternalTable#resolveConnectorTableHandle resolves the base
+        // handle immediately before this), so the normal path needs no 
round-trip. Resolving it
+        // here instead would enter the authenticator a second time and would 
turn a missing base
+        // table into a thrown RuntimeException rather than this method's 
Optional.empty() contract.
+        //
+        // "Exactly the way the catalog builds it" includes building it over 
the UNDECORATED base:
+        // CatalogUtils#loadTable hands createSystemTable the raw 
FileStoreTableFactory#create
+        // result, and the decorator a catalog may add afterwards never 
reaches a system table,
+        // because it only wraps a FileStoreTable and no system table is one. 
That matters for $ro:
+        // ReadOptimizedTable#newScan builds its two-branch FallbackReadScan 
only when its immediate
+        // wrapped object is a FallbackReadFileStoreTable, so with a 
PrivilegedFileStoreTable in
+        // between it would plan the main branch alone through the pair's 
inherited
+        // newSnapshotReader() and silently drop every fallback-only partition.
+        Table baseTable = base.getPaimonTable();
+        FileStoreTable sysBase = baseTable instanceof FileStoreTable
+                ? 
PaimonTableDecorators.unwrapToFallbackOrBase((FileStoreTable) baseTable)
+                : null;
+        Table sysTable = sysBase == null ? null : SystemTableLoader.load(sys, 
sysBase);
         if (sysTable == null) {
-            return Optional.empty();
+            // Not a file store table (format / object table): let the catalog 
decide.
+            // M-11: wrap the remote getTable in executeAuthenticated (D-052). 
TableNotExistException is
+            // caught INSIDE the lambda (Kerberos UGI.doAs would wrap it 
otherwise) and signalled out as a
+            // null Table so this method can still short-circuit to 
Optional.empty().
+            Identifier sysId = new Identifier(
+                    base.getDatabaseName(), base.getTableName(), "main", sys);
+            try {
+                sysTable = context.executeAuthenticated(() -> {
+                    try {
+                        return catalogOps.getTable(sysId);
+                    } catch (Catalog.TableNotExistException e) {
+                        return null;
+                    }
+                });
+            } catch (Exception e) {
+                throw new RuntimeException("Failed to load Paimon system 
table: " + sysId, e);
+            }
+            if (sysTable == null) {
+                return Optional.empty();
+            }
+            sysBase = null;
         }
         // #65984 widened the name-forced set to include row_tracking: like 
binlog/audit_log its rows
         // are materialized by the paimon reader itself, so the native reader 
would return wrong rows.
@@ -484,6 +515,7 @@ public class PaimonConnectorMetadata implements 
ConnectorMetadata {
         PaimonTableHandle handle = PaimonTableHandle.forSystemTable(
                 base.getDatabaseName(), base.getTableName(), sys, forceJni);
         handle.setPaimonTable(sysTable);
+        handle.setSysBaseTable(sysBase);
         return Optional.of(handle);
     }
 
diff --git 
a/fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/PaimonIncrementalScanParams.java
 
b/fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/PaimonIncrementalScanParams.java
index adc2022057f..4353d30bda2 100644
--- 
a/fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/PaimonIncrementalScanParams.java
+++ 
b/fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/PaimonIncrementalScanParams.java
@@ -19,6 +19,11 @@ package org.apache.doris.connector.paimon;
 
 import org.apache.doris.connector.api.DorisConnectorException;
 
+import org.apache.paimon.Snapshot;
+import org.apache.paimon.table.FallbackReadFileStoreTable;
+import org.apache.paimon.table.FileStoreTable;
+import org.apache.paimon.utils.SnapshotManager;
+
 import java.util.HashMap;
 import java.util.Map;
 
@@ -320,4 +325,87 @@ public final class PaimonIncrementalScanParams {
         withResets.putAll(scanOptions);
         return withResets;
     }
+
+    /**
+     * Bind an incremental relation's range to the catalog-visible snapshot, 
for the table the BE
+     * re-plans on a catalog-less copy.
+     *
+     * <p>Only for {@link PaimonScanParams#resolvesSnapshotOnBackend} system 
tables, i.e. today
+     * {@code $partitions}, the one table that both re-plans on the BE and 
accepts {@code @incr}.
+     * Paimon selects the incremental scanner from {@code 
incremental-between*} and never reads
+     * {@code scan.snapshot-id} in that mode, so the snapshot pin
+     * ({@code PaimonScanPlanProvider#pinCatalogSnapshot}) cannot bound this 
scan - and
+     * {@link PaimonScanParams#isolateIncrementalRead} clears it anyway, so 
one relation's read state
+     * cannot leak into another's.
+     *
+     * <p>The timestamp form is the exposed one. {@code 
IncrementalDeltaStartingScanner
+     * #betweenTimestamps} turns both endpoints into snapshot ids through
+     * {@code SnapshotManager#earlierOrEqualTimeMills}, whose binary search 
runs up to
+     * {@code latestSnapshotId()}: the catalog's pointer here, but the newest 
file in the snapshot
+     * directory on the catalog-less BE. So resolve the endpoints while the 
loader is still around
+     * and hand the BE the explicit id range Paimon would have computed 
itself. Only the delta
+     * scanner's rule is reachable: {@code incremental-between-scan-mode} is 
cleared with the rest of
+     * the read state, and its default {@code AUTO} never selects the diff 
scanner.
+     *
+     * <p>Only when the requested end reaches the catalog's snapshot. An older 
end already resolves
+     * to the same id on both sides, because every snapshot the catalog has 
not published yet is
+     * younger than the one it points at. The snapshot-id form ({@code 
startSnapshotId} /
+     * {@code endSnapshotId}) names its endpoints outright and needs nothing.
+     *
+     * <p>Never on a {@code scan.fallback-branch} table, because an id range 
cannot be expressed for
+     * the pair. The two branches keep independent snapshot id sequences, and
+     * {@code FallbackReadFileStoreTable#rewriteFallbackOptions} translates 
only
+     * {@code scan.snapshot-id} - {@code incremental-between} is copied to the 
fallback branch
+     * verbatim. A main-branch id range would then be validated against the 
fallback branch's own
+     * range in {@code IncrementalDeltaStartingScanner#betweenSnapshotIds} and 
either fail out of
+     * range or select unrelated commits. The timestamp form needs no 
translation to begin with: a
+     * wall clock means the same thing on both branches, and each resolves it 
against its own
+     * {@code SnapshotManager}, so handing it over untouched is the 
branch-correct choice. What that
+     * keeps is the catalog-versus-filesystem endpoint gap {@code 
pinCatalogSnapshot} documents for
+     * the fallback branch, which is the same gap on the same table.
+     *
+     * @param scanOptions the relation's resolved scan options
+     * @param dataTable the still catalog-ful base table the FE planned with
+     * @return a NEW map whose timestamp range is replaced by the resolved id 
range, or
+     *         {@code scanOptions} unchanged (same reference) when the range 
is already bound, cannot
+     *         be bound, or does not need to be
+     */
+    public static Map<String, String> bindRangeToCatalog(
+            Map<String, String> scanOptions, FileStoreTable dataTable) {
+        String range = scanOptions == null ? null : 
scanOptions.get(PAIMON_INCREMENTAL_BETWEEN_TIMESTAMP);
+        if (range == null) {
+            return scanOptions;
+        }
+        // Before any pair-level accessor is read: on a fallback pair both 
catalogEnvironment() and
+        // snapshotManager() describe the main branch alone, and the bound 
could not be carried to
+        // the fallback branch anyway.
+        if (PaimonTableDecorators.unwrapToFallbackOrBase(dataTable) instanceof 
FallbackReadFileStoreTable) {
+            return scanOptions;
+        }
+        if (!dataTable.catalogEnvironment().supportsVersionManagement()) {
+            return scanOptions;
+        }
+        SnapshotManager snapshotManager = dataTable.snapshotManager();
+        Snapshot latest = snapshotManager.latestSnapshot();
+        Snapshot earliest = snapshotManager.earliestSnapshot();
+        if (latest == null || earliest == null) {
+            return scanOptions;
+        }
+        String[] endpoints = range.split(",");
+        long startMillis = Long.parseLong(endpoints[0]);
+        long endMillis = Long.parseLong(endpoints[1]);
+        if (endMillis < latest.timeMillis()) {
+            return scanOptions;
+        }
+        Snapshot start = snapshotManager.earlierOrEqualTimeMills(startMillis);
+        // The starting snapshot is exclusive, hence the id before the 
earliest one when the range
+        // opens before it - exactly what betweenTimestamps computes.
+        long startId = (start == null || earliest.timeMillis() > startMillis)
+                ? earliest.id() - 1
+                : start.id();
+        Map<String, String> boundParams = new HashMap<>(scanOptions);
+        boundParams.put(PAIMON_INCREMENTAL_BETWEEN_TIMESTAMP, null);
+        boundParams.put(PAIMON_INCREMENTAL_BETWEEN, startId + "," + 
latest.id());
+        return boundParams;
+    }
 }
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 4b10b584372..47a6f6dc6eb 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
@@ -103,6 +103,12 @@ public final class PaimonScanParams {
     private static final Set<String> PAIMON_READER_SYSTEM_TABLES = 
ImmutableSet.of(
             "audit_log", "binlog", "row_tracking");
 
+    // Not a capability the user selects but a property of where the snapshot 
is resolved: these five
+    // pick it inside the BE reader rather than reading what the FE planned. 
See
+    // resolvesSnapshotOnBackend for the per-table call chain.
+    private static final Set<String> BACKEND_SNAPSHOT_SYSTEM_TABLES = 
ImmutableSet.of(
+            "files", "partitions", "manifests", "statistics", "table_indexes");
+
     private static final Set<String> OPTIONS_SYSTEM_TABLES = ImmutableSet.of(
             // A system table may advertise OPTIONS only when every 
row-producing stage observes
             // the selected snapshot; files and buckets still consult latest 
metadata internally.
@@ -416,6 +422,27 @@ public final class PaimonScanParams {
         return 
PAIMON_READER_SYSTEM_TABLES.contains(systemTableType.toLowerCase());
     }
 
+    /**
+     * The system tables whose rows are not fixed by what the FE planned: the 
FE only sends marker
+     * splits and the snapshot is picked inside the BE reader. Two shapes, 
both honoring
+     * {@code scan.snapshot-id}:
+     * <ul>
+     *   <li>{@code $files} / {@code $partitions} re-plan the base table on 
the BE
+     *       ({@code FilesTable.FilesRead#createReader} -&gt; {@code 
DataTableScan#plan()},
+     *       {@code PartitionsTable.PartitionsRead#createReader} -&gt;
+     *       {@code newScan().listPartitionEntries()});</li>
+     *   <li>{@code $manifests} / {@code $table_indexes} / {@code $statistics} 
resolve it directly
+     *       ({@code TimeTravelUtil#tryTravelOrLatest}, reached from {@code 
ManifestsTable},
+     *       {@code TableIndexesTable} and {@code 
AbstractFileStoreTable#statistics}).</li>
+     * </ul>
+     * All five have a fixed row type, so pinning the catalog's snapshot for 
them cannot rewind the
+     * BE schema. Consumed by {@code PaimonScanPlanProvider}, which hands the 
BE a catalog-less table
+     * and therefore has to do on the FE what the catalog would have done 
inside the BE reader.
+     */
+    public static boolean resolvesSnapshotOnBackend(String systemTableType) {
+        return 
BACKEND_SNAPSHOT_SYSTEM_TABLES.contains(systemTableType.toLowerCase());
+    }
+
     /**
      * Rejects the ONE option a system table can never honor. Which system 
table accepts {@code @options}
      * at all is answered earlier and generically by
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 be76182183e..e24c70723eb 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
@@ -55,12 +55,16 @@ import org.apache.paimon.data.BinaryRow;
 import org.apache.paimon.data.Timestamp;
 import org.apache.paimon.fs.FileIO;
 import org.apache.paimon.io.DataFileMeta;
+import org.apache.paimon.options.Options;
 import org.apache.paimon.rest.RESTToken;
 import org.apache.paimon.rest.RESTTokenFileIO;
 import org.apache.paimon.schema.SchemaManager;
 import org.apache.paimon.schema.TableSchema;
 import org.apache.paimon.table.BucketMode;
+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.source.DataSplit;
 import org.apache.paimon.table.source.DeletionFile;
@@ -72,6 +76,7 @@ import org.apache.paimon.table.source.Split;
 import org.apache.paimon.table.source.TableScan;
 import org.apache.paimon.table.source.snapshot.SnapshotReader;
 import org.apache.paimon.table.system.ReadOptimizedTable;
+import org.apache.paimon.table.system.SystemTableLoader;
 import org.apache.paimon.types.ArrayType;
 import org.apache.paimon.types.DataField;
 import org.apache.paimon.types.DataType;
@@ -863,8 +868,9 @@ public class PaimonScanPlanProvider implements 
ConnectorScanPlanProvider {
             props.put(ScanNodePropertyKeys.PATH_PARTITION_KEYS, 
String.join(",", partitionKeys));
         }
 
-        // Serialized table for BE's JNI reader
-        String serializedTable = encodeObjectToString(table);
+        // Serialized table for BE's JNI reader, stripped of its catalog 
loader (see
+        // tableForBackend) so the BE never has to deserialize the Hive 
metastore stack.
+        String serializedTable = 
encodeObjectToString(tableForBackend(paimonHandle, table));
         props.put(PROP_SERIALIZED_TABLE, serializedTable);
 
         // Serialized predicates for BE's JNI scanner. ALWAYS emit, even for 
the no-filter / empty-predicate
@@ -958,6 +964,245 @@ public class PaimonScanPlanProvider implements 
ConnectorScanPlanProvider {
         return props;
     }
 
+    /**
+     * Build the Paimon table object that is serialized to the BE.
+     *
+     * <p>Every table loaded from a metastore-backed Paimon catalog (HMS / 
DLF) carries a Paimon
+     * {@code HiveCatalogLoader} in its {@link CatalogEnvironment}. The BE 
only reads — via
+     * FE-resolved splits and the object store — and never needs the catalog, 
yet deserializing that
+     * loader forces the whole Hive metastore stack onto the BE classpath: 
{@code HiveConf}, the
+     * metastore API, and, when a system table resolves its latest snapshot, 
even the metastore
+     * client (DLF's {@code ProxyMetaStoreClient} and its REST stack). So we 
serialize a catalog-less
+     * table to the BE:
+     * <ul>
+     *   <li>data table: drop the catalog loader. A {@link FileStoreTable} is 
fully defined by
+     *       fileIO / location / schema / catalogEnvironment, and its dynamic 
options (time travel,
+     *       incremental) are merged into the schema by {@code copy(...)} — 
which
+     *       {@link #resolveScanTable} has already applied — so rebuilding from
+     *       fileIO / location / schema preserves everything except the 
catalog loader.</li>
+     *   <li>system table (e.g. {@code $snapshots}): rebuild it over a 
catalog-less data table so
+     *       {@code SnapshotManager#latestSnapshotId} lists the snapshot 
directory on the filesystem
+     *       instead of calling the metastore. The base table is the one the 
FE-side wrapper was
+     *       built over ({@link PaimonTableHandle#getSysBaseTable()}), and for 
the system tables that
+     *       pick their snapshot on the BE ({@link 
PaimonScanParams#resolvesSnapshotOnBackend}) what
+     *       the catalog would have done there is done here instead: see
+     *       {@link #authorizeDeferredScan} and {@link #pinCatalogSnapshot}. 
Every other system table
+     *       reads what the FE already planned, so it is handed over 
untouched. The relation-scoped
+     *       scan params {@link #resolveScanTable} applied to the original 
wrapper are re-applied to
+     *       the rebuilt one by {@link #reapplyScanParams}.</li>
+     * </ul>
+     */
+    // Package-private for direct unit testing (PaimonBackendBoundTableTest).
+    Table tableForBackend(PaimonTableHandle handle, Table scanTable) {
+        if (scanTable instanceof FileStoreTable) {
+            // resolveScanTable's copy(...) merged the relation's dynamic 
options into the schema,
+            // and the rebuild below goes through that schema, so this branch 
needs no re-application.
+            return dropCatalogLoader((FileStoreTable) scanTable);
+        }
+        if (!handle.isSystemTable()) {
+            return scanTable;
+        }
+        // The very same base table the FE-side wrapper was built over, so 
that the BE never sees a
+        // different schema generation than the one this query was planned 
with.
+        FileStoreTable dataTable = handle.getSysBaseTable();
+        if (dataTable == null) {
+            return scanTable;
+        }
+        String sysTableType = handle.getSysTableName();
+        boolean resolvesOnBackend = 
PaimonScanParams.resolvesSnapshotOnBackend(sysTableType);
+        if (PAIMON_FILES_SYSTEM_TABLE.equalsIgnoreCase(sysTableType)) {
+            authorizeDeferredScan(dataTable);
+        }
+        FileStoreTable baseForBackend = dropCatalogLoader(dataTable);
+        if (resolvesOnBackend) {
+            baseForBackend = pinCatalogSnapshot(baseForBackend, dataTable);
+        }
+        Table catalogLessSysTable = SystemTableLoader.load(sysTableType, 
baseForBackend);
+        if (catalogLessSysTable == null) {
+            return scanTable;
+        }
+        return reapplyScanParams(catalogLessSysTable, dataTable, 
resolvesOnBackend,
+                handle.getScanOptions());
+    }
+
+    /**
+     * Re-apply the relation-scoped scan params to a rebuilt system-table 
wrapper.
+     *
+     * <p>{@link #resolveScanTable} applies {@code @incr} / {@code @options} 
to the wrapper the
+     * handle carries, and every Paimon system table delegates {@code 
copy(...)} to the data table it
+     * wraps. The wrapper rebuilt above is a different object, so the same 
copy has to be redone on
+     * it, otherwise the BE would materialize its splits against the unpinned 
latest state. The
+     * branches mirror {@link #resolveScanTable}'s exactly — one chokepoint's 
worth of logic applied
+     * to two different objects.
+     *
+     * <p>This runs last on purpose: an explicit relation option outranks 
anything this class pins on
+     * the rebuilt table, and {@code copy(...)} lets the option win. An 
incremental relation outranks
+     * {@link #pinCatalogSnapshot} the same way but cannot inherit its bound, 
so it is bound to the
+     * catalog's snapshot separately by {@link 
PaimonIncrementalScanParams#bindRangeToCatalog}.
+     *
+     * <p>Known gap: the {@code @options} branch reaches tables outside
+     * {@link PaimonScanParams#resolvesSnapshotOnBackend}, {@code $ro} among 
them, and on a
+     * {@code scan.fallback-branch} table the {@code copy(...)} here is what 
triggers
+     * {@code rewriteFallbackOptions} - on the already catalog-less pair, so 
the fallback branch's
+     * {@code scan.snapshot-id} is derived from its snapshot directory rather 
than from the catalog
+     * pointer, the same gap {@link #pinCatalogSnapshot} documents. It lands 
harder here: unlike the
+     * pin this is {@code copy(...)}, not {@code copyWithoutTimeTravel(...)}, 
so it also time-travels
+     * the fallback branch's schema, and {@code $ro} is a data table rather 
than read-only metadata.
+     */
+    private Table reapplyScanParams(Table rebuiltSysTable, FileStoreTable 
dataTable,
+            boolean resolvesOnBackend, Map<String, String> scanOptions) {
+        if (scanOptions == null || scanOptions.isEmpty()) {
+            return rebuiltSysTable;
+        }
+        if (PaimonScanParams.isOptionsPin(scanOptions)) {
+            return PaimonScanParams.applyOptions(rebuiltSysTable, scanOptions);
+        }
+        Map<String, String> params = resolvesOnBackend
+                ? PaimonIncrementalScanParams.bindRangeToCatalog(scanOptions, 
dataTable)
+                : scanOptions;
+        return 
rebuiltSysTable.copy(PaimonIncrementalScanParams.applyResetsIfIncremental(params));
+    }
+
+    /**
+     * {@code $files} plans only partition-level splits on the FE and re-plans 
the base table on the
+     * BE through {@code DataTableScan#plan()} ({@code 
FilesTable.FilesRead#createReader}). That
+     * deferred plan normally authorizes itself through the catalog loader
+     * ({@code CatalogEnvironment#tableQueryAuth} -&gt; {@code 
Catalog#authTableQuery}); once the
+     * loader is dropped it silently allows everything. So authorize here, 
while the loader is still
+     * around. Paimon discards the predicates the call returns (row level 
access control is a TODO in
+     * {@code AbstractDataTableScan#authQuery}), so running it on the FE loses 
nothing.
+     *
+     * <p>Only {@code $files} may do this: {@code auth(null)} means "every 
column" to
+     * {@code Catalog#authTableQuery}, and the system tables that keep 
planning on the FE
+     * ({@code $ro}, {@code $row_tracking}, {@code $audit_log}, {@code 
$binlog}) already authorize
+     * themselves through {@code DataTableBatchScan} with the slot projection 
the query really reads.
+     * Authorizing those again for every column would reject a user allowed to 
read only some of the
+     * base columns. {@code $partitions} never reaches {@code plan()} on 
either side
+     * ({@code listPartitionEntries} does not authorize), so it has no 
authorization to transfer.
+     *
+     * <p>A {@code scan.fallback-branch} table has to be authorized branch by 
branch.
+     * {@code FallbackReadFileStoreTable#newScan} builds a {@code 
FallbackReadScan} over both
+     * branches' own scans, so each authorizes itself, and {@code 
FileStoreTableFactory#create} gives
+     * the fallback branch a {@link CatalogEnvironment} of its own carrying a 
branch-qualified
+     * {@code Identifier}. Since the pair delegates {@code 
catalogEnvironment()} to its main branch,
+     * authorizing the pair would check the main branch alone and never the 
fallback one - and once
+     * its loader is dropped that missing check turns into a permanent allow, 
letting a user denied on
+     * the fallback branch read the fallback rows of {@code $files}.
+     */
+    // Package-private for direct unit testing (PaimonBackendBoundTableTest).
+    static void authorizeDeferredScan(FileStoreTable dataTable) {
+        FileStoreTable undecorated = 
PaimonTableDecorators.unwrapToFallbackOrBase(dataTable);
+        if (undecorated instanceof FallbackReadFileStoreTable) {
+            FallbackReadFileStoreTable fallbackReadTable = 
(FallbackReadFileStoreTable) undecorated;
+            authorizeBranch(fallbackReadTable.wrapped());
+            authorizeBranch(fallbackReadTable.fallback());
+            return;
+        }
+        authorizeBranch(undecorated);
+    }
+
+    private static void authorizeBranch(FileStoreTable branch) {
+        CoreOptions options = branch.coreOptions();
+        if (options.queryAuthEnabled()) {
+            branch.catalogEnvironment().tableQueryAuth(options).auth(null);
+        }
+    }
+
+    /**
+     * For catalogs that manage versions themselves (Paimon REST / DLF REST) 
the committed snapshot
+     * is the one the catalog points at, not the newest file in the snapshot 
directory: Paimon
+     * publishes the snapshot file before the pointer moves, and a rollback 
leaves newer files
+     * behind. Without the catalog loader {@code SnapshotManager} falls back 
to listing that
+     * directory, so the BE could plan on a snapshot the catalog has not 
published while the FE
+     * planned on the previous one. Pin the catalog-visible snapshot instead.
+     *
+     * <p>Only for {@link PaimonScanParams#resolvesSnapshotOnBackend} system 
tables, because only
+     * those pick their snapshot inside the BE reader. Every other system 
table materializes the
+     * splits the FE already planned, so pinning them would bind nothing that 
is not already bound.
+     * The pin goes through {@code copyWithoutTimeTravel}, so it bounds which 
snapshot the BE plans
+     * on without rewinding the BE's schema to that snapshot.
+     *
+     * <p>Known gap: {@code scan.snapshot-id} only bounds plans that go through
+     * {@code DataTableScan}. {@code $snapshots} ({@code 
SnapshotManager#snapshotsWithinRange}) and
+     * {@code $buckets} ({@code SnapshotReader#bucketEntries}) ignore it, so 
on the BE they observe
+     * the snapshot directory rather than the catalog's pointer. Both are 
read-only metadata tables,
+     * so this shows up only as a transient extra row inside the publication 
window of a
+     * version-managed catalog.
+     *
+     * <p>Known gap: {@code $files} is planned in two phases and only the 
second one can be pinned.
+     * The FE emits one marker split per partition of {@code 
SnapshotManager#latestSnapshot()} at
+     * split generation time ({@code FilesTable.FilesScan#innerPlan} -&gt;
+     * {@code SnapshotReader#partitions}), and that path reads {@code 
ManifestsReader#read(null, ..)},
+     * which ignores {@code scan.snapshot-id} - so the marker set cannot be 
bound to the pin, and
+     * {@code FilesSplit} is private to {@code FilesTable}, so Doris cannot 
emit a snapshot
+     * independent marker either. A commit landing between initialization and 
split generation that
+     * drops a partition therefore hides that partition's rows even though the 
BE stays on the older
+     * snapshot. Paimon's two-phase plan has this gap regardless: before the 
pin the BE resolved the
+     * latest snapshot at read time, i.e. across a strictly wider window, so 
this only moves which
+     * side of the window the mismatch falls on.
+     *
+     * <p>Known gap: on a table with {@code scan.fallback-branch} this bounds 
the main branch only.
+     * The pin itself is carried across correctly - {@code 
copyWithoutTimeTravel} is pair-aware and
+     * {@code rewriteFallbackOptions} converts the main id to the fallback 
branch's own through
+     * {@code SnapshotManager#earlierOrEqualTimeMills}. What is lost is the 
pointer that conversion
+     * searches against: the rebuilt pair has no catalog loader on either 
branch, so the fallback
+     * bound is capped by the newest file in that branch's snapshot directory 
instead of by the
+     * catalog. Of the pinned tables only {@code $partitions} and {@code 
$files} read the fallback
+     * branch at all ({@code $manifests} / {@code $statistics} / {@code 
$table_indexes} reach it
+     * through {@code store()} / {@code statistics()}, which describe the main 
branch), so the
+     * effect is confined to their rows. Note this outlasts a publication 
window when it is caused
+     * by a rollback: the abandoned snapshot file stays on the filesystem 
until it expires, and for
+     * as long as it does the fallback branch can be bound to it.
+     */
+    // Package-private for direct unit testing (PaimonBackendBoundTableTest).
+    static FileStoreTable pinCatalogSnapshot(FileStoreTable catalogLessTable, 
FileStoreTable dataTable) {
+        if (!dataTable.catalogEnvironment().supportsVersionManagement()) {
+            return catalogLessTable;
+        }
+        Long snapshotId = dataTable.snapshotManager().latestSnapshotId();
+        if (snapshotId == null) {
+            return catalogLessTable;
+        }
+        // Without time travel: pin which snapshot the BE plans on, leave 
schema resolution alone.
+        return catalogLessTable.copyWithoutTimeTravel(
+                Collections.singletonMap(CoreOptions.SCAN_SNAPSHOT_ID.key(), 
String.valueOf(snapshotId)));
+    }
+
+    /**
+     * Return an equivalent {@link FileStoreTable} without the catalog loader, 
so the BE never
+     * deserializes a {@code HiveCatalogLoader} (and snapshot loading uses the 
filesystem instead of
+     * the catalog's metastore). fileIO / location / schema (and the schema's 
options) are preserved.
+     *
+     * <p>A {@code scan.fallback-branch} table must be rebuilt branch by 
branch.
+     * {@link FallbackReadFileStoreTable} exposes only its main branch through 
{@code schema()}, and
+     * the plain {@code FileStoreTableFactory#create} re-expands the fallback 
branch from
+     * {@code SchemaManager(fallbackBranch).latest()} instead of the object 
the FE captured. That
+     * would ship a main/fallback pair from two different generations: after 
external DDL publishes
+     * a new schema on both branches, the FE keeps planning on the cached 
M1/F1 while the BE gets
+     * M1/F2 and fails in {@code FallbackReadFileStoreTable#validateSchema}. 
So rebuild each branch
+     * from the schema the FE really planned with, and re-wrap.
+     */
+    // Package-private for direct unit testing (PaimonBackendBoundTableTest).
+    static FileStoreTable dropCatalogLoader(FileStoreTable dataTable) {
+        if (dataTable.catalogEnvironment().catalogLoader() == null) {
+            return dataTable;
+        }
+        FileStoreTable undecorated = 
PaimonTableDecorators.unwrapToFallbackOrBase(dataTable);
+        if (undecorated instanceof FallbackReadFileStoreTable) {
+            FallbackReadFileStoreTable fallbackReadTable = 
(FallbackReadFileStoreTable) undecorated;
+            return new FallbackReadFileStoreTable(
+                    rebuildWithoutCatalogLoader(fallbackReadTable.wrapped()),
+                    rebuildWithoutCatalogLoader(fallbackReadTable.fallback()));
+        }
+        return rebuildWithoutCatalogLoader(undecorated);
+    }
+
+    private static FileStoreTable rebuildWithoutCatalogLoader(FileStoreTable 
branch) {
+        return FileStoreTableFactory.createWithoutFallbackBranch(
+                branch.fileIO(), branch.location(), branch.schema(), new 
Options(),
+                CatalogEnvironment.empty());
+    }
+
     /**
      * Resolves the {@link FileStoreTable} whose schema dictionary BE needs to 
field-id-match the native
      * data files for {@code table}. A normal data table IS the 
FileStoreTable. A read-optimized system
@@ -1353,6 +1598,10 @@ public class PaimonScanPlanProvider implements 
ConnectorScanPlanProvider {
     private static final String PAIMON_PROPERTY_PREFIX = "paimon.";
     private static final String PAIMON_BINLOG_SYSTEM_TABLE = "binlog";
 
+    // The one system table whose deferred BE-side plan must inherit the 
catalog's authorization —
+    // see authorizeDeferredScan.
+    private static final String PAIMON_FILES_SYSTEM_TABLE = "files";
+
     private static final List<String> BACKEND_PAIMON_JNI_OPTIONS = 
Arrays.asList(
             "jni.enable_jni_io_manager",
             "jni.io_manager.tmp_dir",
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
new file mode 100644
index 00000000000..fbebd7b1989
--- /dev/null
+++ 
b/fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/PaimonTableDecorators.java
@@ -0,0 +1,63 @@
+// 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.paimon.table.DelegatedFileStoreTable;
+import org.apache.paimon.table.FallbackReadFileStoreTable;
+import org.apache.paimon.table.FileStoreTable;
+
+/**
+ * The one place that knows how Paimon stacks {@link DelegatedFileStoreTable} 
decorators on a loaded
+ * table, and how far they may be peeled off.
+ */
+final class PaimonTableDecorators {
+
+    private PaimonTableDecorators() {
+    }
+
+    /**
+     * Peel the decorators Paimon may have stacked on top of the table, down 
to the fallback-branch
+     * pair - the one layer that must stay on top, because that is what 
dispatches a read to the
+     * right branch.
+     *
+     * <p>{@code PrivilegedCatalog#getTable} wraps whatever {@code 
FileStoreTableFactory} built into
+     * a {@code PrivilegedFileStoreTable}, so with file based privileges 
enabled a
+     * {@code scan.fallback-branch} table reaches Doris as {@code 
Privileged(FallbackRead(...))}.
+     * Paimon itself never lets a decorator sit above the pair - {@code 
CatalogUtils#loadTable}
+     * builds system tables straight over the {@code FileStoreTableFactory} 
result and only wraps
+     * what it returns - and both Paimon and Doris dispatch on a direct {@code 
instanceof
+     * FallbackReadFileStoreTable}, which looks straight past a decorator and 
silently falls back to
+     * the delegated main branch alone.
+     *
+     * <p>The decorators are peeled off rather than re-applied, and what that 
costs differs by
+     * caller. Rebuilding the table the BE gets loses nothing: the privilege 
wrapper only asserts on
+     * {@code newScan()} / {@code newRead()}, which the FE has already run 
while planning, and a
+     * plain (non fallback) table loses the wrapper the same way once it is 
rebuilt out of
+     * fileIO / location / schema. Building a system table over the peeled 
base does drop that
+     * assertion, but that is Paimon's own semantics rather than a relaxation 
of it:
+     * {@code PrivilegedCatalog#getTable} wraps only a result that is a {@code 
FileStoreTable}, and
+     * no system table is one, so {@code db.tbl$ro} never carries the 
decorator there either.
+     */
+    static FileStoreTable unwrapToFallbackOrBase(FileStoreTable dataTable) {
+        FileStoreTable current = dataTable;
+        while (current instanceof DelegatedFileStoreTable && !(current 
instanceof FallbackReadFileStoreTable)) {
+            current = ((DelegatedFileStoreTable) current).wrapped();
+        }
+        return current;
+    }
+}
diff --git 
a/fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/PaimonTableHandle.java
 
b/fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/PaimonTableHandle.java
index 0d4738e211f..4b5b20af6bc 100644
--- 
a/fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/PaimonTableHandle.java
+++ 
b/fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/PaimonTableHandle.java
@@ -19,6 +19,7 @@ package org.apache.doris.connector.paimon;
 
 import org.apache.doris.connector.api.handle.ConnectorTableHandle;
 
+import org.apache.paimon.table.FileStoreTable;
 import org.apache.paimon.table.Table;
 
 import java.util.Collections;
@@ -88,6 +89,24 @@ public class PaimonTableHandle implements 
ConnectorTableHandle {
     /** Transient Paimon Table reference; not serialized. Set by 
PaimonConnectorMetadata. */
     private transient Table paimonTable;
 
+    /**
+     * For a SYSTEM handle, the base {@link FileStoreTable} its {@link 
#paimonTable} wrapper was built
+     * over - the very same schema generation, because {@link 
PaimonConnectorMetadata#getSysTableHandle}
+     * builds both from one load. {@code null} for a normal handle and for the 
catalog fallback
+     * (format / object tables, which have no {@code FileStoreTable} base).
+     *
+     * <p>It exists so {@code PaimonScanPlanProvider} can rebuild an 
equivalent wrapper over a
+     * catalog-less copy for the BE without ever asking the catalog for a 
second, possibly newer,
+     * generation: the FE would then plan on one generation while the BE 
materialized the splits on
+     * another, and a system table whose row type follows the base schema 
({@code $audit_log},
+     * {@code $binlog}, {@code $ro}) could reach the BE without the columns 
the FE asked for.
+     *
+     * <p>Transient like {@link #paimonTable}: after a serialization 
round-trip it is null and the BE
+     * simply gets the wrapper as it stands (the pre-#65867 behaviour), never 
a wrapper rebuilt over a
+     * base this handle did not resolve.
+     */
+    private transient FileStoreTable sysBaseTable;
+
     public PaimonTableHandle(String databaseName, String tableName,
             List<String> partitionKeys, List<String> primaryKeys) {
         this(databaseName, tableName, partitionKeys, primaryKeys, null, false);
@@ -182,11 +201,16 @@ public class PaimonTableHandle implements 
ConnectorTableHandle {
      * scanOptions — the snapshot-pinned read variant. The transient Table is 
copied over as-is; the
      * scan path applies {@code Table.copy(scanOptions)} at resolution time. 
branchName is preserved
      * because it is part of the handle identity.
+     *
+     * <p>{@link #sysBaseTable} travels with the Table it belongs to: pinning 
a system table does not
+     * change which base its wrapper was built over, and dropping it here 
would silently send the BE a
+     * catalog-ful wrapper for exactly the pinned reads that need the rebuilt 
one most.
      */
     public PaimonTableHandle withScanOptions(Map<String, String> options) {
         PaimonTableHandle copy = new PaimonTableHandle(databaseName, tableName,
                 partitionKeys, primaryKeys, sysTableName, forceJni, options, 
branchName);
         copy.paimonTable = this.paimonTable;
+        copy.sysBaseTable = this.sysBaseTable;
         return copy;
     }
 
@@ -216,6 +240,19 @@ public class PaimonTableHandle implements 
ConnectorTableHandle {
         this.paimonTable = paimonTable;
     }
 
+    /**
+     * The base table this system handle's wrapper was built over, or null 
(normal handle, catalog
+     * fallback, or a deserialized handle). See {@link #sysBaseTable}.
+     */
+    public FileStoreTable getSysBaseTable() {
+        return sysBaseTable;
+    }
+
+    /** Sets the base table this system handle's wrapper was built over. See 
{@link #sysBaseTable}. */
+    public void setSysBaseTable(FileStoreTable sysBaseTable) {
+        this.sysBaseTable = sysBaseTable;
+    }
+
     @Override
     public boolean equals(Object o) {
         if (this == o) {
diff --git 
a/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonBackendBoundTableTest.java
 
b/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonBackendBoundTableTest.java
new file mode 100644
index 00000000000..755d367caf9
--- /dev/null
+++ 
b/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonBackendBoundTableTest.java
@@ -0,0 +1,762 @@
+// 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.paimon.Snapshot;
+import org.apache.paimon.catalog.FileSystemCatalog;
+import org.apache.paimon.catalog.Identifier;
+import org.apache.paimon.data.GenericRow;
+import org.apache.paimon.fs.local.LocalFileIO;
+import org.apache.paimon.options.Options;
+import org.apache.paimon.privilege.AllGrantedPrivilegeChecker;
+import org.apache.paimon.privilege.PrivilegedFileStoreTable;
+import org.apache.paimon.schema.Schema;
+import org.apache.paimon.schema.SchemaManager;
+import org.apache.paimon.schema.TableSchema;
+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.TableSnapshot;
+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.system.ReadOptimizedTable;
+import org.apache.paimon.table.system.SystemTableLoader;
+import org.apache.paimon.types.DataField;
+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.charset.StandardCharsets;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Base64;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+
+/**
+ * The table {@link PaimonScanPlanProvider#getScanNodeProperties} serializes 
to the BE must carry no
+ * Paimon {@code CatalogLoader}: deserializing one drags the whole Hive 
metastore stack onto the BE
+ * classpath ({@code HiveConf}, the metastore API, and for a version-managed 
catalog even the
+ * metastore client). These tests pin BOTH halves of that: the loader really 
is gone, and everything
+ * the loader used to do on the BE — snapshot resolution, query authorization, 
the fallback-branch
+ * pair, the relation's own scan params — is done on the FE instead, so 
dropping it changes no rows.
+ *
+ * <p>No mocking framework (this module has none by convention): the 
divergence a version-managed
+ * catalog creates is reproduced for real by {@link VersionManagedCatalog}, 
whose {@code loadSnapshot}
+ * answers a pointer the test sets while the snapshot directory on disk holds 
something newer —
+ * exactly the publication window / post-rollback shape the pin exists for.
+ */
+public class PaimonBackendBoundTableTest {
+
+    private static final DataField C1 = new DataField(0, "c1", 
DataTypes.INT());
+    private static final DataField C2 = new DataField(1, "c2", 
DataTypes.INT());
+
+    private static PaimonScanPlanProvider provider() {
+        return new PaimonScanPlanProvider(Collections.emptyMap(), null);
+    }
+
+    private static PaimonConnectorMetadata metadata(RecordingPaimonCatalogOps 
ops) {
+        return new PaimonConnectorMetadata(ops, Collections.emptyMap(), new 
RecordingConnectorContext());
+    }
+
+    // ==================== the loader itself ====================
+
+    @Test
+    public void dropCatalogLoaderKeepsEverythingButTheLoader(@TempDir Path 
warehouse) {
+        VersionManagedCatalog catalog = new VersionManagedCatalog(warehouse, 
false);
+        FileStoreTable table = newTable(warehouse, 
catalogEnvironment(catalog), C1);
+
+        FileStoreTable catalogLess = 
PaimonScanPlanProvider.dropCatalogLoader(table);
+
+        // The loader is the only thing the BE must not deserialize; 
everything a FileStoreTable is
+        // defined by has to survive, or the BE reads a different table than 
the FE planned.
+        Assertions.assertNull(catalogLess.catalogEnvironment().catalogLoader(),
+                "the catalog loader must not reach the BE");
+        Assertions.assertEquals(table.rowType(), catalogLess.rowType());
+        Assertions.assertEquals(table.location(), catalogLess.location());
+        Assertions.assertEquals(table.schema().id(), 
catalogLess.schema().id());
+        Assertions.assertEquals(table.schema().options(), 
catalogLess.schema().options());
+    }
+
+    @Test
+    public void dataTableReachesTheBackendWithoutItsCatalogLoader(@TempDir 
Path warehouse) {
+        VersionManagedCatalog catalog = new VersionManagedCatalog(warehouse, 
false);
+        FileStoreTable table = newTable(warehouse, 
catalogEnvironment(catalog), C1);
+
+        Table forBackend = provider().tableForBackend(dataHandle(), table);
+
+        Assertions.assertNull(((FileStoreTable) 
forBackend).catalogEnvironment().catalogLoader(),
+                "a plain data table must also be stripped, not only system 
tables");
+    }
+
+    @Test
+    public void theSerializedTablePropertyIsTheStrippedOne(@TempDir Path 
warehouse) throws Exception {
+        // The wiring, end to end: whatever tableForBackend returns is what 
actually lands in
+        // paimon.serialized_table and gets Java-deserialized on the BE. 
Asserting only on
+        // tableForBackend would leave getScanNodeProperties free to serialize 
the catalog-ful table.
+        VersionManagedCatalog catalog = new VersionManagedCatalog(warehouse, 
false);
+        FileStoreTable table = newTable(warehouse, 
catalogEnvironment(catalog), C1);
+        PaimonTableHandle handle = dataHandle();
+        handle.setPaimonTable(table);
+        Assertions.assertNotNull(table.catalogEnvironment().catalogLoader(),
+                "precondition: the FE-side table really does carry a loader");
+
+        Map<String, String> props = provider().getScanNodeProperties(null, 
handle,
+                Collections.emptyList(), Optional.empty());
+
+        FileStoreTable onBackend = 
deserializeTable(props.get("paimon.serialized_table"));
+        Assertions.assertNull(onBackend.catalogEnvironment().catalogLoader(),
+                "the BE must never deserialize a CatalogLoader (it drags in 
the Hive metastore stack)");
+        Assertions.assertEquals(table.rowType(), onBackend.rowType());
+    }
+
+    // ==================== schema generation ====================
+
+    @Test
+    public void 
systemTableForBackendFollowsTheFeWrapperSchemaGeneration(@TempDir Path 
warehouse) {
+        // $audit_log / $binlog / $ro derive their row type from the base 
table schema. The FE plans
+        // on the wrapper the sys handle carries; rebuilding the table 
serialized to the BE over a
+        // DIFFERENT generation of that base makes BE reject the query in
+        // PaimonJniScanner#getProjected ("RequiredField c2 not found in 
schema") or read a stale
+        // type under the same name. Both wrappers must come from one 
generation.
+        VersionManagedCatalog catalog = new VersionManagedCatalog(warehouse, 
false);
+        FileStoreTable planned = newTable(warehouse, 
catalogEnvironment(catalog), C1, C2);
+        Table feWrapper = SystemTableLoader.load("audit_log", planned);
+
+        Table forBackend = provider().tableForBackend(sysHandle("audit_log", 
planned), feWrapper);
+
+        Assertions.assertEquals(feWrapper.rowType(), forBackend.rowType());
+        
Assertions.assertTrue(forBackend.rowType().getFieldNames().contains("c2"),
+                "the BE must see the generation the FE planned with");
+    }
+
+    @Test
+    public void systemTableWithoutACapturedBaseIsHandedOverUntouched(@TempDir 
Path warehouse) {
+        // A format / object table has no FileStoreTable base, so 
getSysTableHandle falls back to the
+        // catalog and captures nothing. Rebuilding from some other base would 
be a guess; the
+        // pre-#65867 behaviour (hand the wrapper over as it stands) is the 
safe answer.
+        VersionManagedCatalog catalog = new VersionManagedCatalog(warehouse, 
false);
+        FileStoreTable planned = newTable(warehouse, 
catalogEnvironment(catalog), C1);
+        Table feWrapper = SystemTableLoader.load("audit_log", planned);
+        PaimonTableHandle handle = PaimonTableHandle.forSystemTable("db", 
"tbl", "audit_log", true);
+
+        Assertions.assertSame(feWrapper, provider().tableForBackend(handle, 
feWrapper));
+    }
+
+    // ==================== capturing that base in the first place 
====================
+
+    @Test
+    public void sysTableHandleCarriesTheBaseItsWrapperWasBuiltOver(@TempDir 
Path warehouse) {
+        // The wrapper and the base must come from ONE load, or "the 
generation the FE planned with"
+        // is unknowable downstream. Loading the wrapper through the catalog's 
4-arg sys Identifier
+        // would give it a generation of its own, so the wrapper is built 
here, over the base handle's
+        // own Table, and that base travels with the sys handle.
+        VersionManagedCatalog catalog = new VersionManagedCatalog(warehouse, 
false);
+        FileStoreTable base = newTable(warehouse, catalogEnvironment(catalog), 
C1, C2);
+        PaimonTableHandle baseHandle = dataHandle();
+        baseHandle.setPaimonTable(base);
+        RecordingPaimonCatalogOps ops = new RecordingPaimonCatalogOps();
+
+        PaimonTableHandle sysHandle = (PaimonTableHandle) metadata(ops)
+                .getSysTableHandle(null, baseHandle, 
"audit_log").orElseThrow(AssertionError::new);
+
+        Assertions.assertSame(base, sysHandle.getSysBaseTable(),
+                "the sys handle must carry the very base its wrapper was built 
over");
+        Assertions.assertEquals(base.rowType().getFieldNames().size() + 1,
+                sysHandle.getPaimonTable().rowType().getFieldNames().size(),
+                "$audit_log is the base row type plus rowkind, i.e. built over 
that same base");
+        Assertions.assertNull(ops.lastGetTableId,
+                "a FileStoreTable base needs no second catalog round-trip for 
its system table");
+    }
+
+    @Test
+    public void 
sysTableHandleBuildsTheWrapperOverThePeeledFallbackPair(@TempDir Path warehouse)
+            throws Exception {
+        // With file based privileges enabled the meta cache holds 
Privileged(FallbackRead(..)) - a
+        // shape paimon's own catalog never lets a system table see, because 
CatalogUtils#loadTable
+        // builds one over the raw FileStoreTableFactory result. 
ReadOptimizedTable#newScan dispatches
+        // on a DIRECT instanceof, so with the privilege wrapper still in 
between it plans the main
+        // branch alone through the pair's inherited newSnapshotReader() and 
silently drops every
+        // fallback-only partition.
+        FileStoreTable[] branches = 
fallbackPairWithNewerGenerationOnDisk(warehouse, "fb_sys");
+        FileStoreTable pair = new FallbackReadFileStoreTable(branches[0], 
branches[1]);
+        FileStoreTable decorated = PrivilegedFileStoreTable.wrap(pair,
+                new AllGrantedPrivilegeChecker(), Identifier.create("db", 
"tbl"));
+        PaimonTableHandle baseHandle = dataHandle();
+        baseHandle.setPaimonTable(decorated);
+
+        PaimonTableHandle sysHandle = (PaimonTableHandle) metadata(new 
RecordingPaimonCatalogOps())
+                .getSysTableHandle(null, baseHandle, 
"ro").orElseThrow(AssertionError::new);
+
+        Assertions.assertSame(pair, sysHandle.getSysBaseTable(),
+                "the decorator must be peeled, but not the fallback pair under 
it");
+        Assertions.assertTrue(((ReadOptimizedTable) 
sysHandle.getPaimonTable()).newScan()
+                        instanceof FallbackReadFileStoreTable.FallbackReadScan,
+                "the FE must plan tbl$ro over BOTH branches");
+        // What that stands for: leaving the decorator on takes newScan down 
its single-branch path.
+        Assertions.assertFalse(((ReadOptimizedTable) 
SystemTableLoader.load("ro", decorated)).newScan()
+                        instanceof 
FallbackReadFileStoreTable.FallbackReadScan);
+    }
+
+    @Test
+    public void pinningASysHandleKeepsItsCapturedBase(@TempDir Path warehouse) 
{
+        // applySnapshot rebuilds the handle through withScanOptions for every 
@options / @incr read.
+        // Losing the captured base there would send the BE a catalog-ful 
wrapper for exactly the
+        // pinned reads that need the rebuilt one most.
+        VersionManagedCatalog catalog = new VersionManagedCatalog(warehouse, 
false);
+        FileStoreTable base = newTable(warehouse, catalogEnvironment(catalog), 
C1);
+
+        PaimonTableHandle pinned = sysHandle("ro", base).withScanOptions(
+                Collections.singletonMap("scan.snapshot-id", "7"));
+
+        Assertions.assertSame(base, pinned.getSysBaseTable());
+    }
+
+    // ==================== the snapshot the BE plans on ====================
+
+    @Test
+    public void 
pinsTheCatalogVisibleSnapshotForAVersionManagedCatalog(@TempDir Path warehouse)
+            throws Exception {
+        // A version-managed (REST / DLF REST) catalog owns the committed 
snapshot pointer. Without
+        // the loader the BE resolves "latest" by listing the snapshot 
directory, so it can plan on a
+        // snapshot the catalog has not published yet - or one a rollback left 
behind - while the FE
+        // planned on the previous one.
+        VersionManagedCatalog catalog = new VersionManagedCatalog(warehouse, 
true);
+        FileStoreTable onDisk = commit(newRealTable(warehouse, "t"), 2);
+        // The catalog still points at snapshot 1 while 2 already sits in the 
snapshot directory.
+        catalog.pointAt(onDisk, 1L);
+        FileStoreTable versionManaged = withCatalogEnvironment(onDisk, 
catalogEnvironment(catalog));
+
+        FileStoreTable pinned = PaimonScanPlanProvider.pinCatalogSnapshot(
+                PaimonScanPlanProvider.dropCatalogLoader(versionManaged), 
versionManaged);
+
+        Assertions.assertEquals("1", pinned.options().get("scan.snapshot-id"),
+                "the BE must be pinned to the catalog's pointer, not to the 
newest snapshot file");
+        Assertions.assertEquals(2L, 
onDisk.snapshotManager().latestSnapshotIdFromFileSystem(),
+                "the divergence this pins against must be real, not an 
artifact of the fixture");
+    }
+
+    @Test
+    public void leavesTheSnapshotUnpinnedForAFilesystemCatalog(@TempDir Path 
warehouse) throws Exception {
+        // A catalog that does not manage versions keeps filesystem semantics 
on both sides, so there
+        // is nothing to pin and the BE must stay on "latest".
+        VersionManagedCatalog catalog = new VersionManagedCatalog(warehouse, 
false);
+        FileStoreTable plain = withCatalogEnvironment(
+                commit(newRealTable(warehouse, "t"), 1), 
catalogEnvironment(catalog));
+
+        Assertions.assertNull(PaimonScanPlanProvider
+                
.pinCatalogSnapshot(PaimonScanPlanProvider.dropCatalogLoader(plain), plain)
+                .options().get("scan.snapshot-id"));
+    }
+
+    @Test
+    public void 
pinsEverySystemTableThatResolvesItsSnapshotOnTheBackend(@TempDir Path warehouse)
+            throws Exception {
+        // $files / $partitions re-plan the base table inside the BE reader; 
$manifests /
+        // $table_indexes / $statistics resolve their snapshot directly there 
(TimeTravelUtil
+        // #tryTravelOrLatest). All five honor scan.snapshot-id and have a 
fixed row type, so all five
+        // must be bound to what the catalog sees before they leave the FE.
+        for (String sysTableType : Arrays.asList("files", "partitions", 
"manifests", "statistics",
+                "table_indexes")) {
+            VersionManagedCatalog catalog = new 
VersionManagedCatalog(warehouse, true);
+            FileStoreTable onDisk = commit(newRealTable(warehouse, "t_" + 
sysTableType), 1);
+            catalog.pointAt(onDisk, 1L);
+            FileStoreTable versionManaged = withCatalogEnvironment(onDisk, 
catalogEnvironment(catalog));
+
+            provider().tableForBackend(sysHandle(sysTableType, versionManaged),
+                    SystemTableLoader.load(sysTableType, versionManaged));
+
+            Assertions.assertTrue(catalog.loadSnapshotCalls > 0,
+                    "$" + sysTableType + " must be bound to the catalog's 
snapshot on the FE");
+        }
+    }
+
+    @Test
+    public void 
keepsThePinOffSystemTablesWhoseRowTypeFollowsTheBaseTable(@TempDir Path 
warehouse)
+            throws Exception {
+        // PaimonJniScanner#initTable calls table.copy(table.options()) 
unconditionally, and every
+        // system-table wrapper delegates copy to FileStoreTable#copy, which 
time-travels the schema
+        // to scan.snapshot-id. $audit_log / $ro / $binlog / $row_tracking 
derive their row type from
+        // the base table, so pinning them would rewind the BE schema: c2, 
added after the pinned
+        // snapshot, is planned by the FE and then rejected by getProjected() 
with "RequiredField c2
+        // not found in schema".
+        VersionManagedCatalog catalog = new VersionManagedCatalog(warehouse, 
true);
+        FileStoreTable versionManaged = newTable(warehouse, 
catalogEnvironment(catalog), C1, C2);
+
+        Table forBackend = provider().tableForBackend(sysHandle("audit_log", 
versionManaged),
+                SystemTableLoader.load("audit_log", versionManaged));
+
+        Assertions.assertNull(forBackend.options().get("scan.snapshot-id"),
+                "a row-type-following system table must not be time-travelled 
on the BE");
+        
Assertions.assertTrue(forBackend.rowType().getFieldNames().contains("c2"));
+        Assertions.assertEquals(0, catalog.loadSnapshotCalls,
+                "not merely unpinned: the catalog's pointer must never even be 
consulted");
+    }
+
+    // ==================== authorization the BE can no longer do 
====================
+
+    @Test
+    public void 
authorizesTheDeferredScanBeforeDroppingTheCatalogLoader(@TempDir Path 
warehouse) {
+        // $files only plans partition-level splits on the FE and re-plans the 
base table on the BE,
+        // where Catalog#authTableQuery is what enforces query-auth. A 
loader-less table turns that
+        // check into a permanent allow, so a denied query would come back as 
a successful metadata
+        // read: authorize here instead, while the loader is still around.
+        VersionManagedCatalog catalog = new VersionManagedCatalog(warehouse, 
true);
+        catalog.denyAuthWith("no privilege for db.tbl");
+        FileStoreTable authorized = newTable(warehouse, 
catalogEnvironment(catalog),
+                queryAuthEnabled(), C1);
+
+        RuntimeException denied = 
Assertions.assertThrows(RuntimeException.class,
+                () -> provider().tableForBackend(sysHandle("files", 
authorized),
+                        SystemTableLoader.load("files", authorized)));
+
+        Assertions.assertTrue(rootCauseMessage(denied).contains("no privilege 
for db.tbl"),
+                "a denied query must not reach the BE as a catalog-less 
table");
+    }
+
+    @Test
+    public void leavesDataSystemTablesTheirOwnProjectedAuthorization(@TempDir 
Path warehouse) {
+        // $ro / $row_tracking / $audit_log / $binlog keep planning on the FE 
through
+        // DataTableBatchScan, which already calls authTableQuery with the 
slot projection
+        // (auth(readType.getFieldNames())). A second auth(null) here means 
"every column", so a user
+        // allowed to read only c1 would be rejected for "SELECT c1 FROM 
tbl$ro".
+        VersionManagedCatalog catalog = new VersionManagedCatalog(warehouse, 
true);
+        FileStoreTable dataTable = newTable(warehouse, 
catalogEnvironment(catalog),
+                queryAuthEnabled(), C1);
+
+        provider().tableForBackend(sysHandle("ro", dataTable), 
SystemTableLoader.load("ro", dataTable));
+
+        Assertions.assertTrue(catalog.authCalls.isEmpty(),
+                "only $files, which loses its authorization by re-planning on 
the BE, may transfer it");
+    }
+
+    // ==================== the fallback-branch pair ====================
+
+    @Test
+    public void fallbackBranchKeepsTheGenerationTheFeCaptured(@TempDir Path 
warehouse) throws Exception {
+        // FallbackReadFileStoreTable#schema() only exposes its main branch, 
so rebuilding it through
+        // FileStoreTableFactory#create re-reads the fallback branch from
+        // SchemaManager(fallbackBranch).latest() instead of the object the FE 
planned with. After
+        // external DDL publishes a new generation the BE would get main M1 + 
fallback F2 and die in
+        // FallbackReadFileStoreTable#validateSchema.
+        FileStoreTable[] captured = 
fallbackPairWithNewerGenerationOnDisk(warehouse, "fb_generation");
+
+        FileStoreTable forBackend = PaimonScanPlanProvider.dropCatalogLoader(
+                new FallbackReadFileStoreTable(captured[0], captured[1]));
+
+        assertPairMatchesTheFeGeneration(forBackend, captured[0], captured[1]);
+    }
+
+    @Test
+    public void fallbackBranchSurvivesAPaimonTableDecorator(@TempDir Path 
warehouse) throws Exception {
+        // With file based privileges enabled PrivilegedCatalog#getTable hands 
out
+        // Privileged(FallbackRead(..)). A direct instanceof looks straight 
past that wrapper and
+        // rebuilds an ordinary table from the delegated main branch, so the 
BE loses the
+        // FallbackReadFileStoreTable.Read that dispatches a fallback split to 
the fallback branch -
+        // while the FE keeps planning the wrapper and can still emit one.
+        FileStoreTable[] captured = 
fallbackPairWithNewerGenerationOnDisk(warehouse, "fb_decorated");
+        FileStoreTable decorated = PrivilegedFileStoreTable.wrap(
+                new FallbackReadFileStoreTable(captured[0], captured[1]),
+                new AllGrantedPrivilegeChecker(), Identifier.create("db", 
"tbl"));
+
+        
assertPairMatchesTheFeGeneration(PaimonScanPlanProvider.dropCatalogLoader(decorated),
+                captured[0], captured[1]);
+    }
+
+    @Test
+    public void authorizesBothBranchesOfAFallbackPair(@TempDir Path warehouse) 
{
+        // FallbackReadFileStoreTable#newScan builds a FallbackReadScan over 
both branches' own scans,
+        // so each authorizes itself, and FileStoreTableFactory#create gives 
the fallback branch a
+        // CatalogEnvironment of its own carrying a branch-qualified 
Identifier. The pair delegates
+        // catalogEnvironment() to its main branch, so authorizing the pair 
checks main and silently
+        // skips the fallback branch - and once the loaders are dropped that 
missing check is a
+        // permanent allow, letting a user denied on the fallback branch read 
the fallback rows.
+        VersionManagedCatalog catalog = new VersionManagedCatalog(warehouse, 
true);
+        Identifier mainIdentifier = Identifier.create("db", "tbl");
+        Identifier fallbackIdentifier = new Identifier("db", "tbl", "fb");
+        Map<String, String> fallbackOptions = queryAuthEnabled();
+        fallbackOptions.put("branch", "fb");
+        FileStoreTable main = newTable(warehouse,
+                new CatalogEnvironment(mainIdentifier, null, () -> catalog, 
null, null, true),
+                queryAuthEnabled(), C1);
+        FileStoreTable fallback = newTable(warehouse,
+                new CatalogEnvironment(fallbackIdentifier, null, () -> 
catalog, null, null, true),
+                fallbackOptions, C1);
+
+        PaimonScanPlanProvider.authorizeDeferredScan(new 
FallbackReadFileStoreTable(main, fallback));
+
+        Assertions.assertEquals(Arrays.asList(mainIdentifier, 
fallbackIdentifier), catalog.authCalls,
+                "both branches must be authorized, each against its own 
identifier");
+    }
+
+    // ==================== the relation's own scan params ====================
+
+    @Test
+    public void relationOptionsSurviveTheRebuiltSystemTable(@TempDir Path 
warehouse) {
+        // The FE applies @options to the wrapper the handle carries 
(resolveScanTable ->
+        // PaimonScanParams#applyOptions), but the BE is handed a wrapper 
rebuilt over a catalog-less
+        // base - a different object, on which that copy() never ran. Without 
re-applying them the
+        // reader that materializes this table's splits falls back to the 
unpinned latest state.
+        VersionManagedCatalog catalog = new VersionManagedCatalog(warehouse, 
false);
+        FileStoreTable dataTable = newTable(warehouse, 
catalogEnvironment(catalog), C1);
+        Map<String, String> pinned = 
PaimonScanParams.markAsOptions(Collections.singletonMap(
+                org.apache.paimon.CoreOptions.SCAN_MANIFEST_PARALLELISM.key(), 
"4"));
+        PaimonTableHandle handle = sysHandle("ro", 
dataTable).withScanOptions(pinned);
+
+        Table forBackend = provider().tableForBackend(handle,
+                PaimonScanParams.applyOptions(SystemTableLoader.load("ro", 
dataTable), pinned));
+
+        // $ro delegates options() to the data table it wraps, so this reads 
the rebuilt base.
+        Assertions.assertEquals("4", forBackend.options()
+                
.get(org.apache.paimon.CoreOptions.SCAN_MANIFEST_PARALLELISM.key()));
+    }
+
+    @Test
+    public void incrementalRangeIsResolvedOnTheCatalogVisibleSnapshot(@TempDir 
Path warehouse)
+            throws Exception {
+        // Paimon selects the incremental scanner from incremental-between*, 
so the snapshot pin
+        // cannot bound this scan - and isolateIncrementalRead clears it 
anyway. The timestamp form
+        // would then resolve its endpoints inside the BE reader, through
+        // SnapshotManager#earlierOrEqualTimeMills, whose search runs up to 
latestSnapshotId(): the
+        // snapshot directory once the loader is gone. Resolve them here 
instead.
+        VersionManagedCatalog catalog = new VersionManagedCatalog(warehouse, 
true);
+        FileStoreTable onDisk = commit(newRealTable(warehouse, "t"), 2);
+        catalog.pointAt(onDisk, 1L);
+        FileStoreTable versionManaged = withCatalogEnvironment(onDisk, 
catalogEnvironment(catalog));
+        long firstCommitMillis = 
onDisk.snapshotManager().snapshot(1L).timeMillis();
+
+        Map<String, String> unbounded = new HashMap<>();
+        unbounded.put("incremental-between-timestamp", firstCommitMillis + "," 
+ Long.MAX_VALUE);
+        Map<String, String> bound =
+                PaimonIncrementalScanParams.bindRangeToCatalog(unbounded, 
versionManaged);
+
+        // The range closes on the catalog's snapshot 1, never on snapshot 2 
sitting on disk. The
+        // start is the snapshot at or before that wall clock - snapshot 1 
itself here - exactly what
+        // IncrementalDeltaStartingScanner#betweenTimestamps would have 
resolved on the BE.
+        Assertions.assertEquals("1,1", bound.get("incremental-between"));
+        // Cleared rather than dropped: Paimon's copy() removes a key only 
when it maps to null.
+        
Assertions.assertTrue(bound.containsKey("incremental-between-timestamp"));
+        Assertions.assertNull(bound.get("incremental-between-timestamp"));
+
+        // And a range that opens BEFORE the earliest snapshot keeps paimon's 
exclusive-start rule:
+        // the id before the earliest one, so the earliest snapshot itself is 
included.
+        Map<String, String> fromTheStart = new HashMap<>();
+        fromTheStart.put("incremental-between-timestamp", (firstCommitMillis - 
1) + "," + Long.MAX_VALUE);
+        Assertions.assertEquals("0,1", PaimonIncrementalScanParams
+                .bindRangeToCatalog(fromTheStart, 
versionManaged).get("incremental-between"));
+    }
+
+    @Test
+    public void 
incrementalRangeAlreadyOlderThanTheCatalogSnapshotIsLeftAlone(@TempDir Path 
warehouse)
+            throws Exception {
+        // An end older than the catalog's snapshot already resolves to the 
same id on both sides,
+        // because every snapshot the catalog has not published yet is younger 
than it.
+        VersionManagedCatalog catalog = new VersionManagedCatalog(warehouse, 
true);
+        FileStoreTable onDisk = commit(newRealTable(warehouse, "t"), 2);
+        catalog.pointAt(onDisk, 2L);
+        FileStoreTable versionManaged = withCatalogEnvironment(onDisk, 
catalogEnvironment(catalog));
+
+        Map<String, String> past = new HashMap<>();
+        past.put("incremental-between-timestamp",
+                "0," + (onDisk.snapshotManager().snapshot(2L).timeMillis() - 
1));
+
+        Assertions.assertSame(past,
+                PaimonIncrementalScanParams.bindRangeToCatalog(past, 
versionManaged));
+    }
+
+    @Test
+    public void incrementalRangeIsLeftAloneForAFilesystemCatalog(@TempDir Path 
warehouse) throws Exception {
+        VersionManagedCatalog catalog = new VersionManagedCatalog(warehouse, 
false);
+        FileStoreTable plain = withCatalogEnvironment(
+                commit(newRealTable(warehouse, "t"), 1), 
catalogEnvironment(catalog));
+
+        Map<String, String> range = new HashMap<>();
+        range.put("incremental-between-timestamp", "0," + Long.MAX_VALUE);
+
+        Assertions.assertSame(range, 
PaimonIncrementalScanParams.bindRangeToCatalog(range, plain));
+    }
+
+    @Test
+    public void incrementalRangeIsNotBoundOnAFallbackBranchPair(@TempDir Path 
warehouse) throws Exception {
+        // The two branches keep independent snapshot id sequences, and
+        // FallbackReadFileStoreTable#rewriteFallbackOptions translates only 
scan.snapshot-id, so
+        // incremental-between reaches the fallback branch verbatim. A 
main-branch id range bound here
+        // would be validated against the fallback branch's own range in
+        // IncrementalDeltaStartingScanner#betweenSnapshotIds and either fail 
out of range or select
+        // unrelated commits. The timestamp form is branch-agnostic and each 
branch resolves it
+        // against its own SnapshotManager, so it has to reach the BE 
untouched.
+        VersionManagedCatalog catalog = new VersionManagedCatalog(warehouse, 
true);
+        FileStoreTable onDisk = commit(newRealTable(warehouse, "t"), 2);
+        catalog.pointAt(onDisk, 1L);
+        // Stubbed so that the main branch alone WOULD have produced a range: 
without the guard this
+        // test sees "0,1" written into incremental-between, not an unchanged 
map.
+        FileStoreTable main = withCatalogEnvironment(onDisk, 
catalogEnvironment(catalog));
+        Map<String, String> fallbackOptions = new HashMap<>();
+        fallbackOptions.put("branch", "fb");
+        FileStoreTable fallback = newTable(warehouse, 
catalogEnvironment(catalog), fallbackOptions, C1);
+        FileStoreTable pair = new FallbackReadFileStoreTable(main, fallback);
+
+        Map<String, String> range = new HashMap<>();
+        range.put("incremental-between-timestamp",
+                onDisk.snapshotManager().snapshot(1L).timeMillis() + "," + 
Long.MAX_VALUE);
+
+        Assertions.assertSame(range, 
PaimonIncrementalScanParams.bindRangeToCatalog(range, pair));
+        // And through the decorator PrivilegedCatalog adds, since that is the 
shape Doris receives.
+        Assertions.assertSame(range, 
PaimonIncrementalScanParams.bindRangeToCatalog(range,
+                PrivilegedFileStoreTable.wrap(pair, new 
AllGrantedPrivilegeChecker(),
+                        Identifier.create("db", "tbl"))));
+        // Not merely "the output equals the input": the main branch's 
snapshots must never be read,
+        // because reading them is what produces an id range that cannot be 
carried to the fallback.
+        Assertions.assertEquals(0, catalog.loadSnapshotCalls);
+    }
+
+    @Test
+    public void 
incrementalPartitionsScanBindsItsRangeBeforeTheBackend(@TempDir Path warehouse)
+            throws Exception {
+        // $partitions is the one system table that both re-plans on the BE 
(PartitionsRead
+        // #createReader -> newScan().listPartitionEntries()) and accepts 
@incr, so it is the one that
+        // has to reach the BE with an already-resolved range.
+        VersionManagedCatalog catalog = new VersionManagedCatalog(warehouse, 
true);
+        FileStoreTable onDisk = commit(newRealTable(warehouse, "t"), 2);
+        catalog.pointAt(onDisk, 1L);
+        FileStoreTable versionManaged = withCatalogEnvironment(onDisk, 
catalogEnvironment(catalog));
+        Map<String, String> incremental = new HashMap<>();
+        incremental.put("incremental-between-timestamp",
+                onDisk.snapshotManager().snapshot(1L).timeMillis() + "," + 
Long.MAX_VALUE);
+        PaimonTableHandle handle = sysHandle("partitions", 
versionManaged).withScanOptions(incremental);
+
+        provider().tableForBackend(handle,
+                SystemTableLoader.load("partitions", 
versionManaged).copy(incremental));
+
+        // The range was closed here, on the catalog's snapshot, instead of 
inside the BE reader:
+        // pinCatalogSnapshot reads the pointer once and bindRangeToCatalog 
reads it again.
+        Assertions.assertTrue(catalog.loadSnapshotCalls >= 2,
+                "an @incr $partitions read must resolve its endpoints against 
the catalog");
+    }
+
+    // ==================== fixtures ====================
+
+    private static PaimonTableHandle dataHandle() {
+        return new PaimonTableHandle("db", "tbl", Collections.emptyList(), 
Collections.emptyList());
+    }
+
+    private static PaimonTableHandle sysHandle(String sysTableType, 
FileStoreTable base) {
+        PaimonTableHandle handle = PaimonTableHandle.forSystemTable("db", 
"tbl", sysTableType,
+                PaimonScanParams.requiresPaimonReader(sysTableType));
+        handle.setSysBaseTable(base);
+        return handle;
+    }
+
+    private static Map<String, String> queryAuthEnabled() {
+        Map<String, String> options = new HashMap<>();
+        options.put("query-auth.enabled", "true");
+        return options;
+    }
+
+    private static CatalogEnvironment catalogEnvironment(VersionManagedCatalog 
catalog) {
+        return new CatalogEnvironment(Identifier.create("db", "tbl"), null, () 
-> catalog, null, null,
+                catalog.supportsVersionManagement());
+    }
+
+    /** A table object with the given schema; no files are written, so nothing 
touches the disk. */
+    private static FileStoreTable newTable(Path warehouse, CatalogEnvironment 
env, DataField... fields) {
+        return newTable(warehouse, env, new HashMap<>(), fields);
+    }
+
+    private static FileStoreTable newTable(Path warehouse, CatalogEnvironment 
env,
+            Map<String, String> options, DataField... fields) {
+        List<DataField> fieldList = Arrays.asList(fields);
+        TableSchema schema = new TableSchema(0L, fieldList, fieldList.size() - 
1,
+                Collections.emptyList(), Collections.emptyList(), options, "");
+        return FileStoreTableFactory.create(LocalFileIO.create(),
+                new org.apache.paimon.fs.Path("file://" + warehouse + 
"/db.db/tbl"), schema, env);
+    }
+
+    /** A real, on-disk table so its {@code SnapshotManager} answers from real 
snapshot files. */
+    private static FileStoreTable newRealTable(Path warehouse, String name) 
throws Exception {
+        org.apache.paimon.fs.Path tablePath =
+                new org.apache.paimon.fs.Path("file://" + warehouse + 
"/db.db/" + name);
+        LocalFileIO fileIO = LocalFileIO.create();
+        new SchemaManager(fileIO, tablePath).createTable(new Schema(
+                Collections.singletonList(C1), Collections.emptyList(), 
Collections.emptyList(),
+                Collections.emptyMap(), ""));
+        return FileStoreTableFactory.create(fileIO, tablePath);
+    }
+
+    private static FileStoreTable commit(FileStoreTable table, int snapshots) 
throws Exception {
+        for (int i = 0; i < snapshots; i++) {
+            BatchWriteBuilder writeBuilder = table.newBatchWriteBuilder();
+            try (BatchTableWrite write = writeBuilder.newWrite();
+                    BatchTableCommit commit = writeBuilder.newCommit()) {
+                write.write(GenericRow.of(i));
+                commit.commit(write.prepareCommit());
+            }
+        }
+        return table;
+    }
+
+    /** Re-binds an on-disk table to a catalog environment, the way a catalog 
load would. */
+    private static FileStoreTable withCatalogEnvironment(FileStoreTable table, 
CatalogEnvironment env) {
+        return 
FileStoreTableFactory.createWithoutFallbackBranch(table.fileIO(), 
table.location(),
+                table.schema(), new Options(), env);
+    }
+
+    /**
+     * The {@code {main, fallback}} pair the FE captured (M1 / F1), with a 
newer fallback generation
+     * (F2, one column wider) already published on the filesystem - so a 
rebuild that re-reads the
+     * branch instead of reusing the captured object shows up in the row type.
+     */
+    private static FileStoreTable[] fallbackPairWithNewerGenerationOnDisk(Path 
warehouse, String name)
+            throws Exception {
+        org.apache.paimon.fs.Path tablePath =
+                new org.apache.paimon.fs.Path("file://" + warehouse + 
"/db.db/" + name);
+        // F2: the generation external DDL has already published on the 
fallback branch.
+        new SchemaManager(LocalFileIO.create(), tablePath, 
"fb").createTable(new Schema(
+                Arrays.asList(C1, C2), Collections.emptyList(), 
Collections.emptyList(),
+                Collections.emptyMap(), ""));
+
+        // F1 / M1: what the FE captured and planned this query with.
+        Map<String, String> mainOptions = new HashMap<>();
+        mainOptions.put("scan.fallback-branch", "fb");
+        Map<String, String> fallbackOptions = new HashMap<>();
+        fallbackOptions.put("branch", "fb");
+        return new FileStoreTable[] {
+                branchTable(warehouse, tablePath, mainOptions),
+                branchTable(warehouse, tablePath, fallbackOptions)};
+    }
+
+    /**
+     * One branch of a {@code scan.fallback-branch} pair, built the way Paimon 
builds them: without
+     * re-expanding the fallback branch, so the caller can wrap the two itself.
+     */
+    private static FileStoreTable branchTable(Path warehouse, 
org.apache.paimon.fs.Path tablePath,
+            Map<String, String> options) {
+        TableSchema schema = new TableSchema(0L, 
Collections.singletonList(C1), 0,
+                Collections.emptyList(), Collections.emptyList(), options, "");
+        return 
FileStoreTableFactory.createWithoutFallbackBranch(LocalFileIO.create(), 
tablePath,
+                schema, new Options(),
+                catalogEnvironment(new VersionManagedCatalog(warehouse, 
false)));
+    }
+
+    private static void assertPairMatchesTheFeGeneration(FileStoreTable 
forBackend, FileStoreTable main,
+            FileStoreTable fallback) {
+        Assertions.assertTrue(forBackend instanceof FallbackReadFileStoreTable,
+                "the pair itself must reach the BE, or a fallback split has no 
reader there");
+        FallbackReadFileStoreTable pair = (FallbackReadFileStoreTable) 
forBackend;
+        // The fallback branch must stay on F1 - not the c2 generation sitting 
on the filesystem.
+        Assertions.assertEquals(fallback.rowType(), pair.fallback().rowType());
+        Assertions.assertEquals(main.rowType(), pair.wrapped().rowType());
+        // And neither branch may carry the loader that drags the metastore 
stack onto the BE.
+        
Assertions.assertNull(pair.wrapped().catalogEnvironment().catalogLoader());
+        
Assertions.assertNull(pair.fallback().catalogEnvironment().catalogLoader());
+    }
+
+    /** Decodes {@code paimon.serialized_table} the way {@code 
PaimonJniScanner#initTable} does. */
+    private static FileStoreTable deserializeTable(String encoded) throws 
Exception {
+        byte[] raw = encoded.getBytes(StandardCharsets.UTF_8);
+        byte[] bytes;
+        try {
+            bytes = Base64.getUrlDecoder().decode(raw);
+        } catch (IllegalArgumentException urlReject) {
+            bytes = Base64.getDecoder().decode(raw);
+        }
+        return InstantiationUtil.deserializeObject(bytes,
+                PaimonBackendBoundTableTest.class.getClassLoader());
+    }
+
+    private static String rootCauseMessage(Throwable t) {
+        Throwable cause = t;
+        StringBuilder messages = new StringBuilder();
+        while (cause != null) {
+            messages.append(cause.getMessage()).append('\n');
+            cause = cause.getCause();
+        }
+        return messages.toString();
+    }
+
+    /**
+     * A real {@link FileSystemCatalog} that additionally answers the two 
calls the BE would have made
+     * through the catalog loader: {@code loadSnapshot} (the 
committed-snapshot pointer a
+     * version-managed catalog owns, which the test sets independently of the 
snapshot directory) and
+     * {@code authTableQuery} (recorded, and optionally denied). Both are 
{@code
+     * UnsupportedOperationException} on the base class, so overriding them is 
what makes an offline
+     * version-managed catalog possible at all.
+     */
+    private static final class VersionManagedCatalog extends FileSystemCatalog 
{
+
+        private final boolean supportsVersionManagement;
+        private final Map<String, TableSnapshot> pointers = new HashMap<>();
+        private final List<Identifier> authCalls = new ArrayList<>();
+        private String authDenialMessage;
+        private int loadSnapshotCalls;
+
+        private VersionManagedCatalog(Path warehouse, boolean 
supportsVersionManagement) {
+            super(LocalFileIO.create(), new 
org.apache.paimon.fs.Path("file://" + warehouse));
+            this.supportsVersionManagement = supportsVersionManagement;
+        }
+
+        /** Publishes {@code snapshotId} as the committed pointer, whatever 
the directory holds. */
+        private void pointAt(FileStoreTable table, long snapshotId) {
+            Snapshot snapshot = table.snapshotManager().snapshot(snapshotId);
+            pointers.put(table.location().getName(), new 
TableSnapshot(snapshot, 0L, 0L, 0L, 0L));
+        }
+
+        private void denyAuthWith(String message) {
+            this.authDenialMessage = message;
+        }
+
+        @Override
+        public boolean supportsVersionManagement() {
+            return supportsVersionManagement;
+        }
+
+        @Override
+        public Optional<TableSnapshot> loadSnapshot(Identifier identifier) {
+            loadSnapshotCalls++;
+            // Keyed by the on-disk table directory rather than the 
Identifier: the fixtures bind one
+            // catalog to tables built straight from a path, so the Identifier 
is the same for all.
+            return pointers.isEmpty()
+                    ? Optional.empty()
+                    : Optional.of(pointers.values().iterator().next());
+        }
+
+        @Override
+        public List<String> authTableQuery(Identifier identifier, List<String> 
select) {
+            authCalls.add(identifier);
+            if (authDenialMessage != null) {
+                throw new RuntimeException(authDenialMessage);
+            }
+            return Collections.emptyList();
+        }
+
+        @Override
+        public void close() {
+            // CatalogEnvironment#tableQueryAuth and SnapshotLoaderImpl#load 
both close the catalog
+            // they load; this instance is shared across those calls, so 
closing must not disarm it.
+        }
+    }
+}


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

Reply via email to