Return a partial result with a warning instead of exhausting PIT contexts on a text/keyword mapping conflict - #5657
Conversation
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>
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>
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>
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>
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>
PR Reviewer Guide 🔍(Review updated until commit cdfab96)Here are some key observations to aid the review process:
|
PR Code Suggestions ✨Latest suggestions up to cdfab96 Explore these optional code suggestions:
Previous suggestionsSuggestions up to commit 8a040a2
Suggestions up to commit 44e0eaa
Suggestions up to commit 755031e
Suggestions up to commit 42b4977
Suggestions up to commit 3d21944
|
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>
|
Persistent review updated to latest commit dad3bb3 |
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>
|
Persistent review updated to latest commit 078c949 |
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>
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>
…-result-warning-channel # Conflicts: # core/src/main/java/org/opensearch/sql/calcite/CalcitePlanContext.java
|
Persistent review updated to latest commit 83fd527 |
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>
|
Persistent review updated to latest commit 2a3eab8 |
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>
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 |
…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>
|
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.
|
| "plugins.calcite.pushdown.rowcount.estimation.factor"), | ||
| CALCITE_SUPPORT_ALL_JOIN_TYPES("plugins.calcite.all_join_types.allowed"), | ||
| CALCITE_PARTIAL_RESULT_ON_MAPPING_CONFLICT( | ||
| "plugins.calcite.partial_result.on_mapping_conflict.enabled"), |
There was a problem hiding this comment.
calcite is internal impl. plugins.query.partial_result.on_mapping_conflict.enabled is better.
There was a problem hiding this comment.
We should have multiple settings?
- Does mapping conflict allowed?
- Yes (default, PPL current behavior). I suspect text/keyword conflict not the only case.
- No: Fast Fail.
- Does aggregation on text field allowed?
- Yes: Use script text aggregation.
- No: There are multiple choice. Ignore index has text field or Fast Fail or use null value instead of fetch from text field.
There was a problem hiding this comment.
Thanks for the suggestion, updated the naming
Current behavior isn't uniform
MergeRuleHelper tries three rules, first match wins:
| Conflict | Rule | Today |
|---|---|---|
| STRUCT/ARRAY, same core type | DeepMergeRule |
real merge, correct |
| text ↔ keyword | TextKeywordConflictRule |
collapses to text-without-.keyword → no doc values → no pushdown → PIT per shard → 500 past the limit |
| everything else (long↔integer, ip↔keyword, date↔long…) | LatestRule |
target.put(key, source) while iterating a toUnmodifiableMap — the last mapping yielded wins, and that order isn't guaranteed |
So you're right that text/keyword isn't the only case. For the rest, the merged type is arbitrary per index set — stable for a given set, but two identically-shaped sets can disagree. Verified on 12 freshly-created, identical long+integer conflicts:
describe → 10 sets resolved to `int`, 2 to `bigint`
docs 5000000000 + 7:
int sets → [7, 705032704] ← 64-bit value truncated, 200 OK, no warning
bigint sets → [7, 5000000000] ← correct
long+integer → int is narrowing, so this isn't least-restrictive widening. Filtering is honored correctly, so it's a type-resolution defect, not a predicate bug. Its own issue, independent of this PR.
Settings
allowneeds defining. "Current behavior" means three different things above. It should mean merge and warn, not "merge and sometimes silently truncate" — making this PR's warning channel the substrate for the whole matrix. Fast-fail is genuinely useful: for aLatestRuleconflict, failing loudly beats silent truncation.
Proposal: make it discoverable, not a flag users must know
- Backend
analyze— detect the failed-pushdown signature (aggregate above the scan,requestedTotalSize=2147483647) and emit a recommendation naming the field, the conflict, and the remedy.AnalyzeResponsealready has arecommendationsfield, so this is additive. - Frontend
inspect query— render it with the honest trade-off (faster, no PIT, partial).
Scope
This PR: warning channel + PARTIAL_RESULT producer + single opt-in boolean (default off, inert)
Follow-ups: (1) analyze recommendation + inspect query; (2) LatestRule arbitrary-type/truncation bug;
| // Normal pushdown failed (e.g. a text/keyword mapping conflict across a wildcard pattern that | ||
| // would otherwise fall back to a per-shard scan and exhaust PIT contexts). If partial results | ||
| // are enabled, push the aggregation over the aggregatable index subset instead and warn. | ||
| newRelNode = scan.tryPartialResultAggregate(aggregate, project); |
There was a problem hiding this comment.
why add a new function instead of reuse pushDownAggregate?
There was a problem hiding this comment.
Updated, rule is back to one call
| Map<String, IndexMapping> mappings = | ||
| osIndex.getClient().getIndexMappings(osIndex.getIndexName().getIndexNames()); |
There was a problem hiding this comment.
avoid call indexMapping again.
There was a problem hiding this comment.
Updated — now osIndex.getIndexMappings(), reusing the fetch getFieldTypes() already makes. No second call.
…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>
|
Persistent review updated to latest commit a996d24 |
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>
|
Persistent review updated to latest commit 3d21944 |
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>
|
Persistent review updated to latest commit 42b4977 |
…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>
|
Persistent review updated to latest commit 755031e |
… 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>
|
Persistent review updated to latest commit 44e0eaa |
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>
|
Persistent review updated to latest commit 8a040a2 |
…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>
|
Persistent review updated to latest commit cdfab96 |
Description
On the Calcite PPL path, an aggregation (
stats/top/rare) that groups, sorts, or dedups on a field whose mapping conflicts across a wildcard index pattern —keywordin some indices,textin others — cannot push down. The type merge must pick one type valid on every shard, so it collapses the field totext-without-.keyword; text has no doc values, so the aggregation falls back to an in-process document scan that opens a Point-In-Time (PIT) context on every shard of every matched index. On a wide pattern this tripssearch.max_open_pit_contextand the customer sees an opaque failure.This PR adds an opt-in fallback that returns a partial result plus a structured warning instead of failing. When normal aggregate pushdown fails on such a conflict and partial mode is enabled, the engine narrows the scan to the subset of indices where the field is aggregatable (mapped as
keyword), pushes the aggregation down over just that subset (sosize = 0and no PIT is opened), and attaches aPARTIAL_RESULTwarning naming the excluded indices.Three parts:
warningsarray ({type, message, detail}), emitted only when non-empty so existing responses are byte-for-byte unchanged:{ "schema": [ ... ], "datarows": [ ... ], "total": 3, "size": 3, "warnings": [ { "type": "PARTIAL_RESULT", "message": "Results exclude 1 of 2 indices due to a text/keyword mapping conflict on [applicationid].", "detail": "[applicationid] is not mapped as keyword in every queried index, so these indices were excluded from the aggregation: [logs-text]. Map [applicationid] as keyword across all indices to include them." } ] }detailtruncates the excluded-index list (a few names + "and N more") so a wide pattern stays readable.PartialResultAggregatePushdown), wired intoAggregateIndexScanRuleas the fallback when normal aggregate pushdown returnsnull. Partitioning/selection logic is unit-tested in isolation.partial_resultrequest-body flag (mirroringprofile) lets a client — e.g. an OpenSearch Dashboards toggle — turn partial mode on/off per query. When present it overrides the cluster setting (trueforces on,falseforces off); when absent the cluster setting decides.Behavior (partial mode off vs on), on a
keyword+textwildcard pattern withsearch.max_open_pit_contextbelow the shard count:stats count() by <conflict field>PARTIAL_RESULTwarning, 0 PITstop/rare <conflict field>stats count() by span(...)(no conflict)format=csvKey design points
plugins.calcite.partial_result.on_mapping_conflict.enabled(defaultfalse) and/or the per-requestpartial_resultflag. A partial result is a knowingly-incomplete answer, so it never happens silently, and with nothing enabled the change is byte-for-byte behavior-preserving.warningsarray, so partial mode is skipped for CSV/RAW/VIZ — those fall through to the normal (failing) path rather than returning a silently-partial answer.keywordgroup when any keyword index exists (the canonical aggregatable representation); fall back to thetext-with-.keywordgroup only when there is no keyword index; always exclude bare-text. The returned data does not depend on how many indices of each type happen to match.Not in scope: recovering an excluded but aggregatable group (e.g.
text-with-.keywordwhen akeywordgroup is also present) — that needs a per-group split-and-union, a larger separate change. Partial mode deliberately trades that completeness for a single narrowed scan and a clear warning. (This is also why the warning recommends mapping the field askeywordeverywhere: under a wildcard, a.keywordsub-field still merges to plain text and is not aggregatable.)Related Issues
Check List
--signoffor-s.By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license.