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

englefly 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 5e35c826b36 [fix](statistics) Treat invalid column statistics as 
UNKNOWN instead of aborting analyze job (#66756)
5e35c826b36 is described below

commit 5e35c826b362fb3aa4bc686284668aa5fc10cd2d
Author: csding <[email protected]>
AuthorDate: Fri Aug 21 11:15:41 2026 +0800

    [fix](statistics) Treat invalid column statistics as UNKNOWN instead of 
aborting analyze job (#66756)
    
    ### What problem does this PR solve?
    
    Issue Number: close
    [#64122](https://github.com/apache/doris/issues/64122)
---
 .../apache/doris/statistics/BaseAnalysisTask.java  |   9 +-
 .../org/apache/doris/statistics/ColStatsData.java  |   4 +
 .../apache/doris/statistics/ColumnStatistic.java   |   5 +
 .../apache/doris/statistics/StatisticsCache.java   |  11 +-
 .../doris/statistics/BaseAnalysisTaskTest.java     |  45 +------
 .../query_p0/stats/invalid_stats/invalid_stats.out |   2 +-
 .../suites/statistics/analyze_stats.groovy         |   4 +-
 .../suites/statistics/test_analyze_all_null.groovy |   4 +-
 .../test_analyze_sample_almost_all_null.groovy     | 138 +++++++++++++++++++++
 9 files changed, 173 insertions(+), 49 deletions(-)

diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/statistics/BaseAnalysisTask.java 
b/fe/fe-core/src/main/java/org/apache/doris/statistics/BaseAnalysisTask.java
index 60e244d240a..e1317955b5f 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/statistics/BaseAnalysisTask.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/statistics/BaseAnalysisTask.java
@@ -675,9 +675,12 @@ public abstract class BaseAnalysisTask {
                 if (MetricRepo.isInit) {
                     MetricRepo.COUNTER_STATISTICS_INVALID_STATS.increase(1L);
                 }
-                String message = String.format("ColStatsData is invalid, skip 
analyzing. %s", colStatsData.toSQL(true));
-                LOG.warn(message);
-                throw new RuntimeException(message);
+                // Don't throw: keep writing the row into the statistics table 
so that the
+                // whole job still finishes. toColumnStatistic() will 
defensively convert
+                // this pattern to ColumnStatistic.UNKNOWN at read time, so 
the optimizer
+                // never sees the bogus numbers. See issue #64122.
+                LOG.warn("ColStatsData is invalid, will write to table but be 
treated as UNKNOWN at read time. {}",
+                        colStatsData.toSQL(true));
             }
             // Update index row count after analyze.
             if (this instanceof OlapAnalysisTask) {
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/statistics/ColStatsData.java 
b/fe/fe-core/src/main/java/org/apache/doris/statistics/ColStatsData.java
index 232ae506d71..f9cbacb86c5 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/statistics/ColStatsData.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/statistics/ColStatsData.java
@@ -139,6 +139,10 @@ public class ColStatsData {
 
     public ColumnStatistic toColumnStatistic() {
         try {
+            if (!isValid()) {
+                return ColumnStatistic.UNKNOWN;
+            }
+
             ColumnStatisticBuilder columnStatisticBuilder = new 
ColumnStatisticBuilder(count);
             columnStatisticBuilder.setNdv(ndv);
             columnStatisticBuilder.setNumNulls(nullCount);
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/statistics/ColumnStatistic.java 
b/fe/fe-core/src/main/java/org/apache/doris/statistics/ColumnStatistic.java
index 555fd25ff93..fd6cea7a120 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/statistics/ColumnStatistic.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/statistics/ColumnStatistic.java
@@ -142,6 +142,11 @@ public class ColumnStatistic {
      * this function is used by analyze job and cbo job.
      */
     public static ColumnStatistic fromResultRow(ResultRow row) {
+        ColStatsData statsData = new ColStatsData(row);
+        if (!statsData.isValid()) {
+            return ColumnStatistic.UNKNOWN;
+        }
+
         double count = Double.parseDouble(row.get(7));
         ColumnStatisticBuilder columnStatisticBuilder = new 
ColumnStatisticBuilder(count);
         double ndv = Double.parseDouble(row.getWithDefault(8, "0"));
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/statistics/StatisticsCache.java 
b/fe/fe-core/src/main/java/org/apache/doris/statistics/StatisticsCache.java
index 58a4a2081ce..0dbcc49de12 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/statistics/StatisticsCache.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/statistics/StatisticsCache.java
@@ -295,7 +295,7 @@ public class StatisticsCache {
     }
 
     /**
-     * Refresh stats cache, invalidate cache if the new data is unknown.
+     * Refresh stats cache, publish UNKNOWN if the new data is unknown.
      */
     public void syncColStats(ColStatsData data) {
         StatsId statsId = data.statsId;
@@ -303,7 +303,14 @@ public class StatisticsCache {
                 statsId.idxId, statsId.colId);
         ColumnStatistic columnStatistic = data.toColumnStatistic();
         if (columnStatistic == ColumnStatistic.UNKNOWN) {
-            invalidateColumnStatsCache(k.catalogId, k.dbId, k.tableId, 
k.idxId, k.colName);
+            // Publish a blocking UNKNOWN instead of invalidating. 
Invalidation leaves the
+            // entry absent, so a concurrent get() can trigger the async 
loader which reads
+            // the previous (stale) row from the statistics table before the 
buffered
+            // insert commits, and that stale value would then survive until 
the next
+            // refresh. A put closes this window: readers hit UNKNOWN directly 
and no
+            // reload races with the flush.
+            updateColStatsCache(k.catalogId, k.dbId, k.tableId, k.idxId, 
k.colName,
+                    ColumnStatistic.UNKNOWN);
         } else {
             putCache(k, columnStatistic);
         }
diff --git 
a/fe/fe-core/src/test/java/org/apache/doris/statistics/BaseAnalysisTaskTest.java
 
b/fe/fe-core/src/test/java/org/apache/doris/statistics/BaseAnalysisTaskTest.java
index c63113255c2..9e2ea6d3510 100644
--- 
a/fe/fe-core/src/test/java/org/apache/doris/statistics/BaseAnalysisTaskTest.java
+++ 
b/fe/fe-core/src/test/java/org/apache/doris/statistics/BaseAnalysisTaskTest.java
@@ -20,13 +20,10 @@ package org.apache.doris.statistics;
 import org.apache.doris.analysis.TableSample;
 import org.apache.doris.catalog.Column;
 import org.apache.doris.catalog.PrimitiveType;
-import org.apache.doris.qe.StmtExecutor;
 
 import com.google.common.collect.Lists;
 import org.junit.jupiter.api.Assertions;
 import org.junit.jupiter.api.Test;
-import org.mockito.MockedConstruction;
-import org.mockito.Mockito;
 
 import java.util.List;
 
@@ -84,24 +81,9 @@ public class BaseAnalysisTaskTest {
         values.add("500");
         values.add(null);
         ResultRow row = new ResultRow(values);
-        List<ResultRow> result = Lists.newArrayList();
-        result.add(row);
-
-        try (MockedConstruction<StmtExecutor> mocked = 
Mockito.mockConstruction(StmtExecutor.class,
-                (mock, context) -> {
-                    
Mockito.when(mock.executeInternalQuery()).thenReturn(result);
-                })) {
-            BaseAnalysisTask task = new OlapAnalysisTask();
-            try {
-                task.runQuery("test");
-            } catch (Exception e) {
-                Assertions.assertEquals(e.getMessage(),
-                        "ColStatsData is invalid, skip analyzing. "
-                                + 
"('id',10000,20000,30000,0,'col',null,100,1100,300,'min','max',400,'500',NULL)");
-                return;
-            }
-            Assertions.fail();
-        }
+        ColStatsData data = new ColStatsData(row);
+        Assertions.assertFalse(data.isValid());
+        Assertions.assertEquals(ColumnStatistic.UNKNOWN, 
data.toColumnStatistic());
     }
 
     @Test
@@ -123,23 +105,8 @@ public class BaseAnalysisTaskTest {
         values.add("500");
         values.add(null);
         ResultRow row = new ResultRow(values);
-        List<ResultRow> result = Lists.newArrayList();
-        result.add(row);
-
-        try (MockedConstruction<StmtExecutor> mocked = 
Mockito.mockConstruction(StmtExecutor.class,
-                (mock, context) -> {
-                    
Mockito.when(mock.executeInternalQuery()).thenReturn(result);
-                })) {
-            BaseAnalysisTask task = new OlapAnalysisTask();
-            try {
-                task.runQuery("test");
-            } catch (Exception e) {
-                Assertions.assertEquals(e.getMessage(),
-                        "ColStatsData is invalid, skip analyzing. "
-                                + 
"('id',10000,20000,30000,0,'col',null,500,0,300,'min','max',400,'500',NULL)");
-                return;
-            }
-            Assertions.fail();
-        }
+        ColStatsData data = new ColStatsData(row);
+        Assertions.assertFalse(data.isValid());
+        Assertions.assertEquals(ColumnStatistic.UNKNOWN, 
data.toColumnStatistic());
     }
 }
diff --git 
a/regression-test/data/query_p0/stats/invalid_stats/invalid_stats.out 
b/regression-test/data/query_p0/stats/invalid_stats/invalid_stats.out
index 9b1b2e2aa97..84eab1a26a6 100644
--- a/regression-test/data/query_p0/stats/invalid_stats/invalid_stats.out
+++ b/regression-test/data/query_p0/stats/invalid_stats/invalid_stats.out
@@ -26,6 +26,6 @@ PhysicalResultSink
 -- !ndv_row_invalid --
 PhysicalResultSink
 --hashJoin[INNER_JOIN broadcast] hashCondition=((region.r_regionkey = 
nation.n_regionkey)) otherCondition=()
-----PhysicalOlapScan[region]
 ----PhysicalOlapScan[nation]
+----PhysicalOlapScan[region]
 
diff --git a/regression-test/suites/statistics/analyze_stats.groovy 
b/regression-test/suites/statistics/analyze_stats.groovy
index 2f715c92f55..7814f2924d1 100644
--- a/regression-test/suites/statistics/analyze_stats.groovy
+++ b/regression-test/suites/statistics/analyze_stats.groovy
@@ -2788,9 +2788,9 @@ PARTITION `p599` VALUES IN (599)
     sql """alter table alter_test modify column id set stats 
('row_count'='100', 'ndv'='0', 'num_nulls'='0.0', 'data_size'='2.69975443E8', 
'min_value'='1', 'max_value'='2');"""
     alter_result = sql """show column stats alter_test(id)"""
     logger.info("show column alter_test(id) stats: " + alter_result)
-    assertEquals(1, alter_result.size())
+    assertEquals(0, alter_result.size())
     alter_result = sql """show column cached stats alter_test(id)"""
-    assertEquals(1, alter_result.size())
+    assertEquals(0, alter_result.size())
     sql """alter table alter_test modify column id set stats 
('row_count'='100', 'ndv'='0', 'num_nulls'='100', 'data_size'='2.69975443E8', 
'min_value'='1', 'max_value'='2');"""
     alter_result = sql """show column stats alter_test(id)"""
     logger.info("show column alter_test(id) stats: " + alter_result)
diff --git a/regression-test/suites/statistics/test_analyze_all_null.groovy 
b/regression-test/suites/statistics/test_analyze_all_null.groovy
index 44d2f3a6c1f..0ef536afcd6 100644
--- a/regression-test/suites/statistics/test_analyze_all_null.groovy
+++ b/regression-test/suites/statistics/test_analyze_all_null.groovy
@@ -95,12 +95,12 @@ suite("test_analyze_all_null") {
     sql """alter table invalidTest modify column col2 set stats 
('row_count'='100', 'ndv'='0', 'num_nulls'='0.0', 'data_size'='3.2E8', 
'min_value'='min', 'max_value'='max');"""
     sql """alter table invalidTest modify column col3 set stats 
('row_count'='100', 'ndv'='0', 'num_nulls'='100', 'data_size'='3.2E8', 
'min_value'='min', 'max_value'='max');"""
     result = sql """show column cached stats invalidTest"""
-    assertEquals(3, result.size())
+    assertEquals(2, result.size())
 
     explain {
         sql("memo plan select * from invalidTest")
         contains "col1#0 -> ndv=100.0000"
-        contains "col2#1 -> ndv=0.0000"
+        contains "col2#1 -> unknown("
         contains "col3#2 -> ndv=0.0000"
     }
 
diff --git 
a/regression-test/suites/statistics/test_analyze_sample_almost_all_null.groovy 
b/regression-test/suites/statistics/test_analyze_sample_almost_all_null.groovy
new file mode 100644
index 00000000000..13f7f6a3479
--- /dev/null
+++ 
b/regression-test/suites/statistics/test_analyze_sample_almost_all_null.groovy
@@ -0,0 +1,138 @@
+// 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.
+
+// Regression for issue #64122: ColStatsData.isValid() falsely rejects
+// sampled column statistics when a column is (almost) all NULL.
+//
+// On a Unique-Key MoW table where column v is almost entirely NULL but
+// has one surviving non-null value, sample analyze produces
+//   ndv=0 (estimated), min=max='x' (full-scan), nullCount != count
+// which trips the second isValid() guard. Before the fix, runQuery()
+// threw and aborted the whole analyze job; after the fix the row is
+// written and toColumnStatistic() falls back to UNKNOWN at read time.
+suite("test_analyze_sample_almost_all_null", "nonConcurrent") {
+
+    def wait_row_count_at_least = { db, table, threshold ->
+        // For Unique MoW the post-DELETE row count is non-trivial to predict
+        // exactly, so we just gate on "row count is reported and large 
enough",
+        // which is what we need to trigger the isValid() guard. count=0 would
+        // short-circuit isValid() and the issue would not reproduce.
+        def result = sql """show frontends;"""
+        def host
+        def port
+        for (int i = 0; i < result.size(); i++) {
+            if (result[i][8] == "true") {
+                host = result[i][1]
+                port = result[i][4]
+            }
+        }
+        def tokens = context.config.jdbcUrl.split('/')
+        def url = tokens[0] + "//" + host + ":" + port
+        connect(context.config.jdbcUser, context.config.jdbcPassword, url) {
+            sql """use ${db}"""
+            for (int i = 0; i < 120; i++) {
+                Thread.sleep(5000)
+                result = sql """SHOW DATA FROM ${table};"""
+                logger.info("SHOW DATA FROM ${table}: " + result)
+                // Sum the row-count column across all rows returned by SHOW 
DATA.
+                // Layout: rows are per-partition + a Total row at the end. The
+                // row-count column index is 4 (same assumption as the existing
+                // test_analyze_all_null suite).
+                def total = 0L
+                for (int r = 0; r < result.size(); r++) {
+                    def v = result[r][4]
+                    if (v == null) {
+                        continue
+                    }
+                    try {
+                        total += Long.parseLong(v.toString())
+                    } catch (NumberFormatException ignored) {
+                        // "Total" row may already be a formatted string; skip.
+                    }
+                }
+                if (total >= threshold) {
+                    return
+                }
+            }
+            throw new Exception("Row count report timeout for ${db}.${table}, "
+                    + "threshold=" + threshold + ", last result=" + result)
+        }
+    }
+
+    sql """drop database if exists 
regression_test_analyze_sample_almost_all_null"""
+    sql """create database regression_test_analyze_sample_almost_all_null"""
+    sql """use regression_test_analyze_sample_almost_all_null"""
+
+    // The suite mutates the cluster-global enable_auto_analyze, so it runs in 
the
+    // nonConcurrent group and restores the variable via setGlobalVarTemporary 
to
+    // avoid leaking the disabled state into other suites on failure.
+    setGlobalVarTemporary([enable_auto_analyze: false], {
+        sql """CREATE TABLE tbl_del_big (
+                k INT NOT NULL,
+                v VARCHAR(64) NULL
+            )
+            UNIQUE KEY(k)
+            DISTRIBUTED BY HASH(k) BUCKETS 64
+            PROPERTIES (
+                "replication_num" = "1",
+                "enable_unique_key_merge_on_write" = "true"
+            )
+        """
+
+        // 2M rows with v=NULL; then 1 row with v='x' overwriting (k=1, NULL).
+        // DELETE half of the NULL rows so the surviving data set is ~1M rows,
+        // with exactly one non-null v value.
+        sql """INSERT INTO tbl_del_big SELECT number, NULL FROM 
numbers("number"="2000000")"""
+        sql """INSERT INTO tbl_del_big SELECT number * 64 + 1, 'x' FROM 
numbers("number"="1")"""
+        sql """DELETE FROM tbl_del_big WHERE k % 2 = 0 AND v IS NULL"""
+
+        
wait_row_count_at_least("regression_test_analyze_sample_almost_all_null",
+                "tbl_del_big", 500000L)
+
+        sql """ANALYZE TABLE tbl_del_big WITH SAMPLE PERCENT 1 WITH SYNC"""
+
+        def result = sql """show column stats tbl_del_big"""
+
+        // k (NOT NULL) always produces valid sampled stats. Whether v also 
survives
+        // isValid() depends on whether the single 'x' row lands in one of the 
randomly
+        // chosen sample tablets (sampled -> ndv ~ 1, valid; not sampled -> 
ndv = 0 with
+        // full-scan min/max = 'x', invalid). So only assert on k and a loose 
row count.
+        assertTrue(result.size() >= 1)
+        assertTrue(result.any { it[0] == "k" })
+
+        // Deterministically construct the issue #64122 invalid pattern. SET 
STATS writes
+        // the row into the statistics table directly (no isValid check on 
that path) and
+        // syncColStats publishes UNKNOWN into the cache. Any later read also 
goes through
+        // ColumnStatistic.fromResultRow, whose isValid() guard returns 
UNKNOWN for
+        // ndv=0 + min/max!=null + nullCount!=count, so the optimizer must see 
unknown.
+        sql """ALTER TABLE tbl_del_big MODIFY COLUMN v SET STATS (
+                'row_count'='1000000', 'ndv'='0', 'num_nulls'='999999',
+                'data_size'='8000000', 'min_value'='x', 'max_value'='x')"""
+
+        explain {
+            sql("select * from tbl_del_big")
+            contains("planned with unknown column statistics")
+        }
+
+        explain {
+            sql("memo plan select * from tbl_del_big")
+            contains("v#1 -> unknown(")
+        }
+    })
+
+    sql """drop database if exists 
regression_test_analyze_sample_almost_all_null"""
+}


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

Reply via email to