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

yiguolei pushed a commit to branch branch-4.1
in repository https://gitbox.apache.org/repos/asf/doris.git


The following commit(s) were added to refs/heads/branch-4.1 by this push:
     new 094b27b7c4d [fix](paimon) Read row count estimates from snapshot 
metadata (#68155)
094b27b7c4d is described below

commit 094b27b7c4d92bc42ab80b326cf5e97b90966a9a
Author: zhangstar333 <[email protected]>
AuthorDate: Sat Sep 19 09:43:29 2026 +0800

    [fix](paimon) Read row count estimates from snapshot metadata (#68155)
    
    ### 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.
    
    ### Release note
    
    None
    
    ### Check List (For Author)
    
    - Test <!-- At least one of them must be included. -->
        - [ ] Regression test
        - [x] Unit Test
        - [ ] Manual test (add detailed scripts or steps below)
        - [ ] No need to test or manual test. Explain why:
    - [ ] This is a refactor/code format and no logic has been changed.
            - [ ] Previous test can cover this change.
            - [ ] No code files have been changed.
            - [ ] Other reason <!-- Add your reason?  -->
    
    - Behavior changed:
        - [ ] No.
        - [ ] Yes. <!-- Explain the behavior change -->
    
    - Does this need documentation?
        - [ ] No.
    - [ ] Yes. <!-- Add document PR link here. eg:
    https://github.com/apache/doris-website/pull/1214 -->
    
    ### Check List (For Reviewer who merge this PR)
    
    - [ ] Confirm the release note
    - [ ] Confirm test cases
    - [ ] Confirm document
    - [ ] Add branch pick label <!-- Add branch pick label that this PR
    should merge into -->
---
 .../datasource/paimon/PaimonExternalTable.java     |  53 ++++--
 .../datasource/paimon/PaimonSysExternalTable.java  |  14 +-
 .../datasource/paimon/PaimonExternalTableTest.java |   8 +-
 .../datasource/paimon/PaimonRowCountTest.java      | 191 +++++++++++++++++++++
 4 files changed, 240 insertions(+), 26 deletions(-)

diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/datasource/paimon/PaimonExternalTable.java
 
b/fe/fe-core/src/main/java/org/apache/doris/datasource/paimon/PaimonExternalTable.java
index e142d42c7f5..7996b06106d 100644
--- 
a/fe/fe-core/src/main/java/org/apache/doris/datasource/paimon/PaimonExternalTable.java
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/datasource/paimon/PaimonExternalTable.java
@@ -55,11 +55,14 @@ import org.apache.logging.log4j.Logger;
 import org.apache.paimon.CoreOptions;
 import org.apache.paimon.Snapshot;
 import org.apache.paimon.partition.Partition;
+import org.apache.paimon.privilege.PrivilegedFileStoreTable;
 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.types.DataField;
 import org.apache.paimon.types.DataTypeRoot;
 
@@ -269,20 +272,48 @@ public class PaimonExternalTable extends ExternalTable 
implements MTMVRelatedTab
     @Override
     public long fetchRowCount() {
         makeSureInitialized();
-        long rowCount = 0;
-        // Row-count planning bypasses ScanNode, so build the same CPU-capped 
disposable handle
-        // here instead of validating the hardware-neutral catalog copy 
directly.
+        // Keep the reader policy consistent with scan planning, including 
privilege wrappers.
         Table effectiveTable = 
PaimonReaderOptions.runtimeSafeTable(getBasePaimonTable());
-        // Statistics and row-count cache planning run before ScanNode and 
must not reach an
-        // unsafe manifest executor, even when the foreground relation later 
supplies an override.
         PaimonReaderOptions.validateEffectiveTable(effectiveTable);
-        List<Split> splits = 
effectiveTable.newReadBuilder().newScan().plan().splits();
-        for (Split split : splits) {
-            rowCount += split.rowCount();
+        if (!(effectiveTable instanceof FileStoreTable)
+                || 
PaimonTableDecorators.unwrapToFallbackOrBase((FileStoreTable) effectiveTable)
+                        instanceof FallbackReadFileStoreTable) {
+            return UNKNOWN_ROW_COUNT;
         }
-        if (rowCount == 0) {
-            LOG.info("Paimon table {} row count is 0, return -1", name);
+        FileStoreTable table = (FileStoreTable) effectiveTable;
+        CoreOptions options = table.coreOptions();
+        // These batch scans exclude some files from the snapshot. Do not plan 
splits merely
+        // to refine an optimizer estimate.
+        if ((!table.primaryKeys().isEmpty() && options.batchScanSkipLevel0()
+                && options.toConfiguration().get(CoreOptions.BATCH_SCAN_MODE) 
== CoreOptions.BatchScanMode.NONE)
+                || options.bucket() == BucketMode.POSTPONE_BUCKET) {
+            return UNKNOWN_ROW_COUNT;
         }
+        switch (options.startupMode()) {
+            case LATEST:
+            case LATEST_FULL:
+            case FROM_TIMESTAMP:
+            case FROM_SNAPSHOT:
+            case FROM_SNAPSHOT_FULL:
+                break;
+            default:
+                // Incremental, file-creation-time and compacted scans do not 
necessarily read
+                // the complete snapshot selected by TimeTravelUtil.
+                return UNKNOWN_ROW_COUNT;
+        }
+        if (table instanceof PrivilegedFileStoreTable) {
+            // Preserve SELECT authorization without planning. TimeTravelUtil 
calls tagManager(),
+            // which would incorrectly require INSERT permission on the 
privilege wrapper.
+            table.newScan();
+            table = PaimonTableDecorators.unwrapToFallbackOrBase(table);
+        }
+        if (options.queryAuthEnabled()) {
+            table.catalogEnvironment().tableQueryAuth(options).auth(null);
+        }
+        Snapshot snapshot = TimeTravelUtil.tryTravelOrLatest(table);
+        // Read the snapshot counter without enumerating manifests/files or 
materializing splits.
+        // For primary-key tables this is a physical record estimate, not an 
exact logical count.
+        long rowCount = snapshot == null ? UNKNOWN_ROW_COUNT : 
snapshot.totalRecordCount();
         return rowCount > 0 ? rowCount : UNKNOWN_ROW_COUNT;
     }
 
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/datasource/paimon/PaimonSysExternalTable.java
 
b/fe/fe-core/src/main/java/org/apache/doris/datasource/paimon/PaimonSysExternalTable.java
index 6e154d7e6d0..3286f941140 100644
--- 
a/fe/fe-core/src/main/java/org/apache/doris/datasource/paimon/PaimonSysExternalTable.java
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/datasource/paimon/PaimonSysExternalTable.java
@@ -39,7 +39,6 @@ import org.apache.logging.log4j.Logger;
 import org.apache.paimon.table.DataTable;
 import org.apache.paimon.table.FileStoreTable;
 import org.apache.paimon.table.Table;
-import org.apache.paimon.table.source.Split;
 import org.apache.paimon.table.system.SystemTableLoader;
 import org.apache.paimon.types.DataField;
 import org.apache.paimon.types.DataTypeRoot;
@@ -358,16 +357,9 @@ public class PaimonSysExternalTable extends ExternalTable {
 
     @Override
     public long fetchRowCount() {
-        makeSureInitialized();
-        long rowCount = 0;
-        List<Split> splits = 
getSysPaimonTable().newReadBuilder().newScan().plan().splits();
-        for (Split split : splits) {
-            rowCount += split.rowCount();
-        }
-        if (rowCount == 0) {
-            LOG.info("Paimon system table {} row count is 0, return -1", name);
-        }
-        return rowCount > 0 ? rowCount : UNKNOWN_ROW_COUNT;
+        // System-table row counts cannot use the data snapshot's record 
count. Planning their
+        // splits can enumerate all manifests (e.g. $files), so never do it 
for an estimate.
+        return UNKNOWN_ROW_COUNT;
     }
 
     @Override
diff --git 
a/fe/fe-core/src/test/java/org/apache/doris/datasource/paimon/PaimonExternalTableTest.java
 
b/fe/fe-core/src/test/java/org/apache/doris/datasource/paimon/PaimonExternalTableTest.java
index ef0e3e0a42e..27f5557cf8f 100644
--- 
a/fe/fe-core/src/test/java/org/apache/doris/datasource/paimon/PaimonExternalTableTest.java
+++ 
b/fe/fe-core/src/test/java/org/apache/doris/datasource/paimon/PaimonExternalTableTest.java
@@ -551,12 +551,11 @@ public class PaimonExternalTableTest {
     }
 
     @Test
-    public void 
testFetchRowCountCapsAcceptedManifestParallelismBeforePlanning() {
+    public void 
testFetchRowCountCapsAcceptedManifestParallelismWithoutPlanning() {
         int localCapacity = Runtime.getRuntime().availableProcessors();
         org.junit.Assume.assumeTrue(localCapacity < 
PaimonReaderOptions.MAX_MANIFEST_PARALLELISM);
         FileStoreTable rawTable = Mockito.mock(FileStoreTable.class);
         FileStoreTable cappedTable = Mockito.mock(FileStoreTable.class);
-        ReadBuilder readBuilder = Mockito.mock(ReadBuilder.class, 
Mockito.RETURNS_DEEP_STUBS);
         Mockito.when(rawTable.options()).thenReturn(ImmutableMap.of(
                 CoreOptions.SCAN_MANIFEST_PARALLELISM.key(), 
String.valueOf(localCapacity + 1)));
         
Mockito.when(rawTable.copyWithoutTimeTravel(ArgumentMatchers.argThat(options ->
@@ -565,8 +564,8 @@ public class PaimonExternalTableTest {
                 .thenReturn(cappedTable);
         Mockito.when(cappedTable.options()).thenReturn(ImmutableMap.of(
                 CoreOptions.SCAN_MANIFEST_PARALLELISM.key(), 
String.valueOf(localCapacity)));
-        Mockito.when(cappedTable.newReadBuilder()).thenReturn(readBuilder);
-        
Mockito.when(readBuilder.newScan().plan().splits()).thenReturn(Collections.emptyList());
+        CoreOptions cappedOptions = CoreOptions.fromMap(cappedTable.options());
+        Mockito.when(cappedTable.coreOptions()).thenReturn(cappedOptions);
         PaimonExternalTable externalTable = Mockito.mock(
                 PaimonExternalTable.class, Mockito.CALLS_REAL_METHODS);
         Mockito.doNothing().when(externalTable).makeSureInitialized();
@@ -576,6 +575,7 @@ public class PaimonExternalTableTest {
             Assert.assertEquals(TableIf.UNKNOWN_ROW_COUNT, 
externalTable.fetchRowCount());
         }
         
Mockito.verify(rawTable).copyWithoutTimeTravel(ArgumentMatchers.anyMap());
+        Mockito.verify(cappedTable, Mockito.never()).newReadBuilder();
     }
 
     @Test
diff --git 
a/fe/fe-core/src/test/java/org/apache/doris/datasource/paimon/PaimonRowCountTest.java
 
b/fe/fe-core/src/test/java/org/apache/doris/datasource/paimon/PaimonRowCountTest.java
new file mode 100644
index 00000000000..bba3e27f3ac
--- /dev/null
+++ 
b/fe/fe-core/src/test/java/org/apache/doris/datasource/paimon/PaimonRowCountTest.java
@@ -0,0 +1,191 @@
+// 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.datasource.paimon;
+
+import org.apache.doris.catalog.TableIf;
+
+import com.google.common.collect.ImmutableMap;
+import org.apache.paimon.CoreOptions;
+import org.apache.paimon.Snapshot;
+import org.apache.paimon.catalog.Identifier;
+import org.apache.paimon.fs.Path;
+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.Table;
+import org.apache.paimon.types.DataTypes;
+import org.junit.Assert;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+import org.mockito.Mockito;
+
+import java.util.Collections;
+import java.util.Map;
+
+public class PaimonRowCountTest {
+    @Rule
+    public TemporaryFolder temporaryFolder = new TemporaryFolder();
+
+    @Test
+    public void testSystemTableEstimateDoesNotLoadMetadataOrPlan() {
+        PaimonSysExternalTable table = 
Mockito.mock(PaimonSysExternalTable.class, invocation -> {
+            throw new AssertionError("Row count estimation must not access 
metadata: " + invocation.getMethod());
+        });
+        Mockito.doCallRealMethod().when(table).fetchRowCount();
+
+        Assert.assertEquals(TableIf.UNKNOWN_ROW_COUNT, table.fetchRowCount());
+    }
+
+    @Test
+    public void testLatestCountWithoutManifests() throws Exception {
+        for (boolean primaryKey : new boolean[] {false, true}) {
+            FileStoreTable table = newTable(primaryKey);
+            snapshot(table, 1, 10L);
+            snapshot(table, 2, 25L);
+            Assert.assertEquals(25L, rowCount(table));
+        }
+    }
+
+    @Test
+    public void testSnapshotTimestampAndTagCounts() throws Exception {
+        FileStoreTable table = newTable(false);
+        snapshot(table, 1, 10L);
+        table.createTag("retained", 1L);
+        snapshot(table, 2, 25L);
+        Assert.assertEquals(10L, 
rowCount(table.copy(ImmutableMap.of("scan.snapshot-id", "1"))));
+        Assert.assertEquals(10L, 
rowCount(table.copy(ImmutableMap.of("scan.timestamp-millis", "1500"))));
+        table.fileIO().delete(table.snapshotManager().snapshotPath(1), false);
+        Assert.assertEquals(10L, 
rowCount(table.copy(ImmutableMap.of("scan.tag-name", "retained"))));
+    }
+
+    @Test
+    public void testEmptyAndNonpositiveCountsReturnUnknown() throws Exception {
+        FileStoreTable table = newTable(false);
+        Assert.assertEquals(TableIf.UNKNOWN_ROW_COUNT, rowCount(table));
+        snapshot(table, 1, 0L);
+        Assert.assertEquals(TableIf.UNKNOWN_ROW_COUNT, rowCount(table));
+        snapshot(table, 2, -1L);
+        Assert.assertEquals(TableIf.UNKNOWN_ROW_COUNT, rowCount(table));
+    }
+
+    @Test
+    public void testPartialScansReturnUnknownWithoutManifests() throws 
Exception {
+        FileStoreTable table = newTable(false);
+        snapshot(table, 1, 10L);
+        snapshot(table, 2, 25L);
+        for (String[] option : new String[][] {
+                {"incremental-between", "1,2"},
+                {"scan.file-creation-time-millis", "1500"},
+                {"scan.creation-time-millis", "1500"},
+                {"scan.mode", "compacted-full"}}) {
+            Assert.assertEquals(option[0], TableIf.UNKNOWN_ROW_COUNT,
+                    rowCount(table.copy(ImmutableMap.of(option[0], 
option[1]))));
+        }
+        FileStoreTable deletionVectors = newTable(true, 
ImmutableMap.of("deletion-vectors.enabled", "true"));
+        snapshot(deletionVectors, 1, 10L);
+        Assert.assertEquals(TableIf.UNKNOWN_ROW_COUNT, 
rowCount(deletionVectors));
+        FileStoreTable postponedBuckets = newTable(true, 
ImmutableMap.of("bucket", "-2"));
+        snapshot(postponedBuckets, 1, 10L);
+        Assert.assertEquals(TableIf.UNKNOWN_ROW_COUNT, 
rowCount(postponedBuckets));
+    }
+
+    @Test
+    public void testUnsupportedTablesDoNotPlan() throws Exception {
+        Table formatTable = Mockito.mock(Table.class);
+        Assert.assertEquals(TableIf.UNKNOWN_ROW_COUNT, rowCount(formatTable));
+        Mockito.verify(formatTable, Mockito.never()).newReadBuilder();
+        FileStoreTable main = newTable(false);
+        FileStoreTable other = newTable(false);
+        snapshot(main, 1, 10L);
+        snapshot(other, 1, 20L);
+        Assert.assertEquals(TableIf.UNKNOWN_ROW_COUNT,
+                rowCount(new FallbackReadFileStoreTable(main, other, true)));
+    }
+
+    @Test
+    public void testSelectOnlyPrivilegeWrapper() throws Exception {
+        FileStoreTable table = newTable(false);
+        snapshot(table, 1, 10L);
+        PrivilegeChecker checker = Mockito.mock(PrivilegeChecker.class);
+        Identifier identifier = Identifier.create("db", "tbl");
+        Mockito.doThrow(new IllegalStateException("INSERT is not granted"))
+                .when(checker).assertCanInsert(identifier);
+        FileStoreTable privileged = PrivilegedFileStoreTable.wrap(table, 
checker, identifier);
+        Assert.assertEquals(10L, rowCount(privileged));
+        Mockito.verify(checker).assertCanSelect(identifier);
+        Mockito.verify(checker, Mockito.never()).assertCanInsert(identifier);
+        Mockito.doThrow(new IllegalStateException("SELECT is not granted"))
+                .when(checker).assertCanSelect(identifier);
+        Assert.assertThrows(IllegalStateException.class, () -> 
rowCount(privileged));
+    }
+
+    @Test
+    public void testCatalogQueryAuthorizationWithoutPlanning() throws 
Exception {
+        FileStoreTable table = 
Mockito.spy(newTable(false).copy(ImmutableMap.of("query-auth.enabled", 
"true")));
+        snapshot(table, 1, 10L);
+        CatalogEnvironment environment = 
Mockito.mock(CatalogEnvironment.class, Mockito.RETURNS_DEEP_STUBS);
+        Mockito.doReturn(environment).when(table).catalogEnvironment();
+        Assert.assertEquals(10L, rowCount(table));
+        
Mockito.verify(environment.tableQueryAuth(Mockito.any(CoreOptions.class))).auth(null);
+        
Mockito.when(environment.tableQueryAuth(Mockito.any(CoreOptions.class)).auth(null))
+                .thenThrow(new IllegalStateException("Query is not 
authorized"));
+        Assert.assertThrows(IllegalStateException.class, () -> 
rowCount(table));
+        Mockito.verify(table, Mockito.never()).newReadBuilder();
+    }
+
+    private long rowCount(Table table) {
+        PaimonExternalTable external = Mockito.mock(PaimonExternalTable.class, 
Mockito.CALLS_REAL_METHODS);
+        Mockito.doNothing().when(external).makeSureInitialized();
+        Mockito.doReturn(table).when(external).getBasePaimonTable();
+        return external.fetchRowCount();
+    }
+
+    private FileStoreTable newTable(boolean primaryKey) throws Exception {
+        return newTable(primaryKey, Collections.emptyMap());
+    }
+
+    private FileStoreTable newTable(boolean primaryKey, Map<String, String> 
options) throws Exception {
+        Path path = new Path(temporaryFolder.newFolder().toURI());
+        LocalFileIO fileIO = LocalFileIO.create();
+        Schema.Builder schema = Schema.newBuilder().column("id", 
DataTypes.INT())
+                .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 
Exception {
+        // No manifest files exist: accidentally returning to split planning 
must fail.
+        Snapshot snapshot = new Snapshot(id, 0L, "unused-base", null, 
"unused-delta", null,
+                null, null, null, "test", id, Snapshot.CommitKind.APPEND, id * 
1000,
+                count, count, null, null, null, Collections.emptyMap(), null);
+        
table.fileIO().mkdirs(table.snapshotManager().snapshotPath(id).getParent());
+        
table.fileIO().overwriteFileUtf8(table.snapshotManager().snapshotPath(id), 
snapshot.toJson());
+        table.snapshotManager().commitLatestHint(id);
+    }
+}


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

Reply via email to