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

Gabriel39 pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/doris.git


The following commit(s) were added to refs/heads/master by this push:
     new a768fb25678 [fix](iceberg) Derive count pushdown from live manifests 
(#66778)
a768fb25678 is described below

commit a768fb25678b652ee4cc182bd3deb8793223e492
Author: Gabriel <[email protected]>
AuthorDate: Mon Aug 17 09:55:18 2026 +0800

    [fix](iceberg) Derive count pushdown from live manifests (#66778)
    
    ### What problem does this PR solve?
    
    Issue Number: None
    
    Problem Summary:
    
    Iceberg V2 COUNT(*) pushdown treated the optional `total-records`
    snapshot summary field as an exact query result. A writer can provide a
    syntactically valid positive value that does not match the live data
    files, causing Doris to return a wrong count without reading data.
    
    This change derives an exact unfiltered count by summing `addedRowsCount
    + existingRowsCount` across the current manifest list. The common path
    is O(manifests) and reads only the first live data-file task to build
    the representative range. Older manifest lists that omit aggregate
    counters fall back to bounded per-file `recordCount` enumeration.
    Queries with row filters or live delete files use the normal scan, while
    invalid counters and overflow safely fall back instead of exposing
    unverified metadata.
    
    ### Release note
    
    Fix incorrect Iceberg V2 COUNT(*) results when snapshot summary row
    counts are inaccurate.
    
    ### Check List (For Author)
    
    - Test: Unit Test
      - `IcebergScanPlanProviderTest`: 148 tests passed
    - FE Checkstyle: passed with 0 violations
    - Behavior changed: Yes. Exact unfiltered COUNT(*) pushdown now uses
    current manifest-list aggregates and safely falls back when exactness
    cannot be proven.
    - Does this need documentation: No
---
 be/src/format/table/iceberg_reader_mixin.h         |   9 +-
 .../iceberg/IcebergConnectorMetadata.java          |  15 +-
 .../connector/iceberg/IcebergScanPlanProvider.java | 377 ++++++++++++++-------
 .../iceberg/IcebergCountFromSummaryTest.java       | 114 -------
 .../iceberg/IcebergScanPlanProviderTest.java       | 284 ++++++++++++++--
 .../java/org/apache/doris/qe/SessionVariable.java  |  10 +-
 6 files changed, 527 insertions(+), 282 deletions(-)

diff --git a/be/src/format/table/iceberg_reader_mixin.h 
b/be/src/format/table/iceberg_reader_mixin.h
index 55064c6687d..437b76d0e2d 100644
--- a/be/src/format/table/iceberg_reader_mixin.h
+++ b/be/src/format/table/iceberg_reader_mixin.h
@@ -534,11 +534,10 @@ protected:
 
 template <typename BaseReader>
 Status IcebergReaderMixin<BaseReader>::_init_row_filters() {
-    // COUNT(*) short-circuit. A table-level row count of 0 (e.g. an 
all-deleted table read with
-    // ignore_iceberg_dangling_delete, where total-records == 
total-position-deletes) is still a
-    // valid pushed-down count, so accept >= 0 -- matching FileScanner and the 
Paimon readers. FE
-    // sends -1 when there is no table-level count; using > 0 here would drop 
a genuine 0 into the
-    // delete-applying path below and never produce the intended 
CountReader(0).
+    // COUNT(*) short-circuit. A table-level row count of 0 (an empty current 
snapshot) is still a
+    // valid pushed-down count, so accept >= 0 -- matching FileScanner and the 
Paimon readers. FE sends
+    // -1 when there is no table-level count; using > 0 here would drop a 
genuine 0 into the normal read
+    // path below and never produce the intended CountReader(0).
     if (this->_push_down_agg_type == TPushAggOp::type::COUNT &&
         
this->get_scan_range().table_format_params.__isset.table_level_row_count &&
         this->get_scan_range().table_format_params.table_level_row_count >= 0) 
{
diff --git 
a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergConnectorMetadata.java
 
b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergConnectorMetadata.java
index 2546fe4690e..c6c327955ee 100644
--- 
a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergConnectorMetadata.java
+++ 
b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergConnectorMetadata.java
@@ -127,10 +127,9 @@ public class IcebergConnectorMetadata implements 
ConnectorMetadata {
     private static final int ICEBERG_ROW_LINEAGE_MIN_VERSION = 3;
 
     // Snapshot-summary keys for table-level row count (getTableStatistics). 
Local literal copies of the
-    // spec-stable iceberg strings — byte-identical to legacy 
IcebergUtils.TOTAL_* and to the COUNT(*)
-    // pushdown copies in IcebergScanPlanProvider (themselves deliberately NOT 
org.apache.iceberg
-    // .SnapshotSummary.* per that file's note). Duplicated rather than shared 
so this fix does not touch
-    // the unrelated scan provider. All THREE keys are read: legacy 
getIcebergRowCount (via
+    // spec-stable iceberg strings — byte-identical to legacy 
IcebergUtils.TOTAL_*. These remain optimizer
+    // estimates only; exact COUNT(*) pushdown deliberately derives its result 
from live manifest-list counters.
+    // All THREE keys are read: legacy getIcebergRowCount (via
     // getCountFromSummary, upstream 32a2651f66b / #64648) nets out position 
deletes AND gates the count to
     // UNKNOWN on any equality delete — see computeRowCount.
     private static final String TOTAL_RECORDS = "total-records";
@@ -823,9 +822,8 @@ public class IcebergConnectorMetadata implements 
ConnectorMetadata {
      * .getIcebergRowCount} (which calls {@code getCountFromSummary(summary, 
true)}, upstream 32a2651f66b /
      * #64648): any equality delete ({@code total-equality-deletes} absent or 
{@code != "0"}) -> -1 (UNKNOWN),
      * since equality deletes re-project at read time and the summary cannot 
net them out; otherwise
-     * {@code total-records - total-position-deletes}. Shares the 
equality-delete gate with the COUNT(*)
-     * pushdown {@code IcebergScanPlanProvider.getCountFromSummary}, differing 
only in dangling-delete handling
-     * (table statistics always net out position deletes; the pushdown honors 
the dangling-delete session var).
+     * {@code total-records - total-position-deletes}. This best-effort 
optimizer estimate is not used as an
+     * exact query result; COUNT(*) pushdown independently sums live-row 
counters from the manifest list.
      * Empty table (no current snapshot) -> -1, which the caller maps to 
UNKNOWN.
      */
     private static long computeRowCount(Table table) {
@@ -842,8 +840,7 @@ public class IcebergConnectorMetadata implements 
ConnectorMetadata {
         // summary, true) (upstream 32a2651f66b, #64648): an absent total-* 
counter (compaction / replace /
         // overwrite snapshots may omit one — the pre-fix Long.parseLong(null) 
NPE-d), or any equality delete
         // (total-equality-deletes != "0"), makes the summary row count unsafe 
-> -1 (caller maps to UNKNOWN),
-        // because equality deletes re-project at read time and the summary 
cannot net them out. Same gate as
-        // the COUNT(*) pushdown IcebergScanPlanProvider.getCountFromSummary.
+        // because equality deletes re-project at read time and the summary 
cannot net them out.
         String equalityDeletes = summary.get(TOTAL_EQUALITY_DELETES);
         String totalRecords = summary.get(TOTAL_RECORDS);
         String positionDeletes = summary.get(TOTAL_POSITION_DELETES);
diff --git 
a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergScanPlanProvider.java
 
b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergScanPlanProvider.java
index fd1eab305db..bd2e90bd646 100644
--- 
a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergScanPlanProvider.java
+++ 
b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergScanPlanProvider.java
@@ -150,15 +150,11 @@ public class IcebergScanPlanProvider implements 
ConnectorScanPlanProvider {
     // FIX-M3 streaming (file-count) batch gate — keys byte-identical to 
fe-core SessionVariable.
     private static final String ENABLE_EXTERNAL_TABLE_BATCH_MODE = 
"enable_external_table_batch_mode";
     private static final String NUM_FILES_IN_BATCH_MODE = 
"num_files_in_batch_mode";
+    private static final String IGNORE_ICEBERG_DANGLING_DELETE = 
"ignore_iceberg_dangling_delete";
     private static final long DEFAULT_NUM_FILES_IN_BATCH_MODE = 1024L;
 
-    // COUNT(*) pushdown (T05). The snapshot-summary keys are the stable 
iceberg spec strings — byte-identical
-    // to legacy IcebergUtils.TOTAL_* (themselves local constants, not 
org.apache.iceberg.SnapshotSummary.*).
-    private static final String TOTAL_RECORDS = "total-records";
-    private static final String TOTAL_POSITION_DELETES = 
"total-position-deletes";
+    // Equality-delete schema discovery uses this stable Iceberg 
snapshot-summary key as a read-avoidance hint.
     private static final String TOTAL_EQUALITY_DELETES = 
"total-equality-deletes";
-    // Session var: when a table has only (dangling) position deletes, ignore 
them and still push count down.
-    private static final String IGNORE_ICEBERG_DANGLING_DELETE = 
"ignore_iceberg_dangling_delete";
 
     // System-table (P6.5-T05) JNI split: a placeholder path matching legacy 
IcebergSplit.DUMMY_PATH. A sys split
     // carries no real file (BE reads the serialized FileScanTask), so the 
path is never opened — it only keeps
@@ -457,10 +453,23 @@ public class IcebergScanPlanProvider implements 
ConnectorScanPlanProvider {
         if (snapshot == null) {
             return -1;
         }
-        if (getFormatVersion(table) >= 3) {
-            return -1;
+        if (countPushdown && filter.isEmpty()) {
+            boolean netPositionDeletes = sessionBool(session, 
IGNORE_ICEBERG_DANGLING_DELETE, false);
+            ManifestDeleteState deleteState = 
manifestDeleteState(snapshot.deleteManifests(table.io()));
+            // Keep synchronous planning only while COUNT(*) can still 
collapse to one range. Once live deletes
+            // make that impossible, normal file enumeration needs the same 
streaming OOM protection as SELECT.
+            if (deleteState == ManifestDeleteState.NONE) {
+                return -1;
+            }
+            if (deleteState != ManifestDeleteState.PRESENT || 
netPositionDeletes) {
+                OptionalLong positionDeleteRows = 
livePositionDeleteRowCount(table, snapshot);
+                if (positionDeleteRows.isPresent()
+                        && (netPositionDeletes || 
positionDeleteRows.getAsLong() == 0)) {
+                    return -1;
+                }
+            }
         }
-        if (countPushdown && getCountFromSnapshot(scan, session) >= 0) {
+        if (getFormatVersion(table) >= 3) {
             return -1;
         }
         long threshold = sessionLong(session, NUM_FILES_IN_BATCH_MODE, 
DEFAULT_NUM_FILES_IN_BATCH_MODE);
@@ -720,16 +729,17 @@ public class IcebergScanPlanProvider implements 
ConnectorScanPlanProvider {
         // per-file path normalization below, instead of rebuilding it per 
data/delete file (C3).
         UnaryOperator<String> uriNormalizer = newUriNormalizer(vendedToken);
 
-        // COUNT(*) pushdown (T05): when the count is servable from the 
snapshot summary, collapse the scan to
-        // a single whole-file range carrying the full count (mirrors paimon's 
collapse + legacy's <=10000
-        // case; the legacy >10000 parallel multi-split trim is a perf-only 
divergence, dropped). A -1 (equality
-        // deletes, or dangling position deletes without the ignore flag) 
falls through to the normal scan so
-        // BE reads and counts.
-        if (countPushdown) {
-            long realCount = getCountFromSnapshot(scan, session);
-            if (realCount >= 0) {
-                return planCountPushdown(table, scan, realCount, 
formatVersion, partitioned,
-                        orderedPartitionKeys, zone, uriNormalizer, session, 
filter);
+        // COUNT(*) pushdown (T05): derive an exact count from the current 
manifest list and collapse the scan to
+        // a single whole-file range. Snapshot summary fields are optional 
writer-provided metadata and must never
+        // become a query result. If manifest aggregates are absent, fall back 
to bounded per-file enumeration; if
+        // deletes or invalid record counts prevent an exact proof, use the 
normal scan so BE reads and counts.
+        // A data-row predicate can leave partially matching files, whose 
file-level recordCount is only an
+        // upper bound. Keep those scans on the normal path even if the engine 
supplies the count signal.
+        if (countPushdown && filter.isEmpty()) {
+            Optional<List<ConnectorScanRange>> countRanges = 
planCountPushdown(table, scan, formatVersion,
+                    partitioned, orderedPartitionKeys, zone, uriNormalizer, 
session, filter);
+            if (countRanges.isPresent()) {
+                return countRanges.get();
             }
         }
 
@@ -1167,7 +1177,7 @@ public class IcebergScanPlanProvider implements 
ConnectorScanPlanProvider {
      * (profile) and {@code planWith(threadPool)} are intentionally dropped — 
the iceberg SDK default worker
      * pool plans, and the file set is identical (see design deviations). The 
MVCC / time-travel pin (T07) is
      * applied here ({@code useRef} for a tag/branch, else {@code 
useSnapshot}), mirroring legacy
-     * {@code createTableScan}; {@code getCountFromSnapshot} reads {@code 
scan.snapshot()} so the count follows.
+     * {@code createTableScan}; COUNT planning reads this pinned scan's 
manifest list, so the count follows.
      */
     private TableScan buildScan(Table table, IcebergTableHandle handle, 
Optional<ConnectorExpression> filter,
             ConnectorSession session) {
@@ -1254,58 +1264,245 @@ public class IcebergScanPlanProvider implements 
ConnectorScanPlanProvider {
     }
 
     /**
-     * Emit the single collapsed COUNT(*)-pushdown range: the first whole-file 
{@link FileScanTask} from
-     * {@code scan.planFiles()} carrying the full {@code realCount} via {@code 
table_level_row_count} → BE's
-     * count reader serves it without opening the data file. Mirrors paimon's 
{@code buildCountRange} (one
-     * range bearing the summed total). Result-identical to legacy's count 
short-circuit even though legacy
-     * takes a different shape: legacy byte-splits the count file ({@code 
planFileScanTask} →
-     * {@code splitFiles} → {@code TableScanUtil.splitFiles}), keeps the first 
split task's byte-range for
-     * {@code count < 10000}, and {@code assignCountToSplits} distributes the 
same total — but under count
-     * pushdown BE's count reader never reads the file (the range's 
start/length are irrelevant) and sums
-     * {@code table_level_row_count} across ranges, so one whole-file range 
yields the identical total (and
-     * legacy's {@code >10000} parallel multi-split trim is the perf-only 
divergence we drop). An empty table
-     * (no files) yields no range, so BE gets 0 ranges and COUNT returns 0 
(legacy returns empty splits too).
+     * Build a collapsed COUNT(*) range from current manifest-list aggregates. 
Summing each data manifest's
+     * added and existing row counts is O(manifests), while only the first 
live {@link FileScanTask} is needed as
+     * the representative range. Old manifest lists that omit these aggregates 
use the bounded O(files) fallback.
+     * Equality deletes and non-netted position deletes make the optimization 
unsafe and tell the caller to
+     * perform a normal scan.
      */
-    private List<ConnectorScanRange> planCountPushdown(Table table, TableScan 
scan, long realCount,
+    private Optional<List<ConnectorScanRange>> planCountPushdown(Table table, 
TableScan scan,
             int formatVersion, boolean partitioned, List<String> 
orderedPartitionKeys, ZoneId zone,
             UnaryOperator<String> uriNormalizer, ConnectorSession session, 
Optional<ConnectorExpression> filter) {
-        try (CloseableIterable<FileScanTask> tasks = 
countPushdownFileScanTasks(scan, session, table, filter)) {
-            for (FileScanTask task : tasks) {
-                // targetSplitSize = -1: the count-pushdown collapse emits a 
single range, so its scheduling
-                // weight is irrelevant → PluginDrivenSplit keeps 
SplitWeight.standard().
-                return Collections.singletonList(buildRange(table, 
task.file(), task, formatVersion,
-                        partitioned, orderedPartitionKeys, zone, 
uriNormalizer, realCount, -1, null));
+        Snapshot snapshot = scan.snapshot();
+        if (snapshot == null) {
+            return Optional.of(Collections.emptyList());
+        }
+
+        boolean netPositionDeletes = sessionBool(session, 
IGNORE_ICEBERG_DANGLING_DELETE, false);
+        ManifestDeleteState deleteState = 
manifestDeleteState(snapshot.deleteManifests(table.io()));
+        if (deleteState == ManifestDeleteState.PRESENT && !netPositionDeletes) 
{
+            return Optional.empty();
+        }
+        OptionalLong positionDeleteRows = deleteState == 
ManifestDeleteState.NONE
+                ? OptionalLong.of(0)
+                : livePositionDeleteRowCount(table, snapshot);
+        if (!positionDeleteRows.isPresent()
+                || (!netPositionDeletes && positionDeleteRows.getAsLong() != 
0)) {
+            return Optional.empty();
+        }
+
+        OptionalLong manifestCount = 
liveRowCountFromManifests(snapshot.dataManifests(table.io()));
+        if (manifestCount.isPresent()) {
+            // Compatibility mode nets each live position-delete file's rows 
once without trusting summary
+            // counters; it intentionally cannot distinguish dangling entries, 
which is why the flag is opt-in.
+            OptionalLong visibleRows = subtractPositionDeleteRows(
+                    manifestCount.getAsLong(), netPositionDeletes ? 
positionDeleteRows.getAsLong() : 0);
+            if (!visibleRows.isPresent()) {
+                return Optional.empty();
+            }
+            return planManifestCountRange(table, scan, 
visibleRows.getAsLong(), formatVersion,
+                    partitioned, orderedPartitionKeys, zone, uriNormalizer, 
session, filter,
+                    netPositionDeletes);
+        }
+
+        // Older manifest lists may omit aggregate counters. Preserve 
correctness by falling back to the
+        // bounded per-file enumeration instead of trusting snapshot summary 
metadata.
+        return planCountPushdownFromFileTasks(table, scan, formatVersion, 
partitioned,
+                orderedPartitionKeys, zone, uriNormalizer, session, filter, 
netPositionDeletes,
+                netPositionDeletes ? positionDeleteRows.getAsLong() : 0);
+    }
+
+    private Optional<List<ConnectorScanRange>> planManifestCountRange(Table 
table, TableScan scan, long exactCount,
+            int formatVersion, boolean partitioned, List<String> 
orderedPartitionKeys, ZoneId zone,
+            UnaryOperator<String> uriNormalizer, ConnectorSession session, 
Optional<ConnectorExpression> filter,
+            boolean netPositionDeletes) {
+        if (!isManifestCacheEnabled()) {
+            return buildManifestCountRange(table, scan.planFiles(), 
exactCount, formatVersion, partitioned,
+                    orderedPartitionKeys, zone, uriNormalizer, 
netPositionDeletes);
+        }
+        String statsQueryId = session != null ? session.getQueryId() : null;
+        try {
+            return buildManifestCountRange(table,
+                    cacheBackedFileScanTasks(scan, session, table, filter, 
statsQueryId), exactCount,
+                    formatVersion, partitioned, orderedPartitionKeys, zone, 
uriNormalizer, netPositionDeletes);
+        } catch (Exception e) {
+            LOG.warn("Iceberg count-pushdown representative plan with manifest 
cache failed, "
+                    + "falling back to SDK scan: {}", e.getMessage(), e);
+            manifestCache.recordFailure(statsQueryId);
+            // The SDK retry must own a new iterable because a lazy cache 
failure may leave the first one partial.
+            return buildManifestCountRange(table, scan.planFiles(), 
exactCount, formatVersion, partitioned,
+                    orderedPartitionKeys, zone, uriNormalizer, 
netPositionDeletes);
+        }
+    }
+
+    private Optional<List<ConnectorScanRange>> buildManifestCountRange(Table 
table,
+            CloseableIterable<FileScanTask> tasks, long exactCount, int 
formatVersion, boolean partitioned,
+            List<String> orderedPartitionKeys, ZoneId zone, 
UnaryOperator<String> uriNormalizer,
+            boolean netPositionDeletes) {
+        try (CloseableIterable<FileScanTask> closeableTasks = tasks) {
+            for (FileScanTask task : closeableTasks) {
+                // Data-manifest rows and the separately netted live delete 
files are authoritative, but retain
+                // this defensive check for malformed metadata before exposing 
the aggregate as a query result.
+                if (hasNonIgnorableTaskDeletes(task, netPositionDeletes) || 
task.file().recordCount() < 0) {
+                    return Optional.empty();
+                }
+                return Optional.of(Collections.singletonList(buildRange(table, 
task.file(), task, formatVersion,
+                        partitioned, orderedPartitionKeys, zone, 
uriNormalizer, exactCount, -1, null)));
             }
         } catch (IOException e) {
             throw new RuntimeException("Failed to plan iceberg count-pushdown 
file, error message is:"
                     + e.getMessage(), e);
         }
-        return Collections.emptyList();
+        return exactCount == 0 ? Optional.of(Collections.emptyList()) : 
Optional.empty();
     }
 
-    /**
-     * The COUNT(*)-pushdown placeholder enumeration: only the FIRST surviving 
file is consumed (BE serves the
-     * count from {@code table_level_row_count} and never reads the file). 
PERF-04 (C18): when the manifest cache is
-     * enabled, read through the lazy {@link #cacheBackedFileScanTasks} (stats 
overload — this runs on the single
-     * planning thread) so the manifest reads are cache hits and, being lazy, 
stop at the first file's manifest
-     * instead of the SDK {@code planFiles()}'s {@code ParallelIterable} 
eagerly submitting every manifest reader.
-     * An eager cache failure falls back to the SDK path (mirrors {@link 
#planFileScanTask}). The first surviving
-     * (pruned) file may differ from the SDK path's first file (its {@code 
ParallelIterable} order is
-     * non-deterministic), but the count is identical (from the snapshot 
summary) and BE ignores the file. Cache
-     * disabled -> the SDK path, byte-unchanged.
-     */
-    private CloseableIterable<FileScanTask> 
countPushdownFileScanTasks(TableScan scan, ConnectorSession session,
-            Table table, Optional<ConnectorExpression> filter) {
-        if (isManifestCacheEnabled()) {
+    private Optional<List<ConnectorScanRange>> 
planCountPushdownFromFileTasks(Table table, TableScan scan,
+            int formatVersion, boolean partitioned, List<String> 
orderedPartitionKeys, ZoneId zone,
+            UnaryOperator<String> uriNormalizer, ConnectorSession session, 
Optional<ConnectorExpression> filter,
+            boolean netPositionDeletes, long positionDeleteRows) {
+        if (!isManifestCacheEnabled()) {
+            return accumulateCountPushdownFileTasks(table, scan.planFiles(), 
formatVersion, partitioned,
+                    orderedPartitionKeys, zone, uriNormalizer, 
netPositionDeletes, positionDeleteRows);
+        }
+        String statsQueryId = session != null ? session.getQueryId() : null;
+        try {
+            return accumulateCountPushdownFileTasks(table,
+                    cacheBackedFileScanTasks(scan, session, table, filter, 
statsQueryId), formatVersion,
+                    partitioned, orderedPartitionKeys, zone, uriNormalizer, 
netPositionDeletes,
+                    positionDeleteRows);
+        } catch (Exception e) {
+            LOG.warn("Iceberg count-pushdown plan with manifest cache failed, 
falling back to SDK scan: {}",
+                    e.getMessage(), e);
+            manifestCache.recordFailure(statsQueryId);
+            // The retry owns a fresh accumulator so rows consumed before a 
lazy cache failure are never counted
+            // twice and the SDK fallback remains an exact restart.
+            return accumulateCountPushdownFileTasks(table, scan.planFiles(), 
formatVersion, partitioned,
+                    orderedPartitionKeys, zone, uriNormalizer, 
netPositionDeletes, positionDeleteRows);
+        }
+    }
+
+    private Optional<List<ConnectorScanRange>> 
accumulateCountPushdownFileTasks(Table table,
+            CloseableIterable<FileScanTask> tasks, int formatVersion, boolean 
partitioned,
+            List<String> orderedPartitionKeys, ZoneId zone, 
UnaryOperator<String> uriNormalizer,
+            boolean netPositionDeletes, long positionDeleteRows) {
+        FileScanTask representative = null;
+        long exactCount = 0;
+        try (CloseableIterable<FileScanTask> closeableTasks = tasks) {
+            for (FileScanTask task : closeableTasks) {
+                // A metadata count is safe only when manifests alone prove 
the exact visible row count.
+                if (hasNonIgnorableTaskDeletes(task, netPositionDeletes) || 
task.file().recordCount() < 0) {
+                    return Optional.empty();
+                }
+                try {
+                    exactCount = Math.addExact(exactCount, 
task.file().recordCount());
+                } catch (ArithmeticException e) {
+                    return Optional.empty();
+                }
+                if (representative == null) {
+                    representative = task;
+                }
+            }
+        } catch (IOException e) {
+            throw new RuntimeException("Failed to plan iceberg count-pushdown 
file, error message is:"
+                    + e.getMessage(), e);
+        }
+        OptionalLong visibleRows = subtractPositionDeleteRows(exactCount, 
positionDeleteRows);
+        if (!visibleRows.isPresent()) {
+            return Optional.empty();
+        }
+        if (representative == null) {
+            return visibleRows.getAsLong() == 0
+                    ? Optional.of(Collections.emptyList()) : Optional.empty();
+        }
+        // targetSplitSize = -1: the count-pushdown collapse emits a single 
range, so its scheduling weight is
+        // irrelevant and PluginDrivenSplit keeps SplitWeight.standard().
+        return Optional.of(Collections.singletonList(buildRange(table, 
representative.file(), representative,
+                formatVersion, partitioned, orderedPartitionKeys, zone, 
uriNormalizer,
+                visibleRows.getAsLong(), -1, null)));
+    }
+
+    private static boolean hasNonIgnorableTaskDeletes(FileScanTask task, 
boolean netPositionDeletes) {
+        if (task.deletes() == null) {
+            return false;
+        }
+        for (DeleteFile delete : task.deletes()) {
+            if (!netPositionDeletes || delete.content() != 
FileContent.POSITION_DELETES) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    private enum ManifestDeleteState {
+        NONE,
+        PRESENT,
+        UNKNOWN
+    }
+
+    private static ManifestDeleteState manifestDeleteState(List<ManifestFile> 
manifests) {
+        for (ManifestFile manifest : manifests) {
+            Integer addedFiles = manifest.addedFilesCount();
+            Integer existingFiles = manifest.existingFilesCount();
+            if (manifest.content() != ManifestContent.DELETES || addedFiles == 
null || existingFiles == null
+                    || addedFiles < 0 || existingFiles < 0) {
+                return ManifestDeleteState.UNKNOWN;
+            }
+            if (addedFiles > 0 || existingFiles > 0) {
+                return ManifestDeleteState.PRESENT;
+            }
+        }
+        return ManifestDeleteState.NONE;
+    }
+
+    private static OptionalLong livePositionDeleteRowCount(Table table, 
Snapshot snapshot) {
+        long exactCount = 0;
+        for (ManifestFile manifest : snapshot.deleteManifests(table.io())) {
+            try (ManifestReader<DeleteFile> reader = 
ManifestFiles.readDeleteManifest(
+                    manifest, table.io(), table.specs())) {
+                for (DeleteFile delete : reader) {
+                    if (delete.content() != FileContent.POSITION_DELETES || 
delete.recordCount() < 0) {
+                        return OptionalLong.empty();
+                    }
+                    try {
+                        exactCount = Math.addExact(exactCount, 
delete.recordCount());
+                    } catch (ArithmeticException e) {
+                        return OptionalLong.empty();
+                    }
+                }
+            } catch (IOException e) {
+                throw new DorisConnectorException(
+                        "Failed to inspect iceberg delete manifest " + 
manifest.path() + ": " + e.getMessage(), e);
+            }
+        }
+        return OptionalLong.of(exactCount);
+    }
+
+    private static OptionalLong subtractPositionDeleteRows(long dataRows, long 
positionDeleteRows) {
+        try {
+            long visibleRows = Math.subtractExact(dataRows, 
positionDeleteRows);
+            return visibleRows >= 0 ? OptionalLong.of(visibleRows) : 
OptionalLong.empty();
+        } catch (ArithmeticException e) {
+            return OptionalLong.empty();
+        }
+    }
+
+    private static OptionalLong liveRowCountFromManifests(List<ManifestFile> 
manifests) {
+        long exactCount = 0;
+        for (ManifestFile manifest : manifests) {
+            Long addedRows = manifest.addedRowsCount();
+            Long existingRows = manifest.existingRowsCount();
+            if (manifest.content() != ManifestContent.DATA || addedRows == 
null || existingRows == null
+                    || addedRows < 0 || existingRows < 0) {
+                return OptionalLong.empty();
+            }
             try {
-                return cacheBackedFileScanTasks(scan, session, table, filter, 
session.getQueryId());
-            } catch (Exception e) {
-                LOG.warn("Iceberg count-pushdown plan with manifest cache 
failed, falling back to SDK scan: {}",
-                        e.getMessage(), e);
-                manifestCache.recordFailure(session.getQueryId());
+                exactCount = Math.addExact(exactCount, addedRows);
+                exactCount = Math.addExact(exactCount, existingRows);
+            } catch (ArithmeticException e) {
+                return OptionalLong.empty();
             }
         }
-        return scan.planFiles();
+        return OptionalLong.of(exactCount);
     }
 
     /**
@@ -2771,68 +2968,6 @@ public class IcebergScanPlanProvider implements 
ConnectorScanPlanProvider {
         return Boolean.parseBoolean(raw.trim());
     }
 
-    /**
-     * Compute the COUNT(*)-pushdown row count from the scan's snapshot 
summary, a faithful port of legacy
-     * {@code IcebergScanNode.getCountFromSnapshot}. No snapshot (empty table) 
&rarr; {@code 0}; otherwise
-     * delegates to {@link #getCountFromSummary}. Reads the scan's snapshot 
({@code scan.snapshot()}) so the
-     * count tracks the scan automatically (the current snapshot today; the 
pinned snapshot once MVCC
-     * time-travel lands) — equivalent to legacy's {@code currentSnapshot()} 
for every non-time-travel query.
-     */
-    private static long getCountFromSnapshot(TableScan scan, ConnectorSession 
session) {
-        Snapshot snapshot = scan.snapshot();
-        if (snapshot == null) {
-            return 0;
-        }
-        return getCountFromSummary(snapshot.summary(), 
ignoreIcebergDanglingDelete(session));
-    }
-
-    /**
-     * Null-safe port of fe-core {@code IcebergUtils.getCountFromSummary} 
(upstream 32a2651f66b, #64648).
-     * Returns {@code -1} — this module's "count not pushable / unknown" 
sentinel; the {@code planScan} gate
-     * and count-collapse callers both test {@code >= 0} — in two cases:
-     * <ul>
-     *   <li>any required {@code total-*} counter is ABSENT: compaction / 
replace / overwrite snapshots may
-     *       omit {@code total-records} / {@code total-position-deletes} / 
{@code total-equality-deletes}, and
-     *       the pre-fix code NPE-d on {@code summary.get(...).equals(...)} / 
{@code Long.parseLong(null)};</li>
-     *   <li>any equality delete ({@code total-equality-deletes != "0"}) — not 
pushable, since equality
-     *       deletes re-project at read time and the summary cannot net them 
out.</li>
-     * </ul>
-     * Otherwise: no position deletes &rarr; {@code total-records}; position 
deletes present and
-     * {@code ignoreDanglingDelete} &rarr; {@code total-records - 
total-position-deletes}; else {@code -1}.
-     */
-    static long getCountFromSummary(Map<String, String> summary, boolean 
ignoreDanglingDelete) {
-        String equalityDeletes = summary.get(TOTAL_EQUALITY_DELETES);
-        String positionDeletes = summary.get(TOTAL_POSITION_DELETES);
-        String totalRecords = summary.get(TOTAL_RECORDS);
-        if (equalityDeletes == null || positionDeletes == null || totalRecords 
== null) {
-            // a summary that omits any total-* counter can't be netted safely 
-> fall back to a real scan
-            return -1;
-        }
-        if (!equalityDeletes.equals("0")) {
-            // has equality delete files, can not push down count
-            return -1;
-        }
-        long deleteCount = Long.parseLong(positionDeletes);
-        if (deleteCount == 0) {
-            // no delete files, can push down count directly
-            return Long.parseLong(totalRecords);
-        }
-        if (ignoreDanglingDelete) {
-            // has position delete files; if we ignore dangling deletes, the 
netted count can be pushed down
-            return Long.parseLong(totalRecords) - deleteCount;
-        }
-        // otherwise, can not push down count
-        return -1;
-    }
-
-    private static boolean ignoreIcebergDanglingDelete(ConnectorSession 
session) {
-        if (session == null) {
-            return false;
-        }
-        String raw = 
session.getSessionProperties().get(IGNORE_ICEBERG_DANGLING_DELETE);
-        return raw != null && Boolean.parseBoolean(raw.trim());
-    }
-
     // The session time zone drives zone-adjusted (timestamptz) literal 
pushdown. Delegates to the shared
     // IcebergTimeUtils (Doris alias map, mirrors fe-core 
TimeUtils.getTimeZone()) so aliases like CST/PRC/EST
     // match legacy instead of throwing; null/blank/genuinely-invalid -> UTC. 
Package-private for unit testing.
diff --git 
a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergCountFromSummaryTest.java
 
b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergCountFromSummaryTest.java
deleted file mode 100644
index a1e67d79019..00000000000
--- 
a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergCountFromSummaryTest.java
+++ /dev/null
@@ -1,114 +0,0 @@
-// 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.iceberg;
-
-import org.junit.jupiter.api.Assertions;
-import org.junit.jupiter.api.Test;
-
-import java.util.Collections;
-import java.util.HashMap;
-import java.util.Map;
-
-/**
- * FIX-COUNT-NPE (upstream 32a2651f66b, #64648) — pins that
- * {@link IcebergScanPlanProvider#getCountFromSummary} is null-safe.
- *
- * <p>WHY: the COUNT(*)-pushdown row count is read from the iceberg snapshot 
summary's {@code total-records}
- * / {@code total-position-deletes} / {@code total-equality-deletes}. A 
compaction / replace / overwrite
- * snapshot can OMIT one of those counters, and the pre-fix code (a faithful 
hand-port of the legacy,
- * null-unsafe {@code IcebergScanNode.getCountFromSnapshot}) NPE-d on {@code 
summary.get(...).equals("0")}
- * / {@code Long.parseLong(null)} — crashing the whole query instead of just 
declining the pushdown. The fix
- * returns the {@code -1} "not pushable / unknown" sentinel (callers gate on 
{@code >= 0}) when any counter
- * is absent. This is the connector-module analog of fe-core {@code 
IcebergCountPushDownTest}; the SPI
- * migration copied the pre-fix logic, so fe-core carrying the fix did not 
protect the live path here.
- */
-public class IcebergCountFromSummaryTest {
-
-    // The three iceberg snapshot-summary counter keys 
(org.apache.iceberg.SnapshotSummary constants).
-    private static final String TOTAL_EQUALITY_DELETES = 
"total-equality-deletes";
-    private static final String TOTAL_POSITION_DELETES = 
"total-position-deletes";
-    private static final String TOTAL_RECORDS = "total-records";
-
-    /** Build a snapshot summary; a {@code null} arg OMITS that key — the 
exact absence the fix guards. */
-    private static Map<String, String> summary(String equalityDeletes, String 
positionDeletes,
-            String totalRecords) {
-        Map<String, String> m = new HashMap<>();
-        if (equalityDeletes != null) {
-            m.put(TOTAL_EQUALITY_DELETES, equalityDeletes);
-        }
-        if (positionDeletes != null) {
-            m.put(TOTAL_POSITION_DELETES, positionDeletes);
-        }
-        if (totalRecords != null) {
-            m.put(TOTAL_RECORDS, totalRecords);
-        }
-        return m;
-    }
-
-    @Test
-    public void missingAnyCounterReturnsMinusOneInsteadOfNpe() {
-        // The regression: pre-fix each of these threw NPE (get(...).equals / 
parseLong(null)). Assert for
-        // BOTH dangling-delete flag values so the guard is proven independent 
of that branch.
-        for (boolean ignore : new boolean[] {false, true}) {
-            Assertions.assertEquals(-1L,
-                    IcebergScanPlanProvider.getCountFromSummary(summary(null, 
"0", "100"), ignore),
-                    "absent total-equality-deletes must decline pushdown, not 
NPE");
-            Assertions.assertEquals(-1L,
-                    IcebergScanPlanProvider.getCountFromSummary(summary("0", 
null, "100"), ignore),
-                    "absent total-position-deletes must decline pushdown, not 
NPE");
-            Assertions.assertEquals(-1L,
-                    IcebergScanPlanProvider.getCountFromSummary(summary("0", 
"0", null), ignore),
-                    "absent total-records must decline pushdown, not NPE");
-            Assertions.assertEquals(-1L,
-                    
IcebergScanPlanProvider.getCountFromSummary(Collections.emptyMap(), ignore),
-                    "empty summary must decline pushdown, not NPE");
-        }
-    }
-
-    @Test
-    public void noDeletesPushesTotalRecords() {
-        Assertions.assertEquals(100L,
-                IcebergScanPlanProvider.getCountFromSummary(summary("0", "0", 
"100"), false));
-    }
-
-    @Test
-    public void equalityDeletesNotPushable() {
-        // Equality deletes re-project at read time; the summary cannot net 
them out -> not pushable.
-        Assertions.assertEquals(-1L,
-                IcebergScanPlanProvider.getCountFromSummary(summary("3", "0", 
"100"), false));
-        Assertions.assertEquals(-1L,
-                IcebergScanPlanProvider.getCountFromSummary(summary("3", "0", 
"100"), true));
-    }
-
-    @Test
-    public void positionDeletesHonorDanglingFlag() {
-        // ignore dangling deletes -> netted count (total - deletes) is 
pushable; otherwise not pushable.
-        Assertions.assertEquals(90L,
-                IcebergScanPlanProvider.getCountFromSummary(summary("0", "10", 
"100"), true));
-        Assertions.assertEquals(-1L,
-                IcebergScanPlanProvider.getCountFromSummary(summary("0", "10", 
"100"), false));
-    }
-
-    @Test
-    public void allRowsDeletedNetsToZeroNotSentinel() {
-        // 100 records, 100 position deletes, ignore=true -> genuine 0. Must 
NOT collapse to the -1 sentinel
-        // (a real count of 0 is still a valid, pushable answer).
-        Assertions.assertEquals(0L,
-                IcebergScanPlanProvider.getCountFromSummary(summary("0", 
"100", "100"), true));
-    }
-}
diff --git 
a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergScanPlanProviderTest.java
 
b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergScanPlanProviderTest.java
index c5ce41fde94..73c0abd20a9 100644
--- 
a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergScanPlanProviderTest.java
+++ 
b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergScanPlanProviderTest.java
@@ -51,13 +51,16 @@ import org.apache.iceberg.DeleteFile;
 import org.apache.iceberg.FileFormat;
 import org.apache.iceberg.FileMetadata;
 import org.apache.iceberg.FileScanTask;
+import org.apache.iceberg.ManifestFile;
 import org.apache.iceberg.MetadataColumns;
 import org.apache.iceberg.Metrics;
 import org.apache.iceberg.PartitionSpec;
 import org.apache.iceberg.Schema;
+import org.apache.iceberg.Snapshot;
 import org.apache.iceberg.StructLike;
 import org.apache.iceberg.Table;
 import org.apache.iceberg.TableProperties;
+import org.apache.iceberg.TableScan;
 import org.apache.iceberg.catalog.Namespace;
 import org.apache.iceberg.catalog.TableIdentifier;
 import org.apache.iceberg.inmemory.InMemoryCatalog;
@@ -76,6 +79,8 @@ import org.junit.jupiter.api.Test;
 
 import java.io.FileNotFoundException;
 import java.io.IOException;
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Proxy;
 import java.nio.ByteBuffer;
 import java.time.ZoneId;
 import java.time.ZoneOffset;
@@ -89,6 +94,7 @@ import java.util.Map;
 import java.util.NoSuchElementException;
 import java.util.Optional;
 import java.util.Set;
+import java.util.concurrent.atomic.AtomicBoolean;
 import java.util.function.UnaryOperator;
 
 /**
@@ -143,6 +149,87 @@ public class IcebergScanPlanProviderTest {
         return builder.build();
     }
 
+    private static Table tableWithSnapshotSummary(Table table, Map<String, 
String> summaryOverrides) {
+        return (Table) Proxy.newProxyInstance(Table.class.getClassLoader(), 
new Class<?>[] {Table.class},
+                (proxy, method, args) -> wrapSnapshotSummary(invoke(method, 
table, args), summaryOverrides));
+    }
+
+    private static Table tableWithIo(Table table, FileIO fileIO) {
+        return (Table) Proxy.newProxyInstance(Table.class.getClassLoader(), 
new Class<?>[] {Table.class},
+                (proxy, method, args) -> method.getName().equals("io") ? 
fileIO : invoke(method, table, args));
+    }
+
+    private static Table tableWithMissingManifestRowsAndIo(Table table, FileIO 
fileIO) {
+        return (Table) Proxy.newProxyInstance(Table.class.getClassLoader(), 
new Class<?>[] {Table.class},
+                (proxy, method, args) -> {
+                    if (method.getName().equals("io")) {
+                        return fileIO;
+                    }
+                    return wrapMissingManifestRows(invoke(method, table, 
args));
+                });
+    }
+
+    private static Object wrapMissingManifestRows(Object value) {
+        if (value instanceof TableScan) {
+            TableScan scan = (TableScan) value;
+            return Proxy.newProxyInstance(TableScan.class.getClassLoader(), 
new Class<?>[] {TableScan.class},
+                    (proxy, method, args) -> 
wrapMissingManifestRows(invoke(method, scan, args)));
+        }
+        if (value instanceof Snapshot) {
+            Snapshot snapshot = (Snapshot) value;
+            return Proxy.newProxyInstance(Snapshot.class.getClassLoader(), new 
Class<?>[] {Snapshot.class},
+                    (proxy, method, args) -> {
+                        Object result = invoke(method, snapshot, args);
+                        if (!method.getName().equals("dataManifests")) {
+                            return result;
+                        }
+                        List<ManifestFile> manifests = new ArrayList<>();
+                        for (Object manifestValue : (List<?>) result) {
+                            ManifestFile manifest = (ManifestFile) 
manifestValue;
+                            manifests.add((ManifestFile) 
Proxy.newProxyInstance(ManifestFile.class.getClassLoader(),
+                                    new Class<?>[] {ManifestFile.class}, 
(manifestProxy, manifestMethod, manifestArgs) -> {
+                                        if 
(manifestMethod.getName().equals("addedRowsCount")
+                                                || 
manifestMethod.getName().equals("existingRowsCount")) {
+                                            return null;
+                                        }
+                                        return invoke(manifestMethod, 
manifest, manifestArgs);
+                                    }));
+                        }
+                        return manifests;
+                    });
+        }
+        return value;
+    }
+
+    private static Object wrapSnapshotSummary(Object value, Map<String, 
String> summaryOverrides) {
+        if (value instanceof TableScan) {
+            TableScan scan = (TableScan) value;
+            return Proxy.newProxyInstance(TableScan.class.getClassLoader(), 
new Class<?>[] {TableScan.class},
+                    (proxy, method, args) -> 
wrapSnapshotSummary(invoke(method, scan, args), summaryOverrides));
+        }
+        if (value instanceof Snapshot) {
+            Snapshot snapshot = (Snapshot) value;
+            return Proxy.newProxyInstance(Snapshot.class.getClassLoader(), new 
Class<?>[] {Snapshot.class},
+                    (proxy, method, args) -> {
+                        if (method.getName().equals("summary")) {
+                            Map<String, String> summary = new 
HashMap<>(snapshot.summary());
+                            summary.putAll(summaryOverrides);
+                            return summary;
+                        }
+                        return invoke(method, snapshot, args);
+                    });
+        }
+        return value;
+    }
+
+    private static Object invoke(java.lang.reflect.Method method, Object 
target, Object[] args) throws Throwable {
+        try {
+            return method.invoke(target, args);
+        } catch (InvocationTargetException e) {
+            throw e.getCause();
+        }
+    }
+
     /** Run a range's BE-param population end-to-end (the generic node 
pre-sets table_format_type). */
     private static TFileRangeDesc populate(ConnectorScanRange range) {
         TTableFormatFileDesc formatDesc = new TTableFormatFileDesc();
@@ -2131,14 +2218,38 @@ public class IcebergScanPlanProviderTest {
 
     @Test
     public void streamingSplitEstimateServableCountPushdownStaysSynchronous() {
-        // A servable COUNT(*) collapses to one range (never streamed). 3 
files, no deletes -> count servable from
-        // the snapshot summary. MUTATION: dropping the countPushdown 
short-circuit -> 3 -> red.
+        // COUNT(*) needs a complete live-file enumeration to prove an exact 
metadata count, so it never streams.
+        // MUTATION: dropping the countPushdown short-circuit -> 3 -> red.
         IcebergScanPlanProvider provider = providerOver(threeFileTable());
         long estimate = provider.streamingSplitEstimate(batchSession(2, true),
                 new IcebergTableHandle("db1", "t1"), Optional.empty(), true);
         Assertions.assertEquals(-1, estimate, "servable count pushdown must 
not stream");
     }
 
+    @Test
+    public void 
streamingSplitEstimateCountWithLiveDeleteUsesNormalStreamingScan() {
+        Table table = 
threeFileTable(Collections.singletonMap(TableProperties.FORMAT_VERSION, "2"));
+        table.newRowDelta()
+                .addDeletes(positionDeleteFile("s3://b/db/t1/pos.parquet", 
FileFormat.PARQUET, null, null))
+                .commit();
+        IcebergScanPlanProvider provider = providerOver(table);
+
+        long estimate = provider.streamingSplitEstimate(batchSession(2, true),
+                new IcebergTableHandle("db1", "t1"), Optional.empty(), true);
+
+        Assertions.assertEquals(3L, estimate,
+                "a live delete prevents metadata collapse but must retain the 
backpressured normal scan");
+    }
+
+    @Test
+    public void streamingSplitEstimateFilteredCountUsesNormalScan() {
+        // File record counts cannot answer a filtered COUNT exactly, so 
retain normal streaming scan planning.
+        IcebergScanPlanProvider provider = providerOver(threeFileTable());
+        long estimate = provider.streamingSplitEstimate(batchSession(2, true),
+                new IcebergTableHandle("db1", "t1"), Optional.of(eqInt("id", 
1)), true);
+        Assertions.assertEquals(3L, estimate);
+    }
+
     @Test
     public void streamSplitsProducesOneLazyRangePerFile() throws IOException {
         // The lazy source yields exactly one range per data file (3), with 
the raw paths preserved. This is the
@@ -2548,7 +2659,7 @@ public class IcebergScanPlanProviderTest {
         Assertions.assertEquals("oss://bucket/db/t1/f.parquet", 
fd.getOriginalFilePath());
     }
 
-    // --- T05: COUNT(*) pushdown (getCountFromSnapshot + collapse-to-one 
count range, mirrors paimon) ---
+    // --- T05: COUNT(*) pushdown (live manifest count + collapse-to-one count 
range) ---
 
     private static List<ConnectorScanRange> planCount(IcebergScanPlanProvider 
provider, ConnectorSession session,
             boolean countPushdown) {
@@ -2587,6 +2698,27 @@ public class IcebergScanPlanProviderTest {
         Assertions.assertEquals(60L, 
populate(ranges.get(0)).getTableFormatParams().getTableLevelRowCount());
     }
 
+    @Test
+    public void 
countPushdownUsesLiveDataFileRecordsWhenSnapshotSummaryIsWrong() {
+        Table table = createTable("t1", SCHEMA, PartitionSpec.unpartitioned());
+        table.newAppend()
+                .appendFile(dataFile(table.spec(), "s3://b/db/t1/f1.parquet", 
1000, null, null))
+                .appendFile(dataFile(table.spec(), "s3://b/db/t1/f2.parquet", 
2000, null, null))
+                .commit();
+        Table tableWithWrongSummary = tableWithSnapshotSummary(
+                table, Collections.singletonMap("total-records", "999"));
+        Assertions.assertEquals("999", 
tableWithWrongSummary.currentSnapshot().summary().get("total-records"),
+                "precondition: the snapshot summary must contain a valid but 
incorrect positive count");
+        IcebergScanPlanProvider provider = new IcebergScanPlanProvider(
+                IcebergCatalogProperties.of(Collections.emptyMap()), 
opsReturning(tableWithWrongSummary));
+
+        List<ConnectorScanRange> ranges = planCount(provider, null, true);
+
+        Assertions.assertEquals(1, ranges.size());
+        Assertions.assertEquals(30L, ranges.get(0).getPushDownRowCount());
+        Assertions.assertEquals(30L, 
populate(ranges.get(0)).getTableFormatParams().getTableLevelRowCount());
+    }
+
     @Test
     public void countPushdownNotAppliedWithEqualityDeletesScansAll() {
         Map<String, String> v2 = 
Collections.singletonMap(TableProperties.FORMAT_VERSION, "2");
@@ -2601,10 +2733,12 @@ public class IcebergScanPlanProviderTest {
                 .commit();
         IcebergScanPlanProvider provider = new 
IcebergScanPlanProvider(IcebergCatalogProperties.of(Collections.emptyMap()), 
opsReturning(table));
 
-        List<ConnectorScanRange> ranges = planCount(provider, null, true);
+        ConnectorSession session = new FakeScanSession("UTC",
+                Collections.singletonMap("ignore_iceberg_dangling_delete", 
"true"));
+        List<ConnectorScanRange> ranges = planCount(provider, session, true);
 
-        // Equality deletes -> getCountFromSnapshot returns -1 -> fall back to 
the normal scan (every data file,
-        // each count -1 so BE reads & counts). MUTATION: pushing the count 
anyway -> 1 range / a count >= 0 -> red.
+        // The dangling-delete compatibility flag applies only to position 
deletes. Equality deletes still force
+        // the normal scan (every data file, each count -1 so BE reads and 
counts).
         Assertions.assertEquals(2, ranges.size());
         for (ConnectorScanRange range : ranges) {
             Assertions.assertEquals(-1L, range.getPushDownRowCount());
@@ -2612,7 +2746,7 @@ public class IcebergScanPlanProviderTest {
     }
 
     @Test
-    public void countPushdownWithPositionDeletesNetsOutWhenIgnoringDangling() {
+    public void 
countPushdownWithPositionDeletesNetsDeleteRowsWhenIgnoringDangling() {
         Map<String, String> v2 = 
Collections.singletonMap(TableProperties.FORMAT_VERSION, "2");
         Table table = createTable("t1", SCHEMA, PartitionSpec.unpartitioned(), 
v2);
         // 1000/100 = 10 data records.
@@ -2634,11 +2768,10 @@ public class IcebergScanPlanProviderTest {
 
         List<ConnectorScanRange> ranges = planCount(provider, session, true);
 
-        // total-records(10) - total-position-deletes(3) = 7, pushable only 
because the session ignores dangling
-        // deletes. MUTATION: returning total-records (10) / not honoring the 
session flag -> wrong count -> red.
+        // Preserve the compatibility contract without trusting the optional 
summary: current data-manifest rows
+        // minus current position-delete file rows. The flag may still be 
inaccurate for dangling delete entries.
         Assertions.assertEquals(1, ranges.size());
         Assertions.assertEquals(7L, ranges.get(0).getPushDownRowCount());
-        Assertions.assertEquals(7L, 
populate(ranges.get(0)).getTableFormatParams().getTableLevelRowCount());
     }
 
     @Test
@@ -2671,9 +2804,7 @@ public class IcebergScanPlanProviderTest {
 
     @Test
     public void countPushdownEmptyTableProducesNoRanges() {
-        // Empty table (no snapshot) -> getCountFromSnapshot 0, but no 
representative file -> no range -> BE gets
-        // 0 ranges -> COUNT returns 0 (legacy returns empty splits too). 
MUTATION: emitting a synthetic count
-        // range with no path -> red (no file to build from).
+        // Empty table has no representative file, so BE gets 0 ranges and 
COUNT returns 0.
         Table table = createTable("t1", SCHEMA, PartitionSpec.unpartitioned());
         IcebergScanPlanProvider provider = new 
IcebergScanPlanProvider(IcebergCatalogProperties.of(Collections.emptyMap()), 
opsReturning(table));
 
@@ -2701,6 +2832,28 @@ public class IcebergScanPlanProviderTest {
         }
     }
 
+    @Test
+    public void countPushdownWithRowFilterFallsBackToNormalScan() {
+        Table table = createTable("t1", SCHEMA, PartitionSpec.unpartitioned());
+        table.newAppend()
+                .appendFile(dataFile(table.spec(), "s3://b/db/t1/f1.parquet", 
1000, null, null))
+                .appendFile(dataFile(table.spec(), "s3://b/db/t1/f2.parquet", 
1000, null, null))
+                .commit();
+        IcebergScanPlanProvider provider = new IcebergScanPlanProvider(
+                IcebergCatalogProperties.of(Collections.emptyMap()), 
opsReturning(table));
+
+        List<ConnectorScanRange> ranges = provider.planScan(null,
+                ConnectorScanRequest.builder(new IcebergTableHandle("db1", 
"t1"), Collections.emptyList())
+                        .filter(Optional.of(eqInt("id", 1)))
+                        .countPushdown(true)
+                        .build());
+
+        Assertions.assertEquals(2, ranges.size());
+        for (ConnectorScanRange range : ranges) {
+            Assertions.assertEquals(-1L, range.getPushDownRowCount());
+        }
+    }
+
     // --- T08: manifest-level scan planning (gated by 
meta.cache.iceberg.manifest.enable) ---
 
     private static Map<String, String> manifestCacheProps() {
@@ -2779,9 +2932,6 @@ public class IcebergScanPlanProviderTest {
     }
 
     // --- PERF-04: streaming (C17) + COUNT(*) (C18) paths read through the 
manifest cache, LAZILY ---
-    // (fallback-to-SDK on a cache-read failure is not unit-tested: 
IcebergManifestCache is final so it cannot be
-    //  made to throw, exactly as the pre-existing synchronous 
planFileScanTask fallback is untested; the streaming/
-    //  count catch(Exception)+recordFailure mirrors that path verbatim.)
 
     @Test
     public void 
streamSplitsManifestCacheEnabledMatchesSdkPathAndConsumesCache() throws 
IOException {
@@ -2853,8 +3003,8 @@ public class IcebergScanPlanProviderTest {
     }
 
     @Test
-    public void countPushdownManifestCacheMatchesCountAndReadsLazily() {
-        // Three appends -> three data manifests; record counts 10+20+30 = 
total-records 60 (snapshot summary).
+    public void countPushdownManifestCacheReadsOnlyRepresentativeFile() {
+        // Three appends -> three data manifests; manifest-list live-row 
aggregates sum to 10+20+30 = 60.
         Table table = createTable("t1", SCHEMA, PartitionSpec.unpartitioned());
         table.newAppend().appendFile(dataFile(table.spec(), 
"s3://b/db/t1/f1.parquet", 1024, null, null)).commit();
         table.newAppend().appendFile(dataFile(table.spec(), 
"s3://b/db/t1/f2.parquet", 2048, null, null)).commit();
@@ -2868,23 +3018,70 @@ public class IcebergScanPlanProviderTest {
         List<ConnectorScanRange> cached = 
planCount(manifestProvider(manifestCacheProps(), table, cache),
                 emptySession(), true);
 
-        // Same collapsed single range + same count (from the snapshot 
summary). The placeholder file path may
+        // Same collapsed single range + same manifest-list-derived count. The 
placeholder file path may
         // differ (SDK planFiles' ParallelIterable order is 
non-deterministic), so assert count + shape, not path.
         Assertions.assertEquals(1, sdk.size());
         Assertions.assertEquals(1, cached.size());
         Assertions.assertEquals(60L, cached.get(0).getPushDownRowCount());
         Assertions.assertEquals(sdk.get(0).getPushDownRowCount(), 
cached.get(0).getPushDownRowCount());
-        // Lazy early stop: COUNT needs only the first surviving file, so it 
must NOT read every data manifest.
-        // MUTATION: routing count through the materialized cache path -> 
reads all manifests -> size == total -> red.
-        Assertions.assertTrue(cache.size() >= 1 && cache.size() < 
totalManifests,
-                "count reads lazily (stops at the first file's manifest), not 
the whole table");
+        // The manifest list already carries exact live-row aggregates. Only 
the first manifest's entries are
+        // needed to obtain a representative file for the collapsed range.
+        Assertions.assertEquals(1, cache.size());
+    }
+
+    @Test
+    public void countPushdownManifestAggregateCacheFailureRetriesSdk() {
+        Table table = createTable("t1", SCHEMA, PartitionSpec.unpartitioned());
+        table.newAppend()
+                .appendFile(dataFile(table.spec(), "s3://b/db/t1/f1.parquet", 
1024, null, null))
+                .appendFile(dataFile(table.spec(), "s3://b/db/t1/f2.parquet", 
2048, null, null))
+                .appendFile(dataFile(table.spec(), "s3://b/db/t1/f3.parquet", 
3072, null, null))
+                .commit();
+        List<ManifestFile> manifests = 
table.currentSnapshot().dataManifests(table.io());
+        Assertions.assertEquals(1, manifests.size());
+
+        FailOnceFileIO fileIO = new FailOnceFileIO(table.io(), 
manifests.get(0).path());
+        Table wrapped = tableWithSnapshotSummary(tableWithIo(table, fileIO),
+                Collections.singletonMap("total-records", "not-a-number"));
+        IcebergManifestCache cache = new IcebergManifestCache();
+
+        // Usable manifest aggregates must not make the optional cache a 
query-availability dependency.
+        List<ConnectorScanRange> ranges = planCount(
+                manifestProvider(manifestCacheProps(), wrapped, cache), 
emptySession(), true);
+
+        Assertions.assertEquals(1, ranges.size());
+        Assertions.assertEquals(60L, ranges.get(0).getPushDownRowCount());
+        Assertions.assertTrue(fileIO.failed.get());
+        Assertions.assertEquals(1L, cache.takeStats("q")[2]);
+    }
+
+    @Test
+    public void countPushdownLateManifestCacheFailureRetriesSdk() {
+        Table table = createTable("t1", SCHEMA, PartitionSpec.unpartitioned());
+        table.newAppend().appendFile(dataFile(table.spec(), 
"s3://b/db/t1/f1.parquet", 1024, null, null)).commit();
+        table.newAppend().appendFile(dataFile(table.spec(), 
"s3://b/db/t1/f2.parquet", 2048, null, null)).commit();
+        table.newAppend().appendFile(dataFile(table.spec(), 
"s3://b/db/t1/f3.parquet", 3072, null, null)).commit();
+        List<ManifestFile> manifests = 
table.currentSnapshot().dataManifests(table.io());
+        Assertions.assertTrue(manifests.size() >= 2, "precondition: failure 
must occur after iteration starts");
+
+        FailOnceFileIO fileIO = new FailOnceFileIO(table.io(), 
manifests.get(1).path());
+        Table wrapped = tableWithMissingManifestRowsAndIo(table, fileIO);
+        IcebergManifestCache cache = new IcebergManifestCache();
+
+        // A lazy phase-two failure must discard the partial accumulator 
before retrying the SDK path.
+        List<ConnectorScanRange> ranges = planCount(
+                manifestProvider(manifestCacheProps(), wrapped, cache), 
emptySession(), true);
+
+        Assertions.assertEquals(1, ranges.size());
+        Assertions.assertEquals(60L, ranges.get(0).getPushDownRowCount());
+        Assertions.assertTrue(fileIO.failed.get());
+        Assertions.assertEquals(1L, cache.takeStats("q")[2]);
     }
 
     @Test
     public void countPushdownManifestCacheEmptyNullSnapshotReturnsNoRanges() {
-        // A never-appended table has no current snapshot; 
getCountFromSnapshot returns 0 (>=0) so planCountPushdown
-        // runs with a null-snapshot scan. cacheBackedFileScanTasks must keep 
the null-snapshot guard (empty
-        // iterable), not NPE. MUTATION: dropping the guard -> NPE on 
scan.snapshot() -> red.
+        // A never-appended table has no current snapshot. 
cacheBackedFileScanTasks must keep the null-snapshot
+        // guard (empty iterable), not NPE. MUTATION: dropping the guard -> 
NPE on scan.snapshot() -> red.
         Table table = createTable("t1", SCHEMA, PartitionSpec.unpartitioned());
         IcebergManifestCache cache = new IcebergManifestCache();
         List<ConnectorScanRange> cached = 
planCount(manifestProvider(manifestCacheProps(), table, cache),
@@ -3907,6 +4104,41 @@ public class IcebergScanPlanProviderTest {
         }
     }
 
+    /** Injects one manifest read failure while leaving the SDK scan's own 
FileIO untouched. */
+    private static final class FailOnceFileIO implements FileIO {
+        private final FileIO delegate;
+        private final String failingPath;
+        private final AtomicBoolean failed = new AtomicBoolean();
+
+        FailOnceFileIO(FileIO delegate, String failingPath) {
+            this.delegate = delegate;
+            this.failingPath = failingPath;
+        }
+
+        @Override
+        public Map<String, String> properties() {
+            return delegate.properties();
+        }
+
+        @Override
+        public InputFile newInputFile(String path) {
+            if (failingPath.equals(path) && failed.compareAndSet(false, true)) 
{
+                throw new RuntimeException("injected late manifest read 
failure");
+            }
+            return delegate.newInputFile(path);
+        }
+
+        @Override
+        public OutputFile newOutputFile(String path) {
+            return delegate.newOutputFile(path);
+        }
+
+        @Override
+        public void deleteFile(String path) {
+            delegate.deleteFile(path);
+        }
+    }
+
     /** A fake FileIO that ALSO vends StorageCredentials (a REST catalog's 
delegated creds). */
     private static final class VendedFileIO implements FileIO, 
SupportsStorageCredentials {
         private final Map<String, String> props;
diff --git a/fe/fe-core/src/main/java/org/apache/doris/qe/SessionVariable.java 
b/fe/fe-core/src/main/java/org/apache/doris/qe/SessionVariable.java
index a1592de2947..8a6c7c40bf3 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/qe/SessionVariable.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/qe/SessionVariable.java
@@ -3565,13 +3565,9 @@ public class SessionVariable implements Serializable, 
Writable {
 
     public static final String IGNORE_ICEBERG_DANGLING_DELETE = 
"ignore_iceberg_dangling_delete";
     @VarAttrDef.VarAttr(name = IGNORE_ICEBERG_DANGLING_DELETE,
-            description = " Whether to ignore the impact of dangling delete 
files in Iceberg tables on COUNT(*) "
-                    + "statistics. "
-                    + "The default is true, COUNT(*) will directly obtain the 
number of rows from metadata, "
-                    + "which has better performance, but if there are dangling 
deletes, "
-                    + "the result may be inaccurate. "
-                    + "When set to false, COUNT(*) will scan data files "
-                    + "to exclude the impact of dangling delete files.")
+            description = "Whether Iceberg metadata COUNT(*) may subtract 
position-delete record counts from "
+                    + "current data-manifest rows. This improves performance 
but can be inaccurate for dangling "
+                    + "delete entries. Equality deletes always disable 
metadata COUNT(*).")
     public boolean ignoreIcebergDanglingDelete = false;
 
     @VarAttrDef.VarAttr(name = ENABLE_ICEBERG_MERGE_PARTITIONING,


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

Reply via email to