Skip to content

Commit 44e0eaa

Browse files
committed
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>
1 parent 755031e commit 44e0eaa

5 files changed

Lines changed: 115 additions & 17 deletions

File tree

opensearch/src/main/java/org/opensearch/sql/opensearch/data/type/OpenSearchDataType.java

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -264,6 +264,23 @@ protected OpenSearchDataType cloneEmpty() {
264264
: new OpenSearchDataType(this.mappingType);
265265
}
266266

267+
/**
268+
* Clone this type including its nested {@link #properties} subtree, so the copy shares no mutable
269+
* state with the original. Needed by callers that must keep a mapping intact across an in-place
270+
* merge (see {@code MergeRuleHelper}), which rewrites the target's {@code properties}.
271+
*
272+
* @return A deep copy of this type.
273+
*/
274+
public OpenSearchDataType cloneDeep() {
275+
OpenSearchDataType copy = cloneEmpty();
276+
if (!properties.isEmpty()) {
277+
Map<String, OpenSearchDataType> copiedProperties = new LinkedHashMap<>();
278+
properties.forEach((field, type) -> copiedProperties.put(field, type.cloneDeep()));
279+
copy.properties = copiedProperties;
280+
}
281+
return copy;
282+
}
283+
267284
/**
268285
* Flattens mapping tree into a single layer list of objects (pairs of name-types actually), which
269286
* don't have nested types. See {@link OpenSearchDataTypeTest#traverseAndFlatten() test} for

opensearch/src/main/java/org/opensearch/sql/opensearch/request/system/OpenSearchDescribeIndexRequest.java

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
import java.util.List;
1616
import java.util.Locale;
1717
import java.util.Map;
18+
import lombok.Getter;
1819
import lombok.extern.log4j.Log4j2;
1920
import org.opensearch.sql.data.model.ExprTupleValue;
2021
import org.opensearch.sql.data.model.ExprValue;
@@ -92,6 +93,14 @@ public List<ExprValue> search() {
9293
return results;
9394
}
9495

96+
/**
97+
* The per-index mappings behind the last {@link #getFieldTypes()} call, keyed by concrete index
98+
* name. Retained because merging discards which index mapped a field which way, and callers that
99+
* need that detail (e.g. partitioning a wildcard by whether a field is aggregatable) would
100+
* otherwise have to fetch the mappings a second time.
101+
*/
102+
@Getter private Map<String, IndexMapping> lastIndexMappings = Map.of();
103+
95104
/**
96105
* Get the mapping of field and type.
97106
*
@@ -102,18 +111,30 @@ public Map<String, OpenSearchDataType> getFieldTypes() {
102111
Map<String, OpenSearchDataType> fieldTypes = new HashMap<>();
103112
Map<String, IndexMapping> indexMappings =
104113
client.getIndexMappings(getLocalIndexNames(indexName.getIndexNames()));
114+
this.lastIndexMappings = indexMappings;
105115
if (indexMappings.size() <= 1) {
106116
for (IndexMapping indexMapping : indexMappings.values()) {
107117
fieldTypes.putAll(indexMapping.getFieldMappings());
108118
}
109119
} else {
120+
// Merge deep copies: MergeRuleHelper rewrites the accumulated type's nested `properties` in
121+
// place, which would otherwise mutate the per-index mappings retained above (they are reused
122+
// by partial-result partitioning, which needs to see each index's original mapping).
110123
for (IndexMapping indexMapping : indexMappings.values()) {
111-
MergeRuleHelper.merge(fieldTypes, indexMapping.getFieldMappings());
124+
MergeRuleHelper.merge(fieldTypes, deepCopy(indexMapping.getFieldMappings()));
112125
}
113126
}
114127
return fieldTypes;
115128
}
116129

130+
/** Copy a field-mapping map so an in-place merge cannot mutate the source types. */
131+
private static Map<String, OpenSearchDataType> deepCopy(
132+
Map<String, OpenSearchDataType> mappings) {
133+
Map<String, OpenSearchDataType> copy = new LinkedHashMap<>();
134+
mappings.forEach((field, type) -> copy.put(field, type.cloneDeep()));
135+
return copy;
136+
}
137+
117138
/**
118139
* Get the minimum of the max result windows of the indices.
119140
*

opensearch/src/main/java/org/opensearch/sql/opensearch/storage/OpenSearchIndex.java

Lines changed: 31 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
import org.opensearch.sql.opensearch.client.OpenSearchClient;
3030
import org.opensearch.sql.opensearch.data.type.OpenSearchDataType;
3131
import org.opensearch.sql.opensearch.data.value.OpenSearchExprValueFactory;
32+
import org.opensearch.sql.opensearch.mapping.IndexMapping;
3233
import org.opensearch.sql.opensearch.monitor.OpenSearchMemoryHealthy;
3334
import org.opensearch.sql.opensearch.monitor.OpenSearchResourceMonitor;
3435
import org.opensearch.sql.opensearch.planner.physical.ADOperator;
@@ -91,6 +92,13 @@ public class OpenSearchIndex extends AbstractOpenSearchTable {
9192
/** The cached mapping of alias type field to its original path. */
9293
private Map<String, String> aliasMapping = null;
9394

