…pping conflicts (#5657)
* Add non-fatal warning channel to PPL query response
Introduce a first-class warnings array on the query response so the engine
can attach non-fatal, structured notices (type/message/detail) to an
otherwise-successful result, without turning it into an error. This is the
prerequisite for returning a labeled partial result instead of failing a
query that would otherwise exhaust Point-In-Time contexts.
- Add Warning value type and QueryResponse.warnings (core).
- Collect warnings during Calcite planning via a thread-local on
CalcitePlanContext, drained in OpenSearchExecutionEngine.buildResultSet and
cleared with the other lifecycle signals so nothing leaks across queries.
- Thread warnings through QueryResult and emit them in
SimpleJsonResponseFormatter only when non-empty, so existing responses are
byte-for-byte unchanged.
Scoped to the Calcite PPL path.
Signed-off-by: Kai Huang <ahkcs@amazon.com>
* Return a partial result instead of exhausting PIT on a mapping conflict
When an aggregation groups by a field that a text/keyword mapping conflict
collapsed to text-without-keyword across a wildcard pattern, pushdown fails
and the query falls back to a per-shard document scan that opens a
Point-In-Time context on every shard, tripping search.max_open_pit_context.
Add an opt-in fallback: when normal aggregate pushdown fails and
plugins.calcite.partial_result.on_mapping_conflict.enabled is set, narrow the
scan to the largest homogeneous subset of indices whose mapping of the grouped
field is aggregatable, push the aggregation down over just that subset (size=0,
no PIT), and attach a PARTIAL_RESULT warning naming the excluded indices.
- New default-off setting, registered in OpenSearchSettings.
- tryPartialResultAggregate in CalciteLogicalIndexScan partitions the matched
indices, keeps the keyword group (or text-with-keyword if no keyword group),
rebinds the pushdown context to the narrowed index preserving pushed
operations such as a WHERE filter, and re-runs pushdown.
- Wired into AggregateIndexScanRule as the fallback when pushdown returns null.
- drainWarnings de-duplicates, since the planner may raise the warning for
multiple equivalent plan alternatives.
- Integration test verifies: partial off fails with PIT exhaustion; partial on
returns the keyword-index counts with the warning and no PIT; a conflict-free
aggregation attaches no warning.
Scoped to the Calcite PPL path.
Signed-off-by: Kai Huang <ahkcs@amazon.com>
* Gate partial results on a warning-capable response format
A partial result is only safe if the response can carry the warning that says
so. Refuse partial mode (fall through to the normal path) when the requested
format has no warnings channel -- CSV, RAW, and VIZ -- so a knowingly-partial
result is never returned silently.
- QueryContext carries a warnings-supported flag, set in TransportPPLQueryAction
from the request format (true only for the JSON shape), read by the producer.
- tryPartialResultAggregate bails when warnings are unsupported.
- Integration test: partial on + format=csv still errors on PIT exhaustion
rather than returning a silently-partial CSV.
Signed-off-by: Kai Huang <ahkcs@amazon.com>
* Flatten per-index mappings when partitioning for partial results
The partial-result partitioner looked up the grouped field in each index's raw
field mappings by its dotted name. A nested/object field such as
resource.attributes.applicationid is stored as an object tree, not a flat
dotted key, so the lookup returned null, every index classified as
NOT_AGGREGATABLE, and the producer bailed -- leaving the query to exhaust PIT
contexts. This is the exact shape of the real observability field that
motivated the feature.
Flatten each index's field mappings with OpenSearchDataType.traverseAndFlatten
(the same flattening the field-type resolver uses) before the lookup, so the
dotted bucket name resolves. Add an integration test over a nested-field
conflict pattern.
Found by live testing the customer query; the flat-field integration test
missed it.
Signed-off-by: Kai Huang <ahkcs@amazon.com>
* Refine partial-result index selection and warning wording
Two refinements to the partial-result partitioner:
- Pick the kept index group by a deterministic priority instead of a
count-based majority: always keep the keyword group when any keyword index
exists (the canonical aggregatable representation), fall back to the
text-with-.keyword group only when there is no keyword index, and always
exclude bare-text. The returned data no longer depends on how many indices
of each type match, so a stray index can't flip which subset the user sees.
- Correct the warning wording: the old remedy ("add a .keyword sub-field")
was misleading when the excluded index already had one. Reword to say the
aggregation ran over the largest consistently-mapped subset and to suggest
aligning the mapping (e.g. keyword everywhere).
Add an integration test where keyword is outnumbered 2:1 by text-with-.keyword
indices and must still be the kept group.
Signed-off-by: Kai Huang <ahkcs@amazon.com>
* Truncate the excluded-index list in the partial-result warning
A wide observability pattern can exclude hundreds of indices; listing them all
verbatim in the warning detail produces an unreadable multi-kilobyte message.
The exact count is already in the warning's summary, so spell out at most a few
excluded index names in the detail and summarize the rest as "... and N more".
Add an integration test with a large excluded set asserting the detail is
truncated while the summary still reports the full count.
Signed-off-by: Kai Huang <ahkcs@amazon.com>
* Fix partial-result warning wording to reflect the pushdown criterion
The detail said the aggregation ran over the 'largest consistently-mapped
subset', leftover from when the kept group was chosen by index count. Selection
is now by whether the field is aggregatable there (keyword-first), not size, so
reword to 'ran only over the indices where the field is aggregatable'.
Signed-off-by: Kai Huang <ahkcs@amazon.com>
* Extract partial-result partitioning into its own class with unit tests
Harden the partial-result fallback from POC-shaped code into a testable unit:
- Move the classify/partition/priority/warning logic out of the 600-line
CalciteLogicalIndexScan into a dedicated PartialResultAggregatePushdown. The
scan's tryPartialResultAggregate keeps only the plan-time wiring (settings
gate, mapping lookup, narrowed-scan construction, warning emission) and
delegates the decision to PartialResultAggregatePushdown.plan(...).
- Make the PARTIAL_RESULT warning type a shared constant
(Warning.TYPE_PARTIAL_RESULT) instead of a literal, since consumers such as
OpenSearch Dashboards branch on it -- a cross-surface contract.
- Add a field-map constructor to IndexMapping for testability.
- Add unit tests covering classification (keyword / text+keyword / bare-text /
absent), multi-field weakest-resolution, the keyword-first priority ladder
(including when keyword is outnumbered), null/no-op cases, excluded-list
sorting, and warning-list truncation.
No behavior change; the integration tests are unchanged and still pass.
Signed-off-by: Kai Huang <ahkcs@amazon.com>
* Allow a per-request override for partial-result mode
Partial-result mode was gated solely by the cluster setting. Add an optional
per-request partial_result flag (e.g. from an OpenSearch Dashboards toggle) that
takes precedence when present: true forces partial mode on for that query,
false forces it off, and an absent flag defers to the cluster setting.
- Parse partial_result from the PPL request body into a nullable Boolean on
PPLQueryRequest / TransportPPLQueryRequest (mirrors the profile flag; null
means 'unset').
- Carry it into QueryContext as a per-request override, cleared each request so
it cannot leak across pooled worker threads.
- The producer gate now resolves override != null ? override : clusterSetting.
Signed-off-by: Kai Huang <ahkcs@amazon.com>
* Simplify partial-result warning to name the excluded indices and the fix
Trim the warning detail to the essentials for an end user: which field was not
keyword everywhere, which indices were excluded, and the single remedy (map the
field as keyword across all indices). Drops the doc-values / wildcard-merge
mechanics, and removes the earlier suggestion that a text field with a keyword
sub-field is an acceptable mapping -- under a wildcard it still merges to text
and is not aggregatable, so keyword is the only reliable fix to recommend.
Signed-off-by: Kai Huang <ahkcs@amazon.com>
* Do not resolve response format for explain requests
The warnings-supported check called format() on every request, including
explain requests whose format is an explain-only value (json/yaml) that
Format.of() does not recognize -- so an _explain request failed with
'response in json format is not supported' before reaching the explain branch.
Skip the check for explain requests, which never carry query warnings anyway.
Fixes the doctest failures on docs/user/ppl/interfaces/endpoint.md.
Signed-off-by: Kai Huang <ahkcs@amazon.com>
* Cover the null-warnings branch in QueryResult to satisfy protocol coverage
The protocol module requires 100% branch coverage. QueryResult's warnings
constructor normalizes null to an empty list, but no test exercised the null
branch, dropping protocol branch coverage to 0.9 and failing
jacocoTestCoverageVerification. Add a QueryResultTest case covering the
no-warnings, provided-list, and null-list paths.
Signed-off-by: Kai Huang <ahkcs@amazon.com>
* Address review: rename the partial-result setting and fold the fallback into pushDownAggregate
The setting is user-facing behavior, not a Calcite internal, so move it from
plugins.calcite.* to plugins.query.partial_result.on_mapping_conflict.enabled
and drop the CALCITE_ prefix from the key.
Fold tryPartialResultAggregate into pushDownAggregate so the planner rule keeps
a single entry point. The fallback is now private and gated by an
allowPartialFallback flag, so re-entering on the narrowed scan attempts it at
most once.
Signed-off-by: Kai Huang <ahkcs@amazon.com>
* Reuse the already-fetched index mappings for partial-result partitioning
The partial-result path needs per-index mappings to decide which indices are
aggregatable, but the merged field types cached on OpenSearchIndex discard that
detail, so it was re-requesting the mappings from the client.
Retain the per-index mappings on the describe request that already fetches them
and cache them alongside the merged types, so partitioning reuses that result
instead of issuing a second mapping request. Also collapses three copies of the
fetch-and-cache block into one helper.
Signed-off-by: Kai Huang <ahkcs@amazon.com>
* Fix formatting of the renamed partial-result setting key
The shorter plugins.query.* key fits on one line, so the wrapped form no longer
matches google-java-format.
Signed-off-by: Kai Huang <ahkcs@amazon.com>
* Revert the index-mapping reuse optimization: it exposed a merge-mutation bug
The optimization had partial-result partitioning reuse the per-index mappings
cached on OpenSearchIndex. But getFieldTypes() merges those mappings with
MergeRuleHelper, and DeepMergeRule.mergeInto mutates the target's nested
'properties' map in place -- and that target aliases the first-iterated index's
OpenSearchDataType objects. Reusing the cached mappings therefore handed the
partitioner a mapping whose nested field had been merged into the sibling
index's type, so a text/keyword conflict on a nested field intermittently
classified as no-conflict, returned no partitioning plan, and fell through to
the PIT-exhausting scan. The outcome depended on map iteration order, hence the
flaky CalcitePartialResultOnMappingConflictIT.partialResultOnHandlesNestedDottedField.
Restore the direct getIndexMappings() fetch, which returns freshly-parsed
mappings immune to that mutation. This only runs on the opt-in partial path
after normal pushdown has already failed (a cold path), so the extra fetch is
acceptable. The underlying in-place-merge mutation is a separate latent issue.
Stress-verified: reverted code passes the full IT class 8/8; the optimized code
failed 4/5.
Signed-off-by: Kai Huang <ahkcs@amazon.com>
* Reuse the already-fetched index mappings, and stop the merge mutating them
Partial-result partitioning needs per-index mappings, which the merged field
types cached on OpenSearchIndex discard, so it was fetching them a second time.
Retain the per-index mappings on the describe request that already fetches them
and cache them alongside the merged types, so partitioning reuses that result.
The first attempt at this was reverted because MergeRuleHelper rewrites the
accumulated type's nested properties in place, mutating the very mappings being
retained: a nested text/keyword conflict then read back as no conflict, produced
no partitioning plan, and fell through to the PIT-exhausting scan. Merge deep
copies instead, via a new OpenSearchDataType.cloneDeep() that carries the nested
properties subtree (cloneEmpty drops it).
Covered by a regression test that fails without the copy. Stress-verified:
CalcitePartialResultOnMappingConflictIT passes 8/8 (it failed 4/5 before).
Signed-off-by: Kai Huang <ahkcs@amazon.com>
* Clear the per-request partial-result state after each query
The partial-result override and the warnings-supported flag live in
QueryContext's log4j thread-locals, but only QueryProfiling was being cleared
when a request finished. Transport threads are pooled, so a query that expressed
no preference inherited the previous query's override from the same thread: with
the cluster setting off and no request flag, an aggregation over a text/keyword
conflict intermittently returned a partial result (with a warning) instead of
failing -- observed 7 of 12 runs after an earlier request had set the flag.
Clear both flags alongside QueryProfiling in the response listener. Verified:
flag-absent requests now fail 12/12 when interleaved with explicit true
requests, while explicit true still returns the partial result.
Signed-off-by: Kai Huang <ahkcs@amazon.com>
* Update explain golden files for the changed OpenSearchDataType serialVersionUID
OpenSearchDataType is Serializable without an explicit serialVersionUID, so the
JVM derives one from the class shape. Adding cloneDeep() changed it, and that
UID is embedded in the Java-serialized script blobs these two explain plans
assert on.
Both files now carry the same derived UID (7128bdc1452f35d3). The ppl/ one is
confirmed by ExplainIT passing; the calcite/ one is skipped in this environment
(enabledOnlyWhenPushdownIsEnabled) and verified by decoding both blobs and
comparing the UID bytes.
Signed-off-by: Kai Huang <ahkcs@amazon.com>
* Decide partial-result mode before pushdown analysis, not after it fails
The partial-result path hooked the failure branch of pushDownAggregate: a group
key that collapsed to text-without-keyword used to throw (getReferenceForTermQuery
returned null and the composite builder rejected it), and the fallback caught
that. #5646 made that case succeed instead -- it pushes down as a per-document
_source script -- so the fallback lost its trigger and the setting became a no-op.
Verified by cherry-picking #5646 onto this branch: 7 of 10 ITs failed, the
partial-result ones because pushdown now succeeds and no warning is emitted.
Consult the partial-result plan before AggregateAnalyzer.analyze instead. The
choice is no longer failure-vs-fallback but between two working plans: a native
aggregation over the keyword subset (fast, incomplete, warned) and #5646's script
over every document (slow, complete). Only an up-front check can pick the fast
one. The post-failure call is kept so a key that genuinely cannot push down (e.g.
an array bucket) still gets the chance.
Two ITs asserted the old failure mode (PIT exhaustion raising a 4xx). That
failure no longer happens, which is the point of #5646, so they now assert the
behavior that matters: partial-result off returns the complete result with no
warning, and CSV -- which has no warnings channel -- still returns every index
rather than silently dropping one.
Signed-off-by: Kai Huang <ahkcs@amazon.com>
* Document the partial-result-on-mapping-conflict setting
Add a settings.rst entry for plugins.query.partial_result.on_mapping_conflict.enabled:
what a text/keyword mapping conflict is, the complete-but-slow default vs the
fast-but-partial opt-in, the PARTIAL_RESULT warning, the JSON-only constraint, and
the per-request partial_result override.
Signed-off-by: Kai Huang <ahkcs@amazon.com>
* Tighten inline comments on the partial-result path
Signed-off-by: Kai Huang <ahkcs@amazon.com>
* Address review: mark setting experimental, consolidate enable check
- settings.rst: mark the setting [Experimental] with a note, and correct the
version to 3.9.
- Consolidate the per-request-override + cluster-setting precedence into
QueryContext.isPartialResultEnabled(Settings); drop the duplicate resolver in
CalciteLogicalIndexScan and the getPartialResultOverride accessor.
- Remove a redundant inline comment.
Signed-off-by: Kai Huang <ahkcs@amazon.com>
* Drop the redundant post-failure partial-result fallbacks
The partial-result check runs before analyze (line ~418); the two post-failure
call sites could never add a case. The catch-path call re-invoked with identical
inputs the pre-analyze check already tried, so it always returned null. The
array/nested branch is issue #5006's scope, not a text/keyword conflict, so
partial mode does not apply. Both revert to returning null, and the now-unused
two-arg overload is removed.
Signed-off-by: Kai Huang <ahkcs@amazon.com>
* Tighten inline comments
Signed-off-by: Kai Huang <ahkcs@amazon.com>
* Bail on a non-text type conflict instead of excluding the index
A group field mapped keyword in some indices and a non-text type (e.g. int) in
others is a type conflict, not a text/keyword collapse. The int index is
aggregatable, so excluding it would silently drop valid data and mislabel it a
text/keyword conflict. Classify such a field as CONFLICTING_TYPE and return no
plan, leaving the query to the normal path (the type conflict itself is out of
scope here). Bare text and absent fields are still excludable as before.
Signed-off-by: Kai Huang <ahkcs@amazon.com>
* Partition partial results on the group key's source fields
Resolve each aggregation group key through the eval Project to the scan
fields it reads, so an expression key (e.g. eval g = lower(city) | stats
count() by g) gets partial results over the keyword subset just like a
bare 'by city'. Previously only a bare group field matched the per-index
mapping; a derived key looked up its output alias, found nothing, and
bailed to the complete (script) path. A constant group key resolves to no
field and cleanly bails.
Signed-off-by: Kai Huang <ahkcs@amazon.com>
* Add IT for a multi-field expression group key
Covers concat(city, region) over a text/keyword conflict: the key traces to
both fields, keeps only the index where both are aggregatable, and warns
naming both fields and the excluded index. Closes the end-to-end gap on
multi-field expression keys.
Signed-off-by: Kai Huang <ahkcs@amazon.com>
* Handle a non-text type vs text conflict in partial results
Generalize partitioning from a text/keyword-only enum to a per-index
compatibility signature. This also covers a single aggregatable non-text
type mixed with bare text (e.g. integer vs text): keep the aggregatable
index, exclude the text one, and warn -- rather than silently coercing to
one type and dropping the other index's docs.
A conflict between mutually-incompatible aggregatable types (keyword vs
integer, two numeric types) is left to the normal path: its merged type is
an arbitrary last-write-wins, so narrowing to any one subset could misread
the other's values under that type. That is a fundamental type conflict
tracked separately (#5610).
Signed-off-by: Kai Huang <ahkcs@amazon.com>
* Revert non-text type generalization; scope to text/keyword collapse
Live testing showed the non-text generalization is unsafe. The narrowed scan
reuses the conflict's merged output type, which for a non-text conflict is an
arbitrary last-write-wins. When int-vs-text merged to text, keeping the int
index produced a native numeric aggregation whose integer bucket keys did not
materialize under the text output column -- the group labels came back null
([[2, null], [1, null]]). And when the merge instead picks text, the normal
path already returns the complete result, so narrowing only loses data.
Only the text/keyword collapse narrows safely: its merged type is a
deterministic text, and a kept keyword / text-with-.keyword group's string
bucket keys match it. Reverting to that scope. keyword-vs-int and other
mutually-incompatible aggregatable-type conflicts remain on the normal path
(a fundamental type conflict, #5610). Expression-key tracing (#cbf50748) is
unaffected and retained.
This reverts commit cafac5f.
Signed-off-by: Kai Huang <ahkcs@amazon.com>
* Generalize partial results to any non-aggregatable-vs-aggregatable conflict
Partition by aggregatability, not just text/keyword: an index whose group
field is non-aggregatable is dropped and the aggregatable indices are kept.
Non-aggregatable = the text family (text, text-with-.keyword, match_only_text
-- all collapse to bare text on merge) plus absent fields. Aggregatable =
keyword, numerics, date, boolean, ip. So e.g. integer-vs-text now keeps the
integer index and excludes the text one, warning about the exclusion, instead
of silently coercing to one type and dropping the other index's docs.
Kept indices must share one aggregatable type; a mix of incompatible
aggregatable types (keyword vs integer, two numeric types) has an arbitrary
last-write-wins merged type and is left to the normal path (#5610).
Also coerce a numeric/boolean aggregation bucket key to its string form when
it lands in a text-typed output column (OpenSearchExprValueFactory), rather
than failing the cast and nulling the label -- which happens when the kept
non-keyword index's native buckets flow through the conflict's text-merged
output type.
Signed-off-by: Kai Huang <ahkcs@amazon.com>
* Address review: drop the special keyword token; sort only when building the warning
- resolveBucketSignature: keyword uses the same t:TYPE token as other
aggregatable types (no separate 'kw').
- plan(): stop sorting excludedIndices; sort a copy inside buildWarning,
since ordering only matters for a readable message.
Signed-off-by: Kai Huang <ahkcs@amazon.com>
---------
Signed-off-by: Kai Huang <ahkcs@amazon.com>
Description
When a text field has no
.keywordsub-field, PPL queries that group by or aggregate over that field silently fall back to a full_sourcescan with client-side aggregation, even when Calcite pushdown is enabled.Fix. When
getReferenceForTermQuery()returnsnullfor a bareRexInputRef, route throughScriptQueryExpression(rexNode, ...)to build a Calcite script that reads the field from_source. This reuses the exactSOURCEpath already established byTermQuery/LikeQuery/RexStandardizer.visitInputRefand consumed at runtime byCalciteScriptEngine.ScriptDataContext.getFromSource→SourceLookup.get(...). The change is inAggregateAnalyzer.AggregateBuilderHelper.build— one branch added.Related Issues
Resolves #5634
Check List
AggregateAnalyzerTest:analyze_aggCall_TextWithoutKeyword_countPushesDownAsScript,analyze_groupBy_TextWithoutKeyword(asserts scriptedvalue_count/termsDSL andSOURCES/DIGESTSscript params).text_agg_pushdown.yml(10 cases) coveringtop,stats … by,count(field)— including result correctness and pushed-down DSL assertions — plus baselines for text+keyword and multi-index queries.--signoffor-s.By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license.
For more information on following Developer Certificate of Origin and signing off your commits, please check here.