morningman commented on code in PR #65867:
URL: https://github.com/apache/doris/pull/65867#discussion_r3654831386


##########
fe/fe-core/src/main/java/org/apache/doris/datasource/paimon/source/PaimonScanNode.java:
##########
@@ -190,6 +202,182 @@ protected void doInitialize() throws UserException {
         }
     }
 
+    /**
+     * 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 
org.apache.paimon.table.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(...)}, 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, and for the system tables that pick their snapshot on 
the BE ({@link
+     *       #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.</li>
+     * </ul>
+     */
+    private Table getPaimonTableForBackend() {
+        Table paimonTable = source.getPaimonTable();
+        if (paimonTable instanceof FileStoreTable) {
+            return dropCatalogLoader((FileStoreTable) paimonTable);
+        }
+        if (!(source.getExternalTable() instanceof PaimonSysExternalTable)) {
+            return paimonTable;
+        }
+        PaimonSysExternalTable sysTable = (PaimonSysExternalTable) 
source.getExternalTable();
+        // 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 = sysTable.getSysBaseTable();
+        if (dataTable == null) {
+            return paimonTable;
+        }
+        String sysTableType = sysTable.getSysTableType();
+        if (PAIMON_FILES_SYSTEM_TABLE_TYPE.equalsIgnoreCase(sysTableType)) {
+            authorizeDeferredScan(dataTable);
+        }
+        FileStoreTable baseForBackend = dropCatalogLoader(dataTable);
+        if (resolvesSnapshotOnBackend(sysTableType)) {
+            baseForBackend = pinCatalogSnapshot(baseForBackend, dataTable);
+        }
+        Table catalogLessSysTable = SystemTableLoader.load(sysTableType, 
baseForBackend);
+        return catalogLessSysTable == null ? paimonTable : catalogLessSysTable;
+    }
+
+    /**
+     * 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 {@link #pinCatalogSnapshot} cannot 
rewind the BE schema.
+     */
+    private static boolean resolvesSnapshotOnBackend(String sysTableType) {
+        return PAIMON_FILES_SYSTEM_TABLE_TYPE.equalsIgnoreCase(sysTableType)
+                || 
PAIMON_PARTITIONS_SYSTEM_TABLE_TYPE.equalsIgnoreCase(sysTableType)
+                || 
PAIMON_MANIFESTS_SYSTEM_TABLE_TYPE.equalsIgnoreCase(sysTableType)
+                || 
PAIMON_STATISTICS_SYSTEM_TABLE_TYPE.equalsIgnoreCase(sysTableType)
+                || 
PAIMON_TABLE_INDEXES_SYSTEM_TABLE_TYPE.equalsIgnoreCase(sysTableType);
+    }
+
+    /**
+     * {@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, so it has no
+     * authorization to transfer.
+     */
+    private static void authorizeDeferredScan(FileStoreTable dataTable) {
+        CoreOptions options = dataTable.coreOptions();
+        if (options.queryAuthEnabled()) {
+            dataTable.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 #resolvesSnapshotOnBackend} system tables. {@code 
PaimonJniScanner#initTable}
+     * unconditionally calls {@code table.copy(table.options())}, and every 
Paimon system-table
+     * wrapper delegates {@code copy} to {@code FileStoreTable#copy}, which 
time-travels the schema
+     * to {@code scan.snapshot-id}. For a wrapper whose row type follows the 
base table
+     * ({@code $ro}, {@code $row_tracking}, {@code $audit_log}, {@code 
$binlog}) that would rewind
+     * the BE schema: a column added after the latest snapshot is planned by 
the FE and then
+     * rejected by {@code PaimonJniScanner#getProjected} with "RequiredField 
... not found in
+     * schema". The pinned ones all have a fixed row type, so the pin is safe 
there.
+     *
+     * <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: on a table with {@code scan.fallback-branch} this bounds 
the main branch only.
+     * {@code FallbackReadFileStoreTable#copyWithoutTimeTravel} derives the 
fallback branch's own
+     * bound from the pin ({@code rewriteFallbackOptions} -&gt;
+     * {@code SnapshotManager#earlierOrEqualTimeMills}), and that branch has 
no catalog loader left
+     * either, so its bound comes from the snapshot directory too - same 
transient window on the same
+     * read-only metadata tables.
+     */
+    private 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(PAIMON_SCAN_SNAPSHOT_ID, 
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.
+     */
+    private static FileStoreTable dropCatalogLoader(FileStoreTable dataTable) {
+        if (dataTable.catalogEnvironment().catalogLoader() == null) {
+            return dataTable;
+        }
+        if (dataTable instanceof FallbackReadFileStoreTable) {

Review Comment:
   Confirmed and fixed in e63a97be480 (`unwrapToFallbackOrBase`).
   
   Verified the reachability chain against paimon `release-1.3.1` rather than 
assuming it: `CatalogFactory#createCatalog:74` calls 
`PrivilegedCatalog.tryToCreate(..)`, and all five Doris 
`Paimon*MetaStoreProperties` build their catalog through that entry point; 
`PrivilegedCatalog#getTable:151-159` then wraps any `FileStoreTable` in 
`PrivilegedFileStoreTable`. So with file based privileges enabled the table 
does arrive here as `Privileged(FallbackRead(..))` and the direct `instanceof` 
looked straight past it.
   
   `dropCatalogLoader` now peels `DelegatedFileStoreTable` layers down to the 
fallback pair before matching. The decorators are peeled rather than 
re-applied: `PrivilegedFileStoreTable` only asserts on `newScan()` / 
`newRead()`, which the FE has already run while planning the query, and a plain 
(non fallback) table already loses the wrapper the same way because 
`rebuildWithoutCatalogLoader` builds a bare table out of fileIO / location / 
schema. `PrivilegedFileStoreTable` overrides none of `catalogEnvironment()` / 
`schema()` / `fileIO()` / `location()`, so the rebuild reads the same values 
through the delegation. `PrivilegedFileStoreTable` and 
`FallbackReadFileStoreTable` are the only two `DelegatedFileStoreTable` 
subclasses in paimon-core (`LookupFileStoreTable` is flink-only), so the loop 
terminates on the base table in every other case.
   
   Covered by 
`PaimonScanNodeTest#testFallbackBranchSurvivesAPaimonTableDecorator`, which 
pushes `Privileged(FallbackRead(M1, F1))` through `dropCatalogLoader` with a 
newer fallback generation F2 sitting on the filesystem, and asserts the result 
is still a `FallbackReadFileStoreTable` carrying M1/F1 with no catalog loader 
on either branch. Negative control run: with the peel reverted the test fails 
at the `instanceof FallbackReadFileStoreTable` assertion, since the table comes 
back rebuilt from the delegated main branch alone. Suite is 22/22 green.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


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

Reply via email to