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

zhangstar333 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 ecdaf026564 [fix](paimon) Read row count estimates from snapshot 
metadata (#68135)
ecdaf026564 is described below

commit ecdaf026564c6e6dc0a9e9af8944810450f66400
Author: zhangstar333 <[email protected]>
AuthorDate: Fri Sep 18 16:21:23 2026 +0800

    [fix](paimon) Read row count estimates from snapshot metadata (#68135)
    
    ### What problem does this PR solve?
    Problem Summary:
    
    Ordinary Paimon queries request table cardinality during planning. On a
    row-count cache miss, the connector planned every split and summed file
    record counts, consuming CPU and memory proportional to the table's
    manifests and files even when the query selects only a small partition.
---
 .../doris/connector/paimon/PaimonCatalogOps.java   |  63 +++-
 .../connector/paimon/PaimonConnectorMetadata.java  |  23 +-
 .../paimon/PaimonCatalogRowCountTest.java          | 345 +++++++++++++++++++++
 .../PaimonConnectorMetadataStatisticsTest.java     |   2 +-
 4 files changed, 410 insertions(+), 23 deletions(-)

diff --git 
a/fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/PaimonCatalogOps.java
 
b/fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/PaimonCatalogOps.java
index c60b10f0062..b52ffd8f360 100644
--- 
a/fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/PaimonCatalogOps.java
+++ 
b/fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/PaimonCatalogOps.java
@@ -17,19 +17,23 @@
 
 package org.apache.doris.connector.paimon;
 
+import org.apache.paimon.CoreOptions;
 import org.apache.paimon.Snapshot;
 import org.apache.paimon.catalog.Catalog;
 import org.apache.paimon.catalog.CatalogUtils;
 import org.apache.paimon.catalog.Database;
 import org.apache.paimon.catalog.Identifier;
 import org.apache.paimon.partition.Partition;
+import org.apache.paimon.privilege.PrivilegedFileStoreTable;
 import org.apache.paimon.rest.RESTCatalog;
 import org.apache.paimon.schema.Schema;
 import org.apache.paimon.schema.TableSchema;
+import org.apache.paimon.table.BucketMode;
 import org.apache.paimon.table.DataTable;
+import org.apache.paimon.table.FallbackReadFileStoreTable;
 import org.apache.paimon.table.FileStoreTable;
 import org.apache.paimon.table.Table;
-import org.apache.paimon.table.source.Split;
+import org.apache.paimon.table.source.snapshot.TimeTravelUtil;
 import org.apache.paimon.tag.Tag;
 import org.apache.paimon.types.DataField;
 
@@ -164,12 +168,9 @@ public interface PaimonCatalogOps {
     boolean branchExists(Table table, String branchName);
 
     /**
-     * Returns the total row count of {@code table} = sum of {@code 
split.rowCount()} over
-     * {@code table.newReadBuilder().newScan().plan().splits()} (legacy
-     * {@code PaimonExternalTable.fetchRowCount} / {@code 
PaimonSysExternalTable.fetchRowCount}).
-     * Returns a plain {@code long} (never a paimon {@code Split} list) so the 
metadata layer's
-     * &gt;0-else-UNKNOWN logic is unit-testable offline with {@code 
RecordingPaimonCatalogOps}
-     * ({@code FakePaimonTable.newReadBuilder()} throws).
+     * Returns an optimizer estimate from the selected snapshot's unmerged 
record count, or -1
+     * when snapshot metadata cannot describe the relation. Never plans splits 
to obtain statistics.
+     * Like the former sum of split row counts, this is not an exact count for 
primary-key tables.
      */
     long rowCount(Table table);
 
@@ -422,13 +423,49 @@ public interface PaimonCatalogOps {
 
         @Override
         public long rowCount(Table table) {
-            // Legacy PaimonExternalTable.fetchRowCount / 
PaimonSysExternalTable.fetchRowCount: sum
-            // the planned-split record counts.
-            long rowCount = 0;
-            for (Split split : 
table.newReadBuilder().newScan().plan().splits()) {
-                rowCount += split.rowCount();
+            // System/format tables have no data snapshot count. A fallback 
pair combines two
+            // branches, so its main snapshot alone cannot estimate the 
relation either.
+            if (!(table instanceof FileStoreTable)
+                    || 
PaimonTableDecorators.unwrapToFallbackOrBase((FileStoreTable) table)
+                            instanceof FallbackReadFileStoreTable) {
+                return -1;
             }
-            return rowCount;
+            FileStoreTable fileStoreTable = (FileStoreTable) table;
+            CoreOptions options = fileStoreTable.coreOptions();
+            // Batch scans can exclude level-0 files or postponed buckets even 
in full-snapshot mode.
+            // The snapshot counter includes those files; do not enumerate 
manifests to correct it.
+            if ((!fileStoreTable.primaryKeys().isEmpty() && 
options.batchScanSkipLevel0()
+                    && 
options.toConfiguration().get(CoreOptions.BATCH_SCAN_MODE) == 
CoreOptions.BatchScanMode.NONE)
+                    || options.bucket() == BucketMode.POSTPONE_BUCKET) {
+                return -1;
+            }
+            switch (options.startupMode()) {
+                case LATEST:
+                case LATEST_FULL:
+                case FROM_TIMESTAMP:
+                case FROM_SNAPSHOT:
+                case FROM_SNAPSHOT_FULL:
+                    break;
+                default:
+                    // Incremental/file-creation-time scans and unresolved 
compacted-full scans
+                    // do not read the full snapshot selected by 
TimeTravelUtil.
+                    return -1;
+            }
+            if (fileStoreTable instanceof PrivilegedFileStoreTable) {
+                // Match the old scan's SELECT check without planning any 
splits. TimeTravelUtil
+                // eagerly calls tagManager(), which requires INSERT on the 
privilege wrapper.
+                fileStoreTable.newScan();
+                fileStoreTable = 
PaimonTableDecorators.unwrapToFallbackOrBase(fileStoreTable);
+            }
+            // Catalog query authorization normally runs in scan.plan(), 
independently of the
+            // privilege wrapper's SELECT check. Preserve it without planning 
any file splits.
+            if (options.queryAuthEnabled()) {
+                
fileStoreTable.catalogEnvironment().tableQueryAuth(options).auth(null);
+            }
+            Snapshot snapshot = 
TimeTravelUtil.tryTravelOrLatest(fileStoreTable);
+            // Old snapshot versions can omit totalRecordCount; an empty table 
has no snapshot.
+            return snapshot == null || snapshot.totalRecordCount() == null
+                    ? -1 : snapshot.totalRecordCount();
         }
 
         @Override
diff --git 
a/fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/PaimonConnectorMetadata.java
 
b/fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/PaimonConnectorMetadata.java
index 4e69b899fb2..217f5cf5bf7 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
@@ -1595,13 +1595,10 @@ public class PaimonConnectorMetadata implements 
ConnectorMetadata {
     }
 
     /**
-     * Returns the base-table row count = sum of planned-split row counts 
(legacy
-     * {@code PaimonExternalTable.fetchRowCount}: {@code rowCount > 0 ? 
rowCount : UNKNOWN}). Shared
-     * by normal AND system paimon tables: fe-core {@code 
PluginDrivenSysExternalTable} inherits
-     * {@code PluginDrivenExternalTable.fetchRowCount}, and {@link 
#resolveTable} is sys-aware, so a
-     * sys handle plans its OWN synthetic table's splits (closes Finding 5.1 
with one override).
+     * Returns the base-table optimizer estimate from snapshot metadata 
without planning splits.
+     * System tables and scan modes without a whole-snapshot estimate report 
UNKNOWN.
      * Returns {@code Optional.empty()} (→ fe-core -1 / UNKNOWN) when the 
count is 0 (legacy parity)
-     * or planning fails (best-effort, like the other connector read paths — 
stats run in background
+     * or metadata loading fails (best-effort, like the other connector read 
paths — stats run in background
      * analysis / SHOW and must not surface a transient remote error as a 
query-killing exception).
      * {@code dataSize} is left UNKNOWN (-1): legacy computed no base-table 
dataSize here.
      */
@@ -1611,6 +1608,9 @@ public class PaimonConnectorMetadata implements 
ConnectorMetadata {
         PaimonTableHandle paimonHandle = (PaimonTableHandle) handle;
         long rowCount;
         try {
+            if 
(PaimonScanParams.getPinnedFileCreationTime(paimonHandle.getScanOptions()).isPresent())
 {
+                return Optional.empty();
+            }
             Table table = 
PaimonReaderOptions.runtimeSafeTable(resolveTable(paimonHandle));
             table = runtimeSafeSystemTable(paimonHandle, table, 
Collections.emptyMap());
             PaimonReaderOptions.validateEffectiveTable(table);
@@ -1628,8 +1628,8 @@ public class PaimonConnectorMetadata implements 
ConnectorMetadata {
     /**
      * Row count AS OF the pinned snapshot, for a time-travel read. Applies 
the snapshot to the handle (the
      * SAME {@link #applySnapshot} the scan path uses) and copies its scan 
options onto the resolved table,
-     * so the summed split row counts reflect the pinned snapshot / branch / 
tag &mdash; matching the rows
-     * the scan reads instead of the latest count. Any failure degrades to 
empty, and the caller then falls
+     * so the estimate reflects the pinned snapshot / branch / tag instead of 
the latest count.
+     * Any failure degrades to empty, and the caller then falls
      * back to the latest cached estimate (estimate-only, never a correctness 
concern).
      */
     @Override
@@ -1646,8 +1646,13 @@ public class PaimonConnectorMetadata implements 
ConnectorMetadata {
                 // first commit even though execution is still required to 
scan zero rows.
                 return Optional.empty();
             }
-            Table table = resolveTable(pinned);
             Map<String, String> scanOptions = pinned.getScanOptions();
+            // applyOptions removes this Doris-only marker, but execution 
still filters files by it.
+            // This also covers scan.creation-time-millis when it resolves to 
a file-creation scan.
+            if 
(PaimonScanParams.getPinnedFileCreationTime(scanOptions).isPresent()) {
+                return Optional.empty();
+            }
+            Table table = resolveTable(pinned);
             if (scanOptions != null && !scanOptions.isEmpty()) {
                 table = PaimonScanParams.isOptionsPin(scanOptions)
                         ? PaimonScanParams.applyOptions(table, scanOptions)
diff --git 
a/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonCatalogRowCountTest.java
 
b/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonCatalogRowCountTest.java
new file mode 100644
index 00000000000..d20a2c30911
--- /dev/null
+++ 
b/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonCatalogRowCountTest.java
@@ -0,0 +1,345 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package org.apache.doris.connector.paimon;
+
+import org.apache.doris.connector.spi.mvcc.ConnectorMvccSnapshot;
+
+import org.apache.paimon.CoreOptions;
+import org.apache.paimon.Snapshot;
+import org.apache.paimon.catalog.Catalog;
+import org.apache.paimon.catalog.FileSystemCatalog;
+import org.apache.paimon.catalog.Identifier;
+import org.apache.paimon.data.GenericRow;
+import org.apache.paimon.fs.SeekableInputStream;
+import org.apache.paimon.fs.local.LocalFileIO;
+import org.apache.paimon.privilege.PrivilegeChecker;
+import org.apache.paimon.privilege.PrivilegedFileStoreTable;
+import org.apache.paimon.schema.Schema;
+import org.apache.paimon.schema.SchemaManager;
+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.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.source.Split;
+import org.apache.paimon.table.system.SnapshotsTable;
+import org.apache.paimon.types.DataTypes;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import java.io.IOException;
+import java.lang.reflect.Proxy;
+import java.nio.file.Path;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicInteger;
+
+/** Snapshot statistics must not open manifests or construct a split plan. */
+public class PaimonCatalogRowCountTest {
+    @TempDir
+    Path warehouse;
+
+    private final PaimonCatalogOps ops = new 
PaimonCatalogOps.CatalogBackedPaimonCatalogOps(null);
+
+    @Test
+    public void latestAppendAndPrimaryKeyCountsNeedNoManifests() throws 
Exception {
+        for (boolean primaryKey : new boolean[] {false, true}) {
+            FileStoreTable table = newTable("latest_" + primaryKey, 
primaryKey);
+            snapshot(table, 1, 10L);
+            snapshot(table, 2, 25L);
+            Assertions.assertEquals(25L, ops.rowCount(table));
+        }
+    }
+
+    @Test
+    public void historicalSnapshotAndTimestampUseSelectedCount() throws 
Exception {
+        FileStoreTable table = newTable("history", true);
+        snapshot(table, 1, 10L);
+        snapshot(table, 2, 25L);
+        Assertions.assertEquals(10L, ops.rowCount(table.copy(
+                Collections.singletonMap("scan.snapshot-id", "1"))));
+        Assertions.assertEquals(10L, ops.rowCount(table.copy(
+                Collections.singletonMap("scan.timestamp-millis", "1500"))));
+    }
+
+    @Test
+    public void tagSurvivesExpiredSnapshot() throws Exception {
+        FileStoreTable table = newTable("tag", false);
+        snapshot(table, 1, 10L);
+        table.createTag("retained", 1L);
+        snapshot(table, 2, 25L);
+        table.fileIO().delete(table.snapshotManager().snapshotPath(1), false);
+        Assertions.assertEquals(10L, ops.rowCount(table.copy(
+                Collections.singletonMap("scan.tag-name", "retained"))));
+    }
+
+    @Test
+    public void branchUsesItsOwnSnapshot() throws Exception {
+        FileStoreTable table = newTable("branch", false);
+        snapshot(table, 1, 10L);
+        table.createBranch("dev");
+        FileStoreTable branch = table.switchToBranch("dev");
+        snapshot(branch, 1, 7L);
+        snapshot(table, 2, 25L);
+        Assertions.assertEquals(7L, ops.rowCount(branch));
+        Assertions.assertEquals(25L, ops.rowCount(table));
+    }
+
+    @Test
+    public void emptyAndLegacySnapshotsDoNotFallBackToPlanning() throws 
Exception {
+        FileStoreTable table = newTable("empty", false);
+        Assertions.assertEquals(-1L, ops.rowCount(table));
+        snapshot(table, 1, null);
+        Assertions.assertEquals(-1L, ops.rowCount(table));
+        snapshot(table, 2, 0L);
+        Assertions.assertEquals(0L, ops.rowCount(table));
+    }
+
+    @Test
+    public void partialScansDoNotUseWholeSnapshotCount() throws Exception {
+        FileStoreTable table = newTable("partial", false);
+        snapshot(table, 1, 10L);
+        snapshot(table, 2, 25L);
+        Assertions.assertEquals(-1L, ops.rowCount(table.copy(
+                Collections.singletonMap("incremental-between", "1,2"))));
+        Assertions.assertEquals(-1L, ops.rowCount(table.copy(
+                Collections.singletonMap("scan.file-creation-time-millis", 
"1500"))));
+        Assertions.assertEquals(-1L, ops.rowCount(table.copy(
+                Collections.singletonMap("scan.mode", "compacted-full"))));
+    }
+
+    @Test
+    public void systemAndFallbackTablesDoNotUseBaseCount() throws Exception {
+        FileStoreTable table = newTable("system", false);
+        snapshot(table, 1, 10L);
+        Assertions.assertEquals(-1L, ops.rowCount(new SnapshotsTable(table)));
+        Assertions.assertEquals(-1L, ops.rowCount(new FakePaimonTable("format",
+                DataTypes.ROW(DataTypes.FIELD(0, "id", DataTypes.INT())),
+                Collections.emptyList(), Collections.emptyList())));
+        FileStoreTable fallback = newTable("fallback", false);
+        snapshot(fallback, 1, 20L);
+        Assertions.assertEquals(-1L, ops.rowCount(new 
FallbackReadFileStoreTable(table, fallback)));
+    }
+
+    @Test
+    public void committedFilesExcludedByBatchScanReturnUnknown() throws 
Exception {
+        for (Map<String, String> options : List.of(
+                Collections.singletonMap("deletion-vectors.enabled", "true"),
+                Collections.singletonMap("merge-engine", "first-row"),
+                Collections.singletonMap("bucket", "-2"))) {
+            ManifestGuardFileIO fileIO = new ManifestGuardFileIO();
+            fileIO.rejectManifests = false;
+            FileStoreTable table = 
newTable(options.keySet().iterator().next(), true, options, fileIO);
+            append(table);
+            Assertions.assertEquals(1L, 
table.latestSnapshot().get().totalRecordCount().longValue());
+            Assertions.assertEquals(0L, 
table.newScan().plan().splits().stream().mapToLong(Split::rowCount).sum(),
+                    "The committed file must be invisible to the ordinary 
batch scan");
+
+            FileStoreTable compactScan = 
table.copy(Collections.singletonMap("batch-scan-mode", "compact"));
+            fileIO.rejectManifests = true;
+            Assertions.assertEquals(-1L, ops.rowCount(table));
+            Assertions.assertEquals(options.containsKey("bucket") ? -1L : 1L, 
ops.rowCount(compactScan));
+        }
+    }
+
+    @Test
+    public void committedOrdinaryTableKeepsSnapshotEstimate() throws Exception 
{
+        ManifestGuardFileIO fileIO = new ManifestGuardFileIO();
+        fileIO.rejectManifests = false;
+        FileStoreTable table = newTable("committed", true, 
Collections.emptyMap(), fileIO);
+        append(table);
+        Assertions.assertEquals(1L, 
table.newScan().plan().splits().stream().mapToLong(Split::rowCount).sum());
+        fileIO.rejectManifests = true;
+        Assertions.assertEquals(1L, ops.rowCount(table));
+    }
+
+    @Test
+    public void preservedPrivilegeWrapperNeedsOnlySelect() throws Exception {
+        FileStoreTable table = newTable("privileged", false);
+        snapshot(table, 1, 10L);
+        AtomicInteger selectChecks = new AtomicInteger();
+        AtomicBoolean denySelect = new AtomicBoolean();
+        PrivilegeChecker checker = (PrivilegeChecker) Proxy.newProxyInstance(
+                PrivilegeChecker.class.getClassLoader(), new Class<?>[] 
{PrivilegeChecker.class},
+                (proxy, method, args) -> {
+                    if (method.getName().equals("assertCanInsert")) {
+                        throw new SecurityException("INSERT denied");
+                    }
+                    if (method.getName().equals("assertCanSelect")
+                            || 
method.getName().equals("assertCanSelectOrInsert")) {
+                        selectChecks.incrementAndGet();
+                        if (denySelect.get()) {
+                            throw new SecurityException("SELECT denied");
+                        }
+                    }
+                    return null;
+                });
+        FileStoreTable privileged = PrivilegedFileStoreTable.wrap(table, 
checker, Identifier.create("db", "t"));
+        Assertions.assertSame(privileged, 
PaimonReaderOptions.runtimeSafeTable(privileged));
+        PaimonConnectorMetadata metadata = metadata();
+        PaimonTableHandle handle = handle(privileged);
+        Assertions.assertEquals(10L, metadata.getTableStatistics(null, 
handle).get().getRowCount());
+        Assertions.assertEquals(10L, metadata.getTableStatistics(null, handle,
+                
ConnectorMvccSnapshot.builder().snapshotId(1L).property("scan.snapshot-id", 
"1").build())
+                .get().getRowCount());
+        Assertions.assertTrue(selectChecks.get() >= 2);
+        denySelect.set(true);
+        Assertions.assertThrows(SecurityException.class, () -> 
ops.rowCount(privileged));
+    }
+
+    @Test
+    public void catalogQueryAuthorizationControlsSnapshotStatistics() throws 
Exception {
+        FileStoreTable base = newTable("query_auth", false,
+                Collections.singletonMap("query-auth.enabled", "true"), new 
ManifestGuardFileIO());
+        snapshot(base, 1, 10L);
+        Identifier identifier = Identifier.create("db", "t");
+        AtomicInteger authCalls = new AtomicInteger();
+        AtomicBoolean denyQuery = new AtomicBoolean();
+        try (Catalog catalog = new FileSystemCatalog(LocalFileIO.create(), 
base.location()) {
+            @Override
+            public List<String> authTableQuery(Identifier requested, 
List<String> select) {
+                Assertions.assertEquals(identifier, requested);
+                Assertions.assertNull(select, "Statistics must preserve the 
old all-column authorization");
+                authCalls.incrementAndGet();
+                if (denyQuery.get()) {
+                    throw new Catalog.TableNoPermissionException(requested);
+                }
+                return Collections.emptyList();
+            }
+
+            @Override
+            public Optional<TableSnapshot> loadSnapshot(Identifier requested) {
+                return Optional.of(new 
TableSnapshot(base.snapshotManager().snapshot(1L), 0L, 0L, 0L, 0L));
+            }
+        }) {
+            // Like a REST-loaded table, this has a catalog loader but no 
privilege wrapper.
+            CatalogEnvironment environment = new CatalogEnvironment(
+                    identifier, null, () -> catalog, null, null, false);
+            FileStoreTable table = FileStoreTableFactory.create(
+                    base.fileIO(), base.location(), base.schema(), 
environment);
+            PaimonConnectorMetadata metadata = metadata();
+            PaimonTableHandle handle = handle(table);
+            ConnectorMvccSnapshot pinned = ConnectorMvccSnapshot.builder()
+                    .snapshotId(1L).property("scan.snapshot-id", "1").build();
+
+            Assertions.assertEquals(10L, metadata.getTableStatistics(null, 
handle).get().getRowCount());
+            Assertions.assertEquals(10L, metadata.getTableStatistics(null, 
handle, pinned).get().getRowCount());
+            Assertions.assertEquals(2, authCalls.get());
+
+            denyQuery.set(true);
+            RuntimeException denied = 
Assertions.assertThrows(RuntimeException.class, () -> ops.rowCount(table));
+            
Assertions.assertInstanceOf(Catalog.TableNoPermissionException.class, 
denied.getCause());
+            Assertions.assertFalse(metadata.getTableStatistics(null, 
handle).isPresent());
+            Assertions.assertFalse(metadata.getTableStatistics(null, handle, 
pinned).isPresent());
+            Assertions.assertEquals(5, authCalls.get());
+
+            FileStoreTable disabled = 
table.copy(Collections.singletonMap("query-auth.enabled", "false"));
+            Assertions.assertEquals(10L, ops.rowCount(disabled));
+            Assertions.assertEquals(5, authCalls.get(), "Disabled query 
authorization must not contact the catalog");
+        }
+    }
+
+    @Test
+    public void normalizedFileCreationHandlesReturnUnknown() throws Exception {
+        ManifestGuardFileIO fileIO = new ManifestGuardFileIO();
+        fileIO.rejectManifests = false;
+        FileStoreTable table = newTable("creation", false, 
Collections.emptyMap(), fileIO);
+        append(table);
+        fileIO.rejectManifests = true;
+        PaimonConnectorMetadata metadata = metadata();
+        for (String key : new String[] {"scan.file-creation-time-millis", 
"scan.creation-time-millis"}) {
+            // A threshold before the first snapshot forces creation-time's 
file-filter fallback.
+            Map<String, String> resolved = 
PaimonScanParams.markAsOptions(PaimonScanParams.resolveOptions(
+                    table, Collections.singletonMap(key, "1")));
+            
Assertions.assertTrue(PaimonScanParams.getPinnedFileCreationTime(resolved).isPresent());
+            FileStoreTable selected = (FileStoreTable) 
PaimonScanParams.applyOptions(table, resolved);
+            Assertions.assertEquals(CoreOptions.StartupMode.FROM_SNAPSHOT, 
selected.coreOptions().startupMode());
+            Assertions.assertEquals(1L, ops.rowCount(selected), "The Table 
alone has lost the file filter");
+            ConnectorMvccSnapshot snapshot = 
ConnectorMvccSnapshot.builder().snapshotId(1L)
+                    .properties(resolved).build();
+            Assertions.assertFalse(metadata.getTableStatistics(null, 
handle(table), snapshot).isPresent());
+            Assertions.assertFalse(metadata.getTableStatistics(null,
+                    handle(table).withScanOptions(resolved)).isPresent());
+        }
+    }
+
+    private PaimonConnectorMetadata metadata() {
+        return new PaimonConnectorMetadata(ops, 
PaimonCatalogProperties.of(Collections.emptyMap()),
+                new RecordingConnectorContext());
+    }
+
+    private PaimonTableHandle handle(FileStoreTable table) {
+        PaimonTableHandle handle = new PaimonTableHandle("db", "t", 
table.partitionKeys(), table.primaryKeys());
+        handle.setPaimonTable(table);
+        return handle;
+    }
+
+    private void append(FileStoreTable table) throws Exception {
+        BatchWriteBuilder builder = table.newBatchWriteBuilder();
+        try (BatchTableWrite write = builder.newWrite(); BatchTableCommit 
commit = builder.newCommit()) {
+            write.write(GenericRow.of(1));
+            commit.commit(write.prepareCommit());
+        }
+    }
+
+    private FileStoreTable newTable(String name, boolean primaryKey) throws 
Exception {
+        return newTable(name, primaryKey, Collections.emptyMap(), new 
ManifestGuardFileIO());
+    }
+
+    private FileStoreTable newTable(String name, boolean primaryKey,
+            Map<String, String> options, LocalFileIO fileIO) throws Exception {
+        org.apache.paimon.fs.Path path = new 
org.apache.paimon.fs.Path(warehouse.resolve(name).toUri());
+        Schema.Builder schema = Schema.newBuilder().column("id", 
DataTypes.INT())
+                .option("file.format", "parquet").option("write-only", "true")
+                .option("scan.manifest.parallelism", "1");
+        if (primaryKey) {
+            schema.primaryKey("id").option("bucket", "1");
+        }
+        options.forEach(schema::option);
+        new SchemaManager(fileIO, path).createTable(schema.build());
+        return FileStoreTableFactory.create(fileIO, path);
+    }
+
+    private void snapshot(FileStoreTable table, long id, Long count) throws 
IOException {
+        // Deliberately no manifest files: a return to split planning must 
fail this test.
+        Snapshot snapshot = new Snapshot(id, 0L, "unused-base", null, 
"unused-delta", null,
+                null, null, null, "test", id, Snapshot.CommitKind.APPEND, id * 
1000,
+                Collections.emptyMap(), count, count, null, null, null, null, 
null);
+        
table.fileIO().mkdirs(table.snapshotManager().snapshotPath(id).getParent());
+        
table.fileIO().overwriteFileUtf8(table.snapshotManager().snapshotPath(id), 
snapshot.toJson());
+        table.snapshotManager().commitLatestHint(id);
+    }
+
+    private static class ManifestGuardFileIO extends LocalFileIO {
+        private boolean rejectManifests = true;
+
+        @Override
+        public SeekableInputStream newInputStream(org.apache.paimon.fs.Path 
path) throws IOException {
+            Assertions.assertFalse(rejectManifests && 
path.toString().contains("/manifest/"),
+                    "Row count estimation must not read manifests: " + path);
+            return super.newInputStream(path);
+        }
+    }
+}
diff --git 
a/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonConnectorMetadataStatisticsTest.java
 
b/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonConnectorMetadataStatisticsTest.java
index 410cb35ff3e..c8c528b6bc2 100644
--- 
a/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonConnectorMetadataStatisticsTest.java
+++ 
b/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonConnectorMetadataStatisticsTest.java
@@ -43,7 +43,7 @@ import java.util.Optional;
  * <p>Before the fix the connector inherited the default {@code 
ConnectorStatisticsOps} (returns
  * {@code Optional.empty()}), so every paimon table — normal AND system — 
reported row count -1
  * (UNKNOWN), degrading the Nereids cost model (join-reorder force-disabled) 
and SHOW/info_schema.
- * The fix overrides it to sum {@code split.rowCount()} via the {@code 
PaimonCatalogOps.rowCount}
+ * The connector obtains a snapshot estimate via the {@code 
PaimonCatalogOps.rowCount}
  * seam (faked here — {@code FakePaimonTable.newReadBuilder()} throws, the 
whole reason for the
  * seam). Each test FAILS before the fix (default empty) and PASSES after, and 
encodes WHY.
  */


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

Reply via email to