95+
/**
96+
* The cached per-index field mappings, keyed by concrete index name. Populated as a by-product of
97+
* resolving {@link #cachedFieldOpenSearchTypes}, since merging those types discards which index
98+
* mapped a field which way.
99+
*/
100+
private Map<String, IndexMapping> cachedIndexMappings = null;
101+
94102
/** The cached max result window setting of index. */
95103
private Integer cachedMaxResultWindow = null;
96104

@@ -137,10 +145,7 @@ public void create(Map<String, ExprType> schema) {
137145
*/
138146
@Override
139147
public Map<String, ExprType> getFieldTypes() {
140-
if (cachedFieldOpenSearchTypes == null) {
141-
cachedFieldOpenSearchTypes =
142-
new OpenSearchDescribeIndexRequest(client, indexName).getFieldTypes();
143-
}
148+
resolveFieldOpenSearchTypes();
144149
if (cachedFieldTypes == null) {
145150
cachedFieldTypes =
146151
OpenSearchDataType.traverseAndFlatten(cachedFieldOpenSearchTypes).entrySet().stream()
@@ -163,10 +168,7 @@ public Map<String, ExprType> getAllFieldTypes() {
163168
}
164169

165170
public Map<String, String> getAliasMapping() {
166-
if (cachedFieldOpenSearchTypes == null) {
167-
cachedFieldOpenSearchTypes =
168-
new OpenSearchDescribeIndexRequest(client, indexName).getFieldTypes();
169-
}
171+
resolveFieldOpenSearchTypes();
170172
if (aliasMapping == null) {
171173
aliasMapping =
172174
OpenSearchDataType.traverseAndFlatten(cachedFieldOpenSearchTypes).entrySet().stream()
@@ -184,11 +186,29 @@ public Map<String, String> getAliasMapping() {
184186
* @return A complete map between field names and their types.
185187
*/
186188
public Map<String, OpenSearchDataType> getFieldOpenSearchTypes() {
189+
resolveFieldOpenSearchTypes();
190+
return cachedFieldOpenSearchTypes;
191+
}
192+
193+
/**
194+
* The per-index field mappings behind this index's merged types, keyed by concrete index name
195+
* (the wildcard, if any, is already resolved). Needed by callers that must know which index
196+
* mapped a field which way -- the merged view in {@link #getFieldOpenSearchTypes()} discards
197+
* that. Shares the mapping fetch with the merged types, so this costs no extra round trip.
198+
*/
199+
public Map<String, IndexMapping> getIndexMappings() {
200+
resolveFieldOpenSearchTypes();
201+
return cachedIndexMappings;
202+
}
203+
204+
/** Fetch and cache the merged field types, retaining the per-index mappings behind them. */
205+
private void resolveFieldOpenSearchTypes() {
187206
if (cachedFieldOpenSearchTypes == null) {
188-
cachedFieldOpenSearchTypes =
189-
new OpenSearchDescribeIndexRequest(client, indexName).getFieldTypes();
207+
OpenSearchDescribeIndexRequest request =
208+
new OpenSearchDescribeIndexRequest(client, indexName);
209+
cachedFieldOpenSearchTypes = request.getFieldTypes();
210+
cachedIndexMappings = request.getLastIndexMappings();
190211
}
191-
return cachedFieldOpenSearchTypes;
192212
}
193213

194214
/** Get the max result window setting of the table. */

opensearch/src/main/java/org/opensearch/sql/opensearch/storage/scan/CalciteLogicalIndexScan.java

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -471,11 +471,9 @@ private AbstractRelNode tryPartialResultAggregate(
471471
try {
472472
List<String> outputFields = aggregate.getRowType().getFieldNames();
473473
List<String> bucketNames = outputFields.subList(0, aggregate.getGroupSet().cardinality());
474-
// getIndexNames() returns the raw pattern (e.g. ["logs-*"]) for a wildcard, not the resolved
475-
// concrete indices; getIndexMappings expands it server-side to a map keyed by concrete index
476-
// name, which is what the partitioning classifies.
477-
Map<String, IndexMapping> mappings =
478-
osIndex.getClient().getIndexMappings(osIndex.getIndexName().getIndexNames());
474+
// Per-index mappings keyed by concrete index name (the wildcard is already resolved), reused
475+
// from the fetch that resolved this index's field types rather than re-requesting them.
476+
Map<String, IndexMapping> mappings = osIndex.getIndexMappings();
479477
PartialResultAggregatePushdown.Plan plan =
480478
PartialResultAggregatePushdown.plan(bucketNames, mappings);
481479
if (plan == null) {

opensearch/src/test/java/org/opensearch/sql/opensearch/request/system/OpenSearchDescribeIndexRequestTest.java

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,48 @@ class OpenSearchDescribeIndexRequestTest {
3535

3636
@Mock private IndexMapping mapping2;
3737

38+
/**
39+
* Merging must not mutate the per-index mappings it reads. {@code MergeRuleHelper} rewrites the
40+
* accumulated type's nested {@code properties} in place, so without copying, the first index's
41+
* nested field would be merged into the second's -- and callers of {@link
42+
* OpenSearchDescribeIndexRequest#getLastIndexMappings()} (partial-result partitioning) would see
43+
* a text/keyword conflict as no conflict at all.
44+
*/
45+
@Test
46+
void getFieldTypesLeavesRetainedPerIndexMappingsIntact() {
47+
Map<String, OpenSearchDataType> keywordSide =
48+
Map.of("attrs", nestedObject("env", OpenSearchDataType.MappingType.Keyword));
49+
Map<String, OpenSearchDataType> textSide =
50+
Map.of("attrs", nestedObject("env", OpenSearchDataType.MappingType.Text));
51+
when(mapping.getFieldMappings()).thenReturn(keywordSide);
52+
when(mapping2.getFieldMappings()).thenReturn(textSide);
53+
when(client.getIndexMappings("idx-*"))
54+
.thenReturn(ImmutableMap.of("idx-keyword", mapping, "idx-text", mapping2));
55+
56+
OpenSearchDescribeIndexRequest request = new OpenSearchDescribeIndexRequest(client, "idx-*");
57+
request.getFieldTypes();
58+
59+
// Each index must still report the type it actually declared.
60+
assertEquals(
61+
OpenSearchDataType.MappingType.Keyword,
62+
nestedFieldType(request.getLastIndexMappings().get("idx-keyword"), "attrs", "env"));
63+
assertEquals(
64+
OpenSearchDataType.MappingType.Text,
65+
nestedFieldType(request.getLastIndexMappings().get("idx-text"), "attrs", "env"));
66+
}
67+
68+
private static OpenSearchDataType nestedObject(
69+
String innerField, OpenSearchDataType.MappingType innerType) {
70+
return OpenSearchDataType.of(
71+
OpenSearchDataType.MappingType.Object,
72+
Map.of("properties", Map.of(innerField, Map.of("type", innerType.toString()))));
73+
}
74+
75+
private static OpenSearchDataType.MappingType nestedFieldType(
76+
IndexMapping indexMapping, String parent, String child) {
77+
return indexMapping.getFieldMappings().get(parent).getProperties().get(child).getMappingType();
78+
}
79+
3880
@Test
3981
void testSearch() {
4082
when(mapping.getFieldMappings())

0 commit comments

Comments
 (0)