Repository navigation
Open
Labels
Description
What is the bug?
When using isnotnull() with certain conditions, it won't be pushed down
How can one reproduce the bug?
Steps to reproduce the behavior:
- Run
POST /testindex/_doc
{
"a": "1"
}
POST /testindex/_doc
{
"a": "2"
}
POST /testindex/_doc
{
"a": null,
"b": "1"
}
POST /_plugins/_ppl
{
"query": "source = testindex | where a != '1' and a != '2' and isnotnull(a)"
}- See query result includes doc where
ais null
{
"schema": [
{
"name": "a",
"type": "string"
},
{
"name": "b",
"type": "string"
}
],
"datarows": [
[
null,
"1"
]
],
"total": 1,
"size": 1
}What is the expected behavior?
isnotnull() should always filter out null values
What is your host/environment?
- OS: [e.g. iOS] linux
- Version [e.g. 22] 2.19
- Plugins all
Do you have any screenshots?
If applicable, add screenshots to help explain your problem.
Do you have any additional context?
explain
// source = testindex | where a != '1' and isnotnull(a)
{
"calcite": {
"logical": """LogicalSystemLimit(fetch=[10000], type=[QUERY_SIZE_LIMIT])
LogicalProject(a=[$0], b=[$1])
LogicalFilter(condition=[<>($0, '1')])
CalciteLogicalIndexScan(table=[[OpenSearch, testindex]])
""",
"physical": """CalciteEnumerableIndexScan(table=[[OpenSearch, testindex]], PushDownContext=[[PROJECT->[a, b], FILTER-><>($0, '1'), LIMIT->10000], OpenSearchRequestBuilder(sourceBuilder={"from":0,"size":10000,"timeout":"1m","query":{"bool":{"must":[{"exists":{"field":"a","boost":1.0}}],"must_not":[{"term":{"a.keyword":{"value":"1","boost":1.0}}}],"adjust_pure_negative":true,"boost":1.0}},"_source":{"includes":["a","b"],"excludes":[]}}, requestedTotalSize=10000, pageSize=null, startFrom=0)])
"""
}
}
{
"from": 0,
"size": 10000,
"timeout": "1m",
"query": {
"bool": {
"must": [{ "exists": { "field": "a", "boost": 1.0 } }],
"must_not": [{ "term": { "a.keyword": { "value": "1", "boost": 1.0 } } }],
"adjust_pure_negative": true,
"boost": 1.0
}
},
"_source": { "includes": ["a", "b"], "excludes": [] }
}// source = testindex | where a != '1' and a != '2' and isnotnull(a)
{
"calcite": {
"logical": """LogicalSystemLimit(fetch=[10000], type=[QUERY_SIZE_LIMIT])
LogicalProject(a=[$0], b=[$1])
LogicalFilter(condition=[SEARCH($0, Sarg[(-∞..'1'), ('1'..'2'), ('2'..+∞); NULL AS FALSE]:CHAR(1))])
CalciteLogicalIndexScan(table=[[OpenSearch, testindex]])
""",
"physical": """CalciteEnumerableIndexScan(table=[[OpenSearch, testindex]], PushDownContext=[[PROJECT->[a, b], FILTER->SEARCH($0, Sarg[(-∞..'1'), ('1'..'2'), ('2'..+∞); NULL AS FALSE]:CHAR(1)), LIMIT->10000], OpenSearchRequestBuilder(sourceBuilder={"from":0,"size":10000,"timeout":"1m","query":{"bool":{"must_not":[{"terms":{"a.keyword":["1","2"],"boost":1.0}}],"adjust_pure_negative":true,"boost":1.0}},"_source":{"includes":["a","b"],"excludes":[]}}, requestedTotalSize=10000, pageSize=null, startFrom=0)])
"""
}
}
{
"from": 0,
"size": 10000,
"timeout": "1m",
"query": {
"bool": {
"must_not": [{ "terms": { "a.keyword": ["1", "2"], "boost": 1.0 } }],
"adjust_pure_negative": true,
"boost": 1.0
}
},
"_source": { "includes": ["a", "b"], "excludes": [] }
}
Activity
RyanL1997 commentedon May 4, 2026
@ryan-gh-bot /rca
ryan-gh-bot commentedon May 4, 2026
Working on
/rca(invoked by @RyanL1997) — posting result when done.ryan-gh-bot commentedon May 4, 2026
[RCA]
isnotnull()drop-off is a pushdown regression inPredicateAnalyzer'sSEARCHhandling; fixed onmainWhen Calcite folds
a != '1' AND a != '2' AND isnotnull(a)into a singleSEARCH($0, Sarg[(-∞..'1'), ('1'..'2'), ('2'..+∞); NULL AS FALSE]),PredicateAnalyzeremitsmust_not: [terms a.keyword in ['1','2']]without an accompanyingexistsclause, so documents whereais null/missing slip through. The fix that restores theexistswrapping for complemented-points / range Sargs withNULL AS FALSEalready landed onmainas commitb4df01060(PR opensearch-project#5238) and is not in3.4/3.5.Environment
sql(opensearch-project/sql)upstream/main @ d3bdca82a(contains the fix)upstream/3.5 @ d680c44cc(regression present)upstream/3.4(regression present)upstream/3.3(correct — pre-regression shape)upstream/3.2(correct)upstream/2.19does not containPredicateAnalyzer.javaat all (no Calcite engine on 2.x). The Calcite logical/physical plans in the issue match a 3.x release; this bug reproduces on3.4and3.5.Suspected location
opensearch/src/main/java/org/opensearch/sql/opensearch/request/PredicateAnalyzer.javaL737-L771 — thecase SEARCHarm ofbinary()that switches onSarg.nullAs. Thecase FALSEbranch is the one that was incorrectly collapsed toexpressionduring3.4.opensearch/src/main/java/org/opensearch/sql/opensearch/request/PredicateAnalyzer.javaL785-L807 —constructQueryExpressionForSearch: forisSearchWithComplementedPointsreturnsnotIn(...), which is implemented asboolQuery().mustNot(termsQuery(...))with noexistsfilter of its own.opensearch/src/main/java/org/opensearch/sql/opensearch/request/PredicateAnalyzer.javaL1557-L1563 —notIn(...)producing the baremust_not(terms)builder that becomes the physical DSL observed in the issue.Analysis
The reporter's two queries diverge inside
PredicateAnalyzerbecause Calcite represents them differently:where a != '1' and isnotnull(a)— Calcite does not build a Sarg. TheLogicalFilteris just<>($0, '1'). This goes throughNamedFieldExpression.notEquals, which explicitly buildsboolQuery().must(existsQuery(field)).mustNot(termQuery(...)). That is why the working physical plan containsmust: [exists a].where a != '1' and a != '2' and isnotnull(a)— Calcite merges all three conjuncts into a singleSEARCH($0, Sarg[(-∞..'1'), ('1'..'2'), ('2'..+∞); NULL AS FALSE]). That Sarg satisfies Calcite'sSarg.isComplementedPoints()(range spans(-∞,+∞), not the all-range, and the complement is a set of points:{'1','2'}). TheNULL AS FALSEencodes theisnotnull(a)conjunct directly into the Sarg's null-handling semantics.Inside
binary(call):constructQueryExpressionForSearchroutes complemented-points to.notIn(...), which isboolQuery().mustNot(termsQuery(...))— crucially, noexistsfilter (unlike.notEquals). Onmain, thecase FALSEarm then checksisSearchWithPoints(call)— false for this Sarg (no ranges are points) — and wraps the expression inCompoundQueryExpression.and(expression, exists(field)), producing the expected{must: [{bool: {must_not: [terms]}}, {exists: field}]}. That is exactly what the regression test added alongside the fix asserts.On
3.4/3.5, the same arm is simplycase FALSE -> expression;, so theexistsfilter is never added and nulls pass throughmust_not(terms ...). PR opensearch-project#4877 introduced this simplification with a TODO-style comment speculating it was equivalent; that assumption only holds forisSearchWithPoints(plain IN), not for the complemented-points (NOT IN) or range cases.Evidence
Regression commit (introduced
case FALSE -> expression;):Diff of the regression in
2f64c40ce(PR opensearch-project#4877):Fix in
b4df01060(PR opensearch-project#5238):Regression test added by the fix exactly mirrors the reporter's scenario —
opensearch/src/test/java/org/opensearch/sql/opensearch/request/PredicateAnalyzerTest.java—search_complementedPointsWithNullAsFalse_generatesExistsAndNotInQuery:Per-branch status of the
case FALSEarm:case FALSEbodyupstream/3.2and(expression, exists())upstream/3.3and(expression, exists())upstream/3.4expression;upstream/3.5expression;upstream/main @ d3bdca82aisSearchWithPoints ? expression : and(expression, exists())Live server verification was not performed: the two independent code signals (exact regression commit and exact matching fix commit, both with targeted tests) converge on the same explanation, so escalation to
/reproduceis unnecessary.Confidence: High
The regression commit, the fixing commit, the Calcite
Sargsemantics forisComplementedPoints()/nullAs, and the dedicated regression test in the fix PR all agree on the same root cause and the same code path.Next steps
isnotnull()not being pushed down when combined with multiple!=conditions opensearch-project/sql#5238,b4df01060) is already onmain. If there is no existing backport issue, file one for3.5and3.4— those branches are the ones that actually exhibit this behavior.PredicateAnalyzeron2.19). Worth confirming which plugin build they are actually running so the backport target is correct.... | where a not in ('1', '2') and isnotnull(a)— but given the sameSEARCH+Sarg path is likely taken, this may not avoid the bug. A safer form would be to split the conjunct usingispresent(a)/ a non-collapsible predicate, or to keep the singlea != '1'form that doesn't collapse into a Sarg. These were not run against a live cluster.Automated response from @ryan-gh-bot (maintainer-triggered agent). About this agent.