Resolve an object-vs-scalar mapping conflict to the scalar - #5756
Open
ahkcs wants to merge 1 commit into
Open
Conversation
…h-project#5752) When a wildcard spans indices that disagree on whether a path is an object or a scalar -- as happens when a mapping changes at a rollover boundary -- the pair matches no merge rule: DeepMergeRule needs both sides to share an ExprCoreType and TextKeywordConflictRule only matches the text family. It therefore falls through to LatestRule, so the winner is whichever index is merged last. That order comes from the mapping map, which OpenSearchNodeClient builds with Collectors.toUnmodifiableMap. The JDK randomizes iteration order of its immutable maps per JVM (ImmutableCollections.SALT), so the resolved type differs between nodes and changes when a node restarts. Measured on six freshly started clusters with one keyword index and one object index, the same query resolved the path as an object three times and as a string three times. When it lands on the object side, every value aggregates to null and `timechart ... by <path>` fails (opensearch-project#5750). Add ObjectScalarConflictRule, ahead of LatestRule, resolving such a path to the scalar side. The scalar is the only side holding a value that can be grouped, sorted or charted, and it keeps doc-values pushdown available; resolving to text instead (what the text/keyword rule does for its case) would force _source retrieval and a full scan. Documents from the indices that map the path as an object have no scalar there, so they aggregate into the missing bucket, the same as a field absent from an index. The predicate decides on the mapping type rather than the ExprCoreType, because text, match_only_text, geo_point and binary all resolve to UNKNOWN while still holding one value per document -- keying on the core type would have left the most common conflict, text vs object, on the coin flip. Two consequences worth noting for review: - The object's sub-fields go with it, so `path.sub` over the wildcard now fails with "field not found" where it previously worked whenever the object side happened to win. Retaining the sub-fields was tried first and is worse: the row cannot hold both a scalar and a subtree at one path, so the retained column resolved but always read null. Querying the object-mapped index directly still returns the sub-fields. - Aggregating over the path puts the object side's documents in the missing bucket rather than excluding them, so counts still add up. Signed-off-by: Kai Huang <ahkcs@amazon.com>
ahkcs
requested review from
LantaoJin,
RyanL1997,
Swiddis,
acarbonetto,
anirudha,
dai-chen,
joshuali925,
mengweieric,
noCharger,
penghuo,
ps48,
qianheng-aws,
songkant-aws,
vamsimanohar,
ykmr1224 and
yuancu
as code owners
September 9, 2026 18:27
Contributor
PR Reviewer Guide 🔍Here are some key observations to aid the review process:
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
When a wildcard spans indices that disagree on whether a path is an object or a scalar — as happens when a mapping changes at a rollover boundary — the merged type is decided by whichever index is merged last, and that order is randomized per JVM. The same query therefore resolves the path as
stringon some nodes andstructon others, and flips when a node restarts.Measured on six freshly started single-node clusters with one keyword index and one object index, identical fixture each time:
When it lands on the object side, every value aggregates to
nullandtimechart ... by <path>fails (#5750, #5751). When it lands on the scalar side the query works. Same data, same code, different node.Why it is nondeterministic.
MergeRuleHelpertries its rules in order:DeepMergeRuleExprCoreType(STRUCT or ARRAY)TextKeywordConflictRuleLatestRuletarget.put(key, source), last write winsThe order comes from the mapping map that
OpenSearchNodeClient.getIndexMappingsbuilds withCollectors.toUnmodifiableMap; the JDK randomizes iteration order of its immutable maps per JVM viaImmutableCollections.SALT. Reduced to the merge decision alone over 11 index names of which 4 map the path as an object,objectwon 15 of 20 JVM runs andkeyword5.This change adds
ObjectScalarConflictRuleahead ofLatestRule, resolving such a path to the scalar side. The scalar is the only side holding a value that can be grouped, sorted or charted, and it keeps doc-values pushdown available — resolving to text instead (what the text/keyword rule does for its case) would force_sourceretrieval and a full scan, i.e. the PIT-exhaustion path of #5634/#5646.The predicate decides on the mapping type rather than the
ExprCoreType, becausetext,match_only_text,geo_pointandbinaryall resolve toExprCoreType.UNKNOWNwhile still holding one value per document. Keying on the core type would have left the most common conflict — text vs object — on the coin flip.Behaviour, measured before and after
fields labels.zonestructorstring, per nodestring, alwaysstats count() by labels.zonenull, or real values[1,null] [1,"z1"] [1,"z2"]timechart span=1m count() by labels.zonefields labels.zone.nameField [labels.zone.name] not found. Did you mean: labels.zonesource=<object index> | fields labels.zone.nameTwo consequences worth a reviewer's attention:
path.subover the wildcard now fails deterministically, where previously it worked whenever the object side happened to win. I implemented sub-field retention first and measured it to be worse: the row cannot hold both a scalar and a subtree at one path, so_sourceparsing never descends and the retained column resolved but always read null — silently wrong rather than an error. Querying the object-mapped index directly still returns the sub-fields, which is the workaround for anyone who needs the leaf.Not included, deliberately
PartialResultAggregatePushdown.resolveBucketSignatureemits at:Objecttoken for an object-mapped index, which counts as a second "aggregatable group" and makesplan()bail — so on a wildcard holding text + keyword + object indices, the partial-result feature of #5657 declines to engage for a reason unrelated to its own conflict. That is a separate defect with its own fixture, and it is not needed here: the aggregation pushes down fine without it, and excluding those indices would drop their documents from the count, which the missing bucket does not.Related Issues
Resolves #5752
Related: #5750, #5751 (the crash this conflict caused), #5685 / #5618 (the value-decode side of the same conflict), #5610 (schema-conflict policy), #4659 (
TextKeywordConflictRule, the precedent for resolving a conflict deterministically)Testing
ObjectScalarConflictRuleTest— 9 cases: object/nested/text against a scalar in both directions; no match when neither or both sides are containers; no match on a null side; the scalar wins regardless of merge order; sub-fields are dropped.integ-test/.../ppl/object_scalar_conflict.yml— 6 cases over HTTP: the path resolves tostring; grouping returns the scalar values with the object side in the missing bucket;timechartcan split by the path; the sub-fields fail with "field not found"; the sub-fields still work when the object index is queried directly; a text mapping also wins the path against an object.ObjectScalarConflictRuleTestyamlRestTest -Dtests.rest.suite=ppl/object_scalar_conflict./gradlew test)yamlRestTest issues/5685(object/scalar decode conflict)yamlRestTest ppl/non_scalar_coercion(#5751)*Conflict*IT,*DataType*IT,*Nested*ITCheck List
--signoffor-s.By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license.