Add an opt-in partial-result mode for aggregations on text/keyword mapping conflicts - #5657
Add an opt-in partial-result mode for aggregations on text/keyword mapping conflicts#5657ahkcs wants to merge 31 commits into
Conversation
PR Reviewer Guide 🔍(Review updated until commit 77fa72d)Here are some key observations to aid the review process:
|
PR Code Suggestions ✨Latest suggestions up to 77fa72d Explore these optional code suggestions:
Previous suggestionsSuggestions up to commit 0db5aa7
Suggestions up to commit cafac5f
Suggestions up to commit 11b57b2
Suggestions up to commit cbf5074
Suggestions up to commit ad3c0a5
|
|
Persistent review updated to latest commit dad3bb3 |
|
Persistent review updated to latest commit 078c949 |
|
Persistent review updated to latest commit 83fd527 |
|
Persistent review updated to latest commit 2a3eab8 |
PR Code Analyzer ❗AI-powered 'Code-Diff-Analyzer' found issues on commit a996d24.
The table above displays the top 10 most important findings. Pull Requests Author(s): Please update your Pull Request according to the report above. Repository Maintainer(s): You can Thanks. |
|
Persistent review updated to latest commit 19b6187 |
|
Persistent review updated to latest commit 51220fe |
|
Performance benchmark: partial results vs. today vs. scripted text pushdown (#5646)Comparing three responses to the mapping-conflict PIT-exhaustion case (an aggregation groups on a field mapped
These do not compute the same thing, so every latency figure is paired with a completeness column. Test setup
Datasets (deterministic, seed = 42):
Conflict field for wide/small is a nested Latency p50 / p90 / p99 (ms)"Today" (A) has two modes on the same query, decided by whether the shard count exceeds
The "under limit" column used Completeness (sum of
|
| Dataset | A (complete) | B | C (partial) | C completeness |
|---|---|---|---|---|
| wide, nested field | 219,328 | 220,000 but 1 null bucket (grouping lost) |
200,000 | 91.2% |
| small, nested field | 40,000 | 40,000 but 1 null bucket |
20,000 | 50% |
| flat field | 40,000 | 40,000, 50 buckets (correct) | 20,000 | 50% |
Why the latencies differ (mechanism)
A leaves the aggregate above the scan (explain shows requestedTotalSize=2147483647): every matching document is streamed out of every shard over PIT cursors into the coordinator JVM and counted there — cost scales with document count. B and C fuse the aggregate into the scan (size=0), so the count runs inside each shard and only bucket results cross the wire — cost scales with bucket count, and no PIT is opened. B groups on a per-document _source script; C groups on native keyword doc values, which is why C stays ~2–5× ahead of B even where both push down.
Takeaways
- When it runs, C is fastest (~34× vs A, ~10× vs B on wide) — but that speed is the partial answer: it excludes the non-aggregatable indices. On wide that is an 8.8% undercount; where the text indices hold half the data, 50%. Always accompanied by the
PARTIAL_RESULTwarning. - In the low-PIT-budget regime, A fails outright (100% errors on wide). B and C never open a PIT.
- B is complete and PIT-free on flat fields and is the natural default there. On the nested dotted field, B in its current state grouped all documents into a single
nullbucket (complete count, grouping lost) — worth verifying whether the scripted_sourcereader resolves nested dotted paths. This PR's producer resolves the nested path.
C is intended as an opt-in escape hatch (default off) for the widest patterns / lowest PIT budgets where a knowingly-partial, clearly-warned answer is preferable to a slow scan or a 500 — complementary to, not competing with, a complete-answer pushdown fix.
Single-node, laptop-scale absolutes; the ratios and the PIT / completeness / error-rate columns are the transferable results.
|
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>
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>
…erage 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>
…ck 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>
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>
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>
…ion 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>
… 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>
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>
…VersionUID 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>
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. opensearch-project#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 opensearch-project#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 opensearch-project#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 opensearch-project#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>
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>
Signed-off-by: Kai Huang <ahkcs@amazon.com>
- 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>
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 opensearch-project#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>
Signed-off-by: Kai Huang <ahkcs@amazon.com>
c06668d to
7db73c0
Compare
|
Persistent review updated to latest commit 7db73c0 |
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>
|
Persistent review updated to latest commit ad3c0a5 |
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>
|
Persistent review updated to latest commit cbf5074 |
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>
|
Persistent review updated to latest commit 11b57b2 |
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 (opensearch-project#5610). Signed-off-by: Kai Huang <ahkcs@amazon.com>
|
Persistent review updated to latest commit cafac5f |
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, opensearch-project#5610). Expression-key tracing (#cbf50748) is unaffected and retained. This reverts commit cafac5f. Signed-off-by: Kai Huang <ahkcs@amazon.com>
|
Persistent review updated to latest commit 0db5aa7 |
…nflict 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 (opensearch-project#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>
|
Persistent review updated to latest commit 77fa72d |
Description
On the Calcite PPL path, an aggregation grouped on a field that is mapped
keywordin some indices of a wildcard pattern andtextin others cannot use native pushdown. The multi-index type merge collapses the field totext-without-.keyword, which has no doc values, so the aggregation runs as a per-document_sourcescript over every document — correct, but a full-index scan that is orders of magnitude slower on a wide pattern.This PR adds an opt-in mode that returns a fast, partial answer instead: it aggregates over only the subset of indices where the field is natively aggregatable (
keyword) and attaches a warning naming the ones it excluded.So the choice becomes complete-but-slow (default) vs fast-but-partial (opt-in) — both correct, differing in coverage and speed.
How it works
warnings: [{type, message, detail}]array, emitted only when non-empty (existing responses are byte-for-byte unchanged):PartialResultAggregatePushdown). When the mode is on and the group key is a text/keyword conflict, the scan is narrowed to the aggregatable index subset and the aggregation pushed down over just that subset (size = 0, no PIT). The partitioning logic is unit-tested in isolation.partial_resultboolean in the query body (mirroringprofile) overrides the cluster setting for one query; absent → cluster setting decides.Behavior
Cluster setting
plugins.query.partial_result.on_mapping_conflict.enabled(defaultfalse):stats count() by <conflict field>_sourcescan of all docs)keywordsubset +PARTIAL_RESULTwarningformat=csvKey points
warnings, so CSV/RAW/VIZ fall through to the complete result rather than dropping data unannounced.keywordgroup whenever one exists; otherwise thetext-with-.keywordgroup; always exclude baretext. The result never depends on how many indices of each type match.Not in scope: recovering an excluded but aggregatable group (
text-with-.keywordalongside akeywordgroup) — that needs a per-group split-and-union, a larger separate change. This is why the warning recommends mapping the field askeywordeverywhere.Related Issues
Check List
docs/user/admin/settings.rst).--signoffor-s.By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license.