github-actions[bot] commented on code in PR #68128:
URL: https://github.com/apache/doris/pull/68128#discussion_r4034470956


##########
regression-test/suites/external_table_p0/iceberg/test_iceberg_nan_filter.groovy:
##########
@@ -0,0 +1,127 @@
+// 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.
+
+// DORIS-29047. Doris orders NaN above every other floating-point value, so `d 
> 5` and `d >= 5` are true for a
+// NaN row. Iceberg's file metrics take the opposite view: NaN is excluded 
from the lower/upper bounds and an
+// all-NaN column is flagged via nan_value_counts, so pushing a bare 
greaterThan prunes files that do hold
+// matching rows -- BE never sees the split and the rows vanish silently. The 
fix ORs an is_nan arm into the
+// pushed range predicate; the inputSplitNum assertions below pin that a 
NaN-free file is STILL pruned, so a
+// future "just stop pushing float ranges" regression is caught as well as a 
re-introduced row loss.
+//
+// Data 
(docker/thirdparties/docker-compose/iceberg/scripts/create_preinstalled_scripts/iceberg/run32.sql),
+// one file per row group of the listing:
+//   nan_filter_double  file1 {1.0, NaN}  file2 {NaN}  file3 {8.0}  file4 {1.0}
+//   nan_filter_float   file1 {1.0, NaN}  file2 {2.0}
+suite("test_iceberg_nan_filter", "p0,external") {
+    String enabled = context.config.otherConfigs.get("enableIcebergTest")
+    if (enabled == null || !enabled.equalsIgnoreCase("true")) {
+        return
+    }
+
+    String rest_port = context.config.otherConfigs.get("iceberg_rest_uri_port")
+    String minio_port = context.config.otherConfigs.get("iceberg_minio_port")
+    String externalEnvIp = context.config.otherConfigs.get("externalEnvIp")
+    String catalog_name = "test_iceberg_nan_filter"
+
+    sql """set enable_external_table_batch_mode=false"""
+    sql """drop catalog if exists ${catalog_name}"""
+    sql """CREATE CATALOG ${catalog_name} PROPERTIES (
+            'type'='iceberg',
+            'iceberg.catalog.type'='rest',
+            'uri' = 'http://${externalEnvIp}:${rest_port}',
+            "s3.access_key" = "admin",
+            "s3.secret_key" = "password",
+            "s3.endpoint" = "http://${externalEnvIp}:${minio_port}";,
+            "s3.region" = "us-east-1"
+        );"""
+
+    try {
+        sql """switch ${catalog_name}"""
+        sql """use test_db"""
+
+        def ids = { String query -> sql(query).collect { (it[0] as Integer) 
}.sort() }
+
+        // --- rows: a NaN row satisfies > and >=, so it must survive file 
pruning --------------------------
+        // id=2 and id=3 are the NaN rows, id=4 is 8.0.
+        assertEquals([2, 3, 4], ids("select id from nan_filter_double where d 
> 5"))

Review Comment:
   [P2] Use generated golden output for deterministic rows
   
   These fixed, sorted query results are checked with `assertEquals`, but the 
repository regression contract requires `qt`/`order_qt` (or explicit `ORDER 
BY`) and an auto-generated `.out` for determined results. Please convert these 
row checks to named `order_qt_*` cases and generate/commit the output with the 
regression runner; the EXPLAIN `contains` checks can remain plan assertions.



##########
fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergPredicateConverterNaNTest.java:
##########
@@ -0,0 +1,341 @@
+// 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.apache.doris.connector.spi.ConnectorType;
+import org.apache.doris.connector.spi.pushdown.ConnectorAnd;
+import org.apache.doris.connector.spi.pushdown.ConnectorBetween;
+import org.apache.doris.connector.spi.pushdown.ConnectorColumnRef;
+import org.apache.doris.connector.spi.pushdown.ConnectorComparison;
+import org.apache.doris.connector.spi.pushdown.ConnectorExpression;
+import org.apache.doris.connector.spi.pushdown.ConnectorIn;
+import org.apache.doris.connector.spi.pushdown.ConnectorLiteral;
+import org.apache.doris.connector.spi.pushdown.ConnectorNot;
+
+import org.apache.iceberg.DataFile;
+import org.apache.iceberg.DataFiles;
+import org.apache.iceberg.FileFormat;
+import org.apache.iceberg.Metrics;
+import org.apache.iceberg.PartitionSpec;
+import org.apache.iceberg.Schema;
+import org.apache.iceberg.expressions.Expression;
+import org.apache.iceberg.expressions.InclusiveMetricsEvaluator;
+import org.apache.iceberg.types.Conversions;
+import org.apache.iceberg.types.Type;
+import org.apache.iceberg.types.Types;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+import java.nio.ByteBuffer;
+import java.time.ZoneOffset;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * DORIS-29047: Doris orders NaN above every other floating-point value 
(be/src/common/compare.h), so
+ * {@code d > 0} and {@code d >= 0} are true for NaN. Iceberg's 
metrics/manifest evaluators instead assume NaN
+ * never satisfies a range comparison: NaN is excluded from lower/upper bounds 
and an all-NaN file is pruned
+ * outright. Pushing a bare {@code greaterThan} therefore prunes files that 
hold matching NaN rows, and BE never
+ * sees the split — the rows are silently lost.
+ *
+ * <p>These tests assert the SEMANTICS (what {@link InclusiveMetricsEvaluator} 
does to a file's metrics), not the
+ * rendered expression string: a string assertion cannot tell a correct 
rewrite from one that also destroys the
+ * pruning of NaN-free files. Each range case therefore pins three metrics 
shapes — an all-NaN file, a
+ * {1.0, NaN} file whose bounds omit the NaN, and a NaN-free file that must 
STILL be pruned.</p>
+ */
+public class IcebergPredicateConverterNaNTest {
+
+    private static final Schema SCHEMA = new Schema(
+            Types.NestedField.required(1, "c_int", Types.IntegerType.get()),
+            Types.NestedField.required(4, "c_float", Types.FloatType.get()),
+            Types.NestedField.required(5, "c_double", Types.DoubleType.get()));
+
+    private static final int DOUBLE_ID = 5;
+    private static final int FLOAT_ID = 4;
+
+    private static IcebergPredicateConverter converter() {
+        return new IcebergPredicateConverter(SCHEMA, ZoneOffset.UTC);
+    }
+
+    private static ConnectorColumnRef col(String name) {
+        return new ConnectorColumnRef(name, ConnectorType.of("UNKNOWN"));
+    }
+
+    private static ConnectorLiteral doubleLit(double value) {
+        return new ConnectorLiteral(ConnectorType.of("DOUBLE"), value);
+    }
+
+    private static ConnectorLiteral floatLit(double value) {
+        return new ConnectorLiteral(ConnectorType.of("FLOAT"), value);
+    }
+
+    private static ConnectorComparison cmp(ConnectorComparison.Operator op, 
String colName, ConnectorLiteral lit) {
+        return new ConnectorComparison(op, col(colName), lit);
+    }
+
+    /** The single pushed predicate for {@code expr}; fails loudly when the 
converter dropped it. */
+    private static Expression pushed(ConnectorExpression expr) {
+        List<Expression> out = converter().convert(expr);
+        Assertions.assertEquals(1, out.size(), "expected exactly one pushed 
predicate for " + expr);
+        return out.get(0);
+    }
+
+    /**
+     * A data file carrying only the metrics the evaluators consult. {@code 
nanCount == null} models a writer that
+     * reports no nan_value_counts at all (Doris' own iceberg writer, see 
IcebergWriterHelper), the shape where a
+     * hidden NaN is completely invisible in the metadata.
+     */
+    private static DataFile file(int fieldId, Type type, long rows, Long 
nanCount, Object lower, Object upper) {
+        Map<Integer, Long> nans = nanCount == null ? null : 
Collections.singletonMap(fieldId, nanCount);
+        Map<Integer, ByteBuffer> lowers = lower == null ? null
+                : Collections.singletonMap(fieldId, 
Conversions.toByteBuffer(type, lower));
+        Map<Integer, ByteBuffer> uppers = upper == null ? null
+                : Collections.singletonMap(fieldId, 
Conversions.toByteBuffer(type, upper));
+        Metrics metrics = new Metrics(rows, null, 
Collections.singletonMap(fieldId, rows),
+                Collections.singletonMap(fieldId, 0L), nans, lowers, uppers);
+        return DataFiles.builder(PartitionSpec.unpartitioned())
+                .withPath("/tmp/doris-29047-" + fieldId + "-" + rows + "-" + 
nanCount + "-" + upper + ".parquet")
+                .withFormat(FileFormat.PARQUET)
+                .withFileSizeInBytes(64)
+                .withRecordCount(rows)
+                .withMetrics(metrics)
+                .build();
+    }
+
+    private static boolean mightMatch(Expression expr, DataFile dataFile) {
+        return new InclusiveMetricsEvaluator(SCHEMA, expr).eval(dataFile);
+    }
+
+    /** All-NaN file: Iceberg's containsNaNsOnly shortcut prunes it for any 
range predicate. */
+    private static DataFile allNaNDouble() {
+        return file(DOUBLE_ID, Types.DoubleType.get(), 1L, 1L, null, null);
+    }
+
+    /** {1.0, NaN} written by a writer that reports nan counts (Spark/Flink): 
the bounds still omit the NaN. */
+    private static DataFile mixedNaNDouble() {
+        return file(DOUBLE_ID, Types.DoubleType.get(), 2L, 1L, 1.0d, 1.0d);
+    }
+
+    /** {1.0, NaN} written by a writer that reports NO nan counts (Doris' 
writer): the NaN is invisible. */
+    private static DataFile mixedNaNDoubleWithoutNaNCount() {
+        return file(DOUBLE_ID, Types.DoubleType.get(), 2L, null, 1.0d, 1.0d);
+    }
+
+    /** A NaN-free file below the literal: pruning it is the whole point of 
pushdown and must be preserved. */
+    private static DataFile nanFreeDouble() {
+        return file(DOUBLE_ID, Types.DoubleType.get(), 1L, 0L, 1.0d, 1.0d);
+    }
+
+    private static void 
assertKeepsNaNFilesAndStillPrunesNaNFreeFile(Expression expr) {
+        Assertions.assertTrue(mightMatch(expr, allNaNDouble()), "all-NaN file 
must not be pruned: " + expr);
+        Assertions.assertTrue(mightMatch(expr, mixedNaNDouble()), "{1.0, NaN} 
file must not be pruned: " + expr);
+        Assertions.assertTrue(mightMatch(expr, 
mixedNaNDoubleWithoutNaNCount()),
+                "{1.0, NaN} file without nan counts must not be pruned: " + 
expr);
+        Assertions.assertFalse(mightMatch(expr, nanFreeDouble()),
+                "a NaN-free file below the literal must still be pruned: " + 
expr);
+    }
+
+    /**
+     * {@code c_double > 5} keeps every file that may hold a NaN, because 
Doris returns NaN rows for it.
+     * MUTATION: pushing a bare {@code greaterThan} prunes the two NaN files 
-> red.
+     */
+    @Test
+    public void greaterThanOnDoubleKeepsFilesHoldingNaN() {
+        assertKeepsNaNFilesAndStillPrunesNaNFreeFile(
+                pushed(cmp(ConnectorComparison.Operator.GT, "c_double", 
doubleLit(5.0d))));
+    }
+
+    /** {@code c_double >= 5}: same, NaN satisfies GE in Doris too. */
+    @Test
+    public void greaterThanOrEqualOnDoubleKeepsFilesHoldingNaN() {
+        assertKeepsNaNFilesAndStillPrunesNaNFreeFile(
+                pushed(cmp(ConnectorComparison.Operator.GE, "c_double", 
doubleLit(5.0d))));
+    }
+
+    /** A FLOAT column behaves like DOUBLE (the guard keys off the iceberg 
column type, not the literal). */
+    @Test
+    public void greaterThanOnFloatKeepsFilesHoldingNaN() {
+        Expression expr = pushed(cmp(ConnectorComparison.Operator.GT, 
"c_float", floatLit(5.0d)));
+        Assertions.assertTrue(mightMatch(expr, file(FLOAT_ID, 
Types.FloatType.get(), 1L, 1L, null, null)),
+                "all-NaN float file must not be pruned: " + expr);
+        Assertions.assertTrue(mightMatch(expr, file(FLOAT_ID, 
Types.FloatType.get(), 2L, 1L, 1.0f, 1.0f)),
+                "{1.0, NaN} float file must not be pruned: " + expr);
+        Assertions.assertFalse(mightMatch(expr, file(FLOAT_ID, 
Types.FloatType.get(), 1L, 0L, 1.0f, 1.0f)),
+                "a NaN-free float file below the literal must still be pruned: 
" + expr);
+    }
+
+    /**
+     * {@code c_double > 0.5} arrives as a BigDecimal literal from Nereids (an 
unsuffixed decimal), and
+     * {@code c_double > '0'} as a coerced double; both must get the same 
treatment as a DOUBLE literal.
+     */
+    @Test
+    public void greaterThanWithDecimalLiteralKeepsFilesHoldingNaN() {
+        
assertKeepsNaNFilesAndStillPrunesNaNFreeFile(pushed(cmp(ConnectorComparison.Operator.GT,
 "c_double",
+                new ConnectorLiteral(ConnectorType.of("DECIMALV3", 2, 1), new 
java.math.BigDecimal("5.0")))));
+    }
+
+    /** Integral columns keep the plain range predicate — no is_nan arm, no 
lost pruning. */
+    @Test
+    public void rangePredicateOnIntColumnIsUnchanged() {
+        Expression gt = pushed(cmp(ConnectorComparison.Operator.GT, "c_int",
+                new ConnectorLiteral(ConnectorType.of("INT"), 5L)));
+        Assertions.assertEquals(Expression.Operation.GT, gt.op(), 
gt.toString());
+    }
+
+    /**
+     * LT/LE are left alone: Doris evaluates {@code NaN < v} and {@code NaN <= 
v} as false, exactly what
+     * Iceberg's pruning already assumes. Adding an is_nan arm here would keep 
NaN files for nothing.
+     */
+    @Test
+    public void lessThanOnDoubleIsUnchanged() {
+        Expression lt = pushed(cmp(ConnectorComparison.Operator.LT, 
"c_double", doubleLit(5.0d)));
+        Assertions.assertEquals(Expression.Operation.LT, lt.op(), 
lt.toString());
+        Assertions.assertFalse(mightMatch(lt, allNaNDouble()), "LT must still 
prune an all-NaN file");
+
+        Expression le = pushed(cmp(ConnectorComparison.Operator.LE, 
"c_double", doubleLit(5.0d)));
+        Assertions.assertEquals(Expression.Operation.LT_EQ, le.op(), 
le.toString());
+        Assertions.assertFalse(mightMatch(le, allNaNDouble()), "LE must still 
prune an all-NaN file");
+    }
+
+    /**
+     * {@code NOT (c_double < 5)} is true for NaN in Doris. Iceberg's 
RewriteNot turns {@code not(lt)} into
+     * {@code gtEq} during binding, which prunes the NaN files again — so the 
converter must negate in Doris
+     * semantics itself (floats are totally ordered there) and let the GE path 
add the is_nan arm.
+     * MUTATION: emitting {@code Expressions.not(lessThan(...))} -> red.
+     */
+    @Test
+    public void notLessThanOnDoubleKeepsFilesHoldingNaN() {
+        assertKeepsNaNFilesAndStillPrunesNaNFreeFile(
+                pushed(new ConnectorNot(cmp(ConnectorComparison.Operator.LT, 
"c_double", doubleLit(5.0d)))));
+    }
+
+    /** {@code NOT (c_double <= 5)} negates to GT, which likewise keeps NaN 
files. */
+    @Test
+    public void notLessThanOrEqualOnDoubleKeepsFilesHoldingNaN() {
+        assertKeepsNaNFilesAndStillPrunesNaNFreeFile(
+                pushed(new ConnectorNot(cmp(ConnectorComparison.Operator.LE, 
"c_double", doubleLit(5.0d)))));
+    }
+
+    /** {@code NOT (c_double > 5)} is false for NaN in Doris, so the negated 
LE may keep pruning NaN files. */
+    @Test
+    public void notGreaterThanOnDoublePrunesNaNFiles() {
+        Expression expr = pushed(new 
ConnectorNot(cmp(ConnectorComparison.Operator.GT, "c_double",
+                doubleLit(5.0d))));
+        Assertions.assertFalse(mightMatch(expr, allNaNDouble()), "NOT(d > 5) 
must still prune an all-NaN file");
+    }
+
+    /**
+     * A NOT over a compound node touching a float column is not pushed at 
all: Iceberg's De Morgan rewrite would
+     * turn the inner {@code c_double < 5} into {@code c_double >= 5} with no 
is_nan arm. BE still filters.
+     */
+    @Test
+    public void notOverCompoundWithFloatColumnIsNotPushed() {
+        ConnectorExpression expr = new ConnectorNot(new 
ConnectorAnd(Arrays.asList(
+                cmp(ConnectorComparison.Operator.LT, "c_double", 
doubleLit(5.0d)),
+                cmp(ConnectorComparison.Operator.EQ, "c_int", new 
ConnectorLiteral(ConnectorType.of("INT"), 1L)))));
+        Assertions.assertTrue(converter().convert(expr).isEmpty(), "NOT over a 
compound float node must be dropped");
+    }
+
+    /**
+     * Iceberg refuses to build a literal from NaN ("Cannot create expression 
literal from NaN"), and the scan
+     * planner does not catch it, so a NaN literal must never reach {@code 
Expressions.*}. The comparisons that
+     * have an exact iceberg form are mapped; the rest are dropped for BE to 
evaluate.
+     */
+    @Test
+    public void nanLiteralComparisonsMapToNaNPredicates() {
+        ConnectorLiteral nan = doubleLit(Double.NaN);
+        Assertions.assertEquals(Expression.Operation.IS_NAN,
+                pushed(cmp(ConnectorComparison.Operator.EQ, "c_double", 
nan)).op());
+        Assertions.assertEquals(Expression.Operation.IS_NAN,
+                pushed(cmp(ConnectorComparison.Operator.EQ_FOR_NULL, 
"c_double", nan)).op());
+        Assertions.assertEquals(Expression.Operation.IS_NAN,
+                pushed(cmp(ConnectorComparison.Operator.GE, "c_double", 
nan)).op());
+        Assertions.assertEquals(Expression.Operation.NOT_NAN,
+                pushed(cmp(ConnectorComparison.Operator.NE, "c_double", 
nan)).op());
+        Assertions.assertEquals(Expression.Operation.NOT_NAN,
+                pushed(cmp(ConnectorComparison.Operator.LT, "c_double", 
nan)).op());
+        // d > NaN is never true and d <= NaN is always true for non-null 
rows: no exact narrowing form, drop.
+        
Assertions.assertTrue(converter().convert(cmp(ConnectorComparison.Operator.GT, 
"c_double", nan)).isEmpty());
+        
Assertions.assertTrue(converter().convert(cmp(ConnectorComparison.Operator.LE, 
"c_double", nan)).isEmpty());
+    }
+
+    /** An IN list holding NaN must not blow up planning either. */
+    @Test
+    public void inWithNaNElementDoesNotThrow() {
+        ConnectorIn in = new ConnectorIn(col("c_double"),
+                Arrays.asList(doubleLit(1.0d), doubleLit(Double.NaN)), false);
+        Assertions.assertDoesNotThrow(() -> converter().convert(in));

Review Comment:
   [P2] Assert the NaN IN predicate semantics
   
   This only proves planning does not throw: an empty conversion or the wrong 
`IN`/`NOT IN` boolean tree would still pass. The conflict-mode NaN-IN check 
below has the same weakness and would not catch an unsafely narrowed filter 
that misses a concurrent NaN write. Please assert the exact SCAN and CONFLICT 
expression trees and evaluate representative NaN/ordinary/null metrics so these 
reachable contracts are protected.



##########
fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergPredicateConverter.java:
##########
@@ -223,7 +223,23 @@ private Expression buildOr(ConnectorOr or) {
     }
 
     private Expression buildNot(ConnectorNot not) {
-        Expression child = convertSingle(not.getOperand());
+        ConnectorExpression operand = not.getOperand();
+        if (operand instanceof ConnectorComparison) {
+            ConnectorComparison cmp = (ConnectorComparison) operand;
+            ConnectorComparison.Operator negated = 
negateRange(cmp.getOperator());
+            if (negated != null && isFloatingPointColumn(cmp.getLeft())) {
+                // Iceberg's RewriteNot negates `col < v` into `col >= v` 
under IEEE semantics, where NaN
+                // satisfies neither -- so the NaN files this converter's 
GT/GE arm keeps would be pruned again.
+                // Negate in Doris semantics instead (non-null floats are 
totally ordered there, NaN last), which
+                // routes the result back through buildComparison and its 
isNaN arm.
+                return convertSingle(new ConnectorComparison(negated, 
cmp.getLeft(), cmp.getRight()));
+            }
+        } else if (referencesFloatingPointColumn(operand)) {

Review Comment:
   [P1] Keep exact non-range NOT predicates in rewrite mode
   
   This bailout also catches exact, parser-produced forms in `Mode.REWRITE`. 
`FLOAT`/`DOUBLE IS NOT NULL` is lowered as 
`ConnectorNot(ConnectorIsNull(...))`, so this branch returns null; 
`RewriteDataFilePlanner` then rejects the entire `rewrite_data_files` WHERE 
because that path has no BE residual. Before this change the same form became 
`not(isNull(...))`, which Iceberg can represent exactly. Please limit the 
bailout to negations that expose an unsafe floating range (or handle 
`ConnectorIsNull` explicitly), and test the actual 
`ConnectorNot(ConnectorIsNull(...))` shape.



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

To unsubscribe, e-mail: [email protected]

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


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

Reply via email to