Query Information
PPL Command/Query:
source=nested_repro | where events.name = 'db_query' and events.status = 'ok' | stats count()
Expected Result:
2 — only doc1 and doc4 have a single events child satisfying both predicates, i.e. ∃e(A ∧ B).
Actual Result:
3 — doc2 also matches, because the two predicates are satisfied by different children ({db_query, error} and {http_call, ok}). The engine evaluates (∃e A) ∧ (∃e B).
With plugins.calcite.pushdown.enabled=false the same query returns 1, missing doc4. So both modes are wrong, in opposite directions:
| Path |
... fields id |
vs expected |
Raw DSL, correlated nested query |
doc1, doc4 |
✅ correct |
| PPL, pushdown on (default) |
doc1, doc2, doc4 |
false positive |
| PPL, pushdown off |
doc1 |
false negative |
Dataset Information
Dataset/Schema Type
Index Mapping
{
"mappings": {
"properties": {
"id": {"type": "keyword"},
"events": {
"type": "nested",
"properties": {
"name": {"type": "keyword"},
"status": {"type": "keyword"}
}
}
}
}
}
Sample Data
{"id":"doc1","events":[{"name":"db_query","status":"ok"}]}
{"id":"doc2","events":[{"name":"db_query","status":"error"},{"name":"http_call","status":"ok"}]}
{"id":"doc3","events":[{"name":"http_call","status":"error"}]}
{"id":"doc4","events":[{"name":"http_call","status":"error"},{"name":"db_query","status":"ok"}]}
doc2 is the decoy: both predicates are satisfied, but by different children. doc4 is the mirror: the matching child is not the first one.
Bug Description
Issue Summary:
A where clause referencing two subfields of the same nested path is pushed down as two sibling nested clauses under one bool.must. Each nested clause is scored independently, so the predicates need not be satisfied by the same child document. Nested identity is lost in the logical plan — events.name and events.status are modeled as ordinary flat scalar columns with no shared correlation scope.
_explain (pushdown on):
{"bool":{"must":[
{"nested":{"query":{"term":{"events.name":{"value":"db_query"}}},"path":"events","score_mode":"none"}},
{"nested":{"query":{"term":{"events.status":{"value":"ok"}}},"path":"events","score_mode":"none"}}
]}}
The correct DSL is a single nested clause wrapping the whole conjunction:
{"nested":{"path":"events","query":{"bool":{"must":[
{"term":{"events.name":"db_query"}},
{"term":{"events.status":"ok"}}
]}}}}
Steps to Reproduce:
- Create the index and load the 4 documents above.
PUT _cluster/settings {"transient":{"plugins.calcite.enabled":true}}
- Run the query — get
3, expected 2.
- Confirm ground truth with the correlated
nested DSL above — returns doc1, doc4.
Root cause:
Each leaf predicate wraps itself in a nested query, at opensearch/src/main/java/org/opensearch/sql/opensearch/request/PredicateAnalyzer.java:1329:
if (rel != null && !Strings.isNullOrEmpty(rel.nestedPath)) {
return nestedQuery(rel.nestedPath, builder, ScoreMode.None);
}
CompoundQueryExpression.and() (same file, ~line 1255) then must()s the already-wrapped children with no grouping by nestedPath. Introduced by #4825 (3.5.0), which added nested filter pushdown on top of the flattened-column model from #3476.
The pushdown-off path is wrong for a separate reason — it reads only the first array element (ExprValueUtils.resolveRefPaths, core/src/main/java/org/opensearch/sql/data/model/ExprValueUtils.java:230), so doc1 passing is accidental. Filed separately.
Note the existing expected-output tests pin the incorrect shape:
integ-test/src/test/resources/expectedOutput/calcite/filter_root_and_nested.yaml and filter_multiple_nested_cascaded_range.yaml. They will need updating.
Impact:
Silent wrong results for any where combining two subfields of the same nested path — a routine pattern in observability data (events, spans, attributes). No error is raised and no workaround exists: pushdown on over-counts, pushdown off under-counts. The legacy SQL engine handles this correctly via PartiQL-style scoping (nested(message, message.info = 'a' AND message.author = 'e'), see integ-test/src/test/java/org/opensearch/sql/sql/NestedIT.java:381); PPL Calcite has no equivalent construct.
Environment Information
OpenSearch Version: 3.9.0-SNAPSHOT (./gradlew run on main @ 96399c590)
Additional Details:
Query Information
PPL Command/Query:
Expected Result:
2— onlydoc1anddoc4have a singleeventschild satisfying both predicates, i.e.∃e(A ∧ B).Actual Result:
3—doc2also matches, because the two predicates are satisfied by different children ({db_query, error}and{http_call, ok}). The engine evaluates(∃e A) ∧ (∃e B).With
plugins.calcite.pushdown.enabled=falsethe same query returns1, missingdoc4. So both modes are wrong, in opposite directions:... fields idnestedquerydoc1, doc4doc1, doc2, doc4doc1Dataset Information
Dataset/Schema Type
Index Mapping
{ "mappings": { "properties": { "id": {"type": "keyword"}, "events": { "type": "nested", "properties": { "name": {"type": "keyword"}, "status": {"type": "keyword"} } } } } }Sample Data
{"id":"doc1","events":[{"name":"db_query","status":"ok"}]} {"id":"doc2","events":[{"name":"db_query","status":"error"},{"name":"http_call","status":"ok"}]} {"id":"doc3","events":[{"name":"http_call","status":"error"}]} {"id":"doc4","events":[{"name":"http_call","status":"error"},{"name":"db_query","status":"ok"}]}doc2is the decoy: both predicates are satisfied, but by different children.doc4is the mirror: the matching child is not the first one.Bug Description
Issue Summary:
A
whereclause referencing two subfields of the samenestedpath is pushed down as two siblingnestedclauses under onebool.must. Eachnestedclause is scored independently, so the predicates need not be satisfied by the same child document. Nested identity is lost in the logical plan —events.nameandevents.statusare modeled as ordinary flat scalar columns with no shared correlation scope._explain(pushdown on):{"bool":{"must":[ {"nested":{"query":{"term":{"events.name":{"value":"db_query"}}},"path":"events","score_mode":"none"}}, {"nested":{"query":{"term":{"events.status":{"value":"ok"}}},"path":"events","score_mode":"none"}} ]}}The correct DSL is a single
nestedclause wrapping the whole conjunction:{"nested":{"path":"events","query":{"bool":{"must":[ {"term":{"events.name":"db_query"}}, {"term":{"events.status":"ok"}} ]}}}}Steps to Reproduce:
PUT _cluster/settings {"transient":{"plugins.calcite.enabled":true}}3, expected2.nestedDSL above — returnsdoc1, doc4.Root cause:
Each leaf predicate wraps itself in a
nestedquery, atopensearch/src/main/java/org/opensearch/sql/opensearch/request/PredicateAnalyzer.java:1329:CompoundQueryExpression.and()(same file, ~line 1255) thenmust()s the already-wrapped children with no grouping bynestedPath. Introduced by #4825 (3.5.0), which added nested filter pushdown on top of the flattened-column model from #3476.The pushdown-off path is wrong for a separate reason — it reads only the first array element (
ExprValueUtils.resolveRefPaths,core/src/main/java/org/opensearch/sql/data/model/ExprValueUtils.java:230), sodoc1passing is accidental. Filed separately.Note the existing expected-output tests pin the incorrect shape:
integ-test/src/test/resources/expectedOutput/calcite/filter_root_and_nested.yamlandfilter_multiple_nested_cascaded_range.yaml. They will need updating.Impact:
Silent wrong results for any
wherecombining two subfields of the same nested path — a routine pattern in observability data (events,spans,attributes). No error is raised and no workaround exists: pushdown on over-counts, pushdown off under-counts. The legacy SQL engine handles this correctly via PartiQL-style scoping (nested(message, message.info = 'a' AND message.author = 'e'), seeinteg-test/src/test/java/org/opensearch/sql/sql/NestedIT.java:381); PPL Calcite has no equivalent construct.Environment Information
OpenSearch Version: 3.9.0-SNAPSHOT (
./gradlew runonmain@96399c590)Additional Details:
plugins.calcite.enabled=true