Skip to content

Commit ef51455

Browse files
committed
perf: optimize SearchHit mapping and add benchmark comparison test
- Reimplemented from() as fromOptimized() to reduce JSON mapping overhead - Improved object mapping performance by avoiding redundant serialization - Added benchmark test comparing legacy and optimized implementations
1 parent 16f3add commit ef51455

File tree

3 files changed

+222
-7
lines changed

3 files changed

+222
-7
lines changed

src/main/java/org/springframework/data/elasticsearch/client/elc/DocumentAdapters.java

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,84 @@ public static SearchDocument from(Hit<?> hit, JsonpMapper jsonpMapper) {
144144
documentFields, highlightFields, innerHits, nestedMetaData, explanation, matchedQueries, hit.routing());
145145
}
146146

147+
/**
148+
* Creates a {@link SearchDocument} from a {@link Hit} returned by the Elasticsearch client.
149+
*
150+
* @param hit the hit object
151+
* @param jsonpMapper to map JsonData objects
152+
* @return the created {@link SearchDocument}
153+
*/
154+
public static SearchDocument fromOptimized(Hit<?> hit, JsonpMapper jsonpMapper) {
155+
156+
Assert.notNull(hit, "hit must not be null");
157+
158+
Map<String, List<String>> highlightFields = hit.highlight();
159+
160+
Map<String, SearchDocumentResponse> innerHits = hit.innerHits().entrySet().parallelStream()
161+
.collect(Collectors.toMap(
162+
Map.Entry::getKey,
163+
entry -> SearchDocumentResponseBuilder.from(
164+
entry.getValue().hits(), null, null, null, 0, null, null,
165+
searchDocument -> null, jsonpMapper
166+
),
167+
(a, b) -> b,
168+
LinkedHashMap::new
169+
));
170+
171+
NestedMetaData nestedMetaData = from(hit.nested());
172+
Explanation explanation = from(hit.explanation());
173+
174+
Map<String, Double> matchedQueries = hit.matchedQueries();
175+
176+
EntityAsMap hitFieldsAsMap = new EntityAsMap();
177+
if (!hit.fields().isEmpty()) {
178+
Map<String, Object> fieldMap = new LinkedHashMap<>();
179+
hit.fields().forEach((key, jsonData) -> {
180+
var value = jsonData.to(Object.class);
181+
fieldMap.put(key, value);
182+
});
183+
hitFieldsAsMap.putAll(fieldMap);
184+
}
185+
186+
Map<String, List<Object>> documentFields = hitFieldsAsMap.entrySet().stream()
187+
.collect(Collectors.toMap(
188+
Map.Entry::getKey,
189+
entry -> entry.getValue() instanceof List
190+
? (List<Object>) entry.getValue()
191+
: Collections.singletonList(entry.getValue()),
192+
(a, b) -> b,
193+
LinkedHashMap::new
194+
));
195+
196+
Document document;
197+
Object source = hit.source();
198+
if (source == null) {
199+
document = Document.from(hitFieldsAsMap);
200+
} else if (source instanceof EntityAsMap entityAsMap) {
201+
document = Document.from(entityAsMap);
202+
} else if (source instanceof JsonData jsonData) {
203+
document = Document.from(jsonData.to(EntityAsMap.class));
204+
} else {
205+
if (LOGGER.isWarnEnabled()) {
206+
LOGGER.warn(String.format("Cannot map from type " + source.getClass().getName()));
207+
}
208+
document = Document.create();
209+
}
210+
document.setIndex(hit.index());
211+
document.setId(hit.id());
212+
if (hit.version() != null) {
213+
document.setVersion(hit.version());
214+
}
215+
document.setSeqNo(hit.seqNo() != null && hit.seqNo() >= 0 ? hit.seqNo() : -2);
216+
document.setPrimaryTerm(hit.primaryTerm() != null && hit.primaryTerm() > 0 ? hit.primaryTerm() : 0);
217+
218+
float score = hit.score() != null ? hit.score().floatValue() : Float.NaN;
219+
return new SearchDocumentAdapter(
220+
document, score, hit.sort().stream().map(TypeUtils::toObject).toArray(),
221+
documentFields, highlightFields, innerHits, nestedMetaData, explanation, matchedQueries, hit.routing()
222+
);
223+
}
224+
147225
public static SearchDocument from(CompletionSuggestOption<EntityAsMap> completionSuggestOption) {
148226

149227
Document document = completionSuggestOption.source() != null ? Document.from(completionSuggestOption.source())
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
/*
2+
* Copyright 2021-2025 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package org.springframework.data.elasticsearch.client.elc;
17+
18+
import co.elastic.clients.elasticsearch.core.search.*;
19+
import co.elastic.clients.json.JsonData;
20+
import co.elastic.clients.json.JsonpMapper;
21+
import co.elastic.clients.json.jackson.JacksonJsonpMapper;
22+
import org.junit.jupiter.api.BeforeEach;
23+
import org.junit.jupiter.api.Test;
24+
25+
import java.util.*;
26+
import java.util.function.Supplier;
27+
import java.util.stream.Collectors;
28+
29+
class DocumentAdaptersBenchmark {
30+
31+
private final JsonpMapper jsonpMapper = new JacksonJsonpMapper();
32+
33+
private List<Hit<EntityAsMap>> mockHits;
34+
35+
@BeforeEach
36+
public void setup() {
37+
mockHits = generateMockHits(100);
38+
}
39+
40+
public Object from() {
41+
return mockHits.stream()
42+
.map(hit -> DocumentAdapters.from(hit, jsonpMapper))
43+
.collect(Collectors.toList());
44+
}
45+
46+
public Object fromOptimized() {
47+
return mockHits.stream()
48+
.map(hit -> DocumentAdapters.fromOptimized(hit, jsonpMapper))
49+
.collect(Collectors.toList());
50+
}
51+
52+
@Test
53+
public void runner() {
54+
benchmark("from", this::from);
55+
benchmark("fromOptimized", this::fromOptimized);
56+
}
57+
58+
private void benchmark(String name, Supplier<Object> func) {
59+
int warmups = 5;
60+
int runs = 20;
61+
62+
// Warm-up
63+
for (int i = 0; i < warmups; i++) {
64+
func.get();
65+
}
66+
67+
long total = 0;
68+
for (int i = 0; i < runs; i++) {
69+
long start = System.nanoTime();
70+
func.get();
71+
long end = System.nanoTime();
72+
total += (end - start);
73+
}
74+
75+
double avgMs = total / (runs * 1_000_000.0);
76+
System.out.printf("%s average time: %.6f ms%n", name, avgMs);
77+
}
78+
79+
public static List<Hit<EntityAsMap>> generateMockHits(int count) {
80+
List<Hit<EntityAsMap>> results = new ArrayList<>();
81+
Random random = new Random();
82+
83+
for (int i = 0; i < count; i++) {
84+
EntityAsMap source = new EntityAsMap();
85+
source.put("field1", 10000 + i);
86+
source.put("field2", "value2_" + UUID.randomUUID().toString().substring(0, 8));
87+
source.put("field3", UUID.randomUUID().toString() + ".jpeg");
88+
source.put("field4", "value4_" + random.nextInt(1000));
89+
source.put("field5", "value5_" + random.nextInt(10));
90+
91+
List<Map<String, Object>> subCategories = new ArrayList<>();
92+
for (int j = 0; j < 50; j++) {
93+
Map<String, Object> subCat = new LinkedHashMap<>();
94+
subCat.put("key", "sub_category_" + j);
95+
subCat.put("value", random.nextInt(5));
96+
subCategories.add(subCat);
97+
}
98+
99+
EntityAsMap innerSource = new EntityAsMap();
100+
innerSource.put("field1", "inner_source_" + i);
101+
innerSource.put("field2", random.nextInt(5));
102+
innerSource.put("field3", subCategories);
103+
104+
NestedIdentity nested = new NestedIdentity.Builder().field("nested_field").offset(random.nextInt(10)).build();
105+
Hit<JsonData> innerHit = new Hit.Builder<JsonData>()
106+
.index("index")
107+
.id(String.valueOf(10000 + i))
108+
.nested(nested)
109+
.score(random.nextDouble())
110+
.source(JsonData.of(innerSource))
111+
.build();
112+
113+
List<Hit<JsonData>> innerHits = List.of(innerHit);
114+
115+
TotalHits total = new TotalHits.Builder()
116+
.value(1)
117+
.relation(TotalHitsRelation.Eq)
118+
.build();
119+
120+
HitsMetadata<JsonData> hits = new HitsMetadata.Builder<JsonData>()
121+
.total(total)
122+
.maxScore(random.nextDouble())
123+
.hits(innerHits)
124+
.build();
125+
126+
InnerHitsResult innerHitsResult = new InnerHitsResult.Builder()
127+
.hits(hits)
128+
.build();
129+
130+
Hit<EntityAsMap> hit = new Hit.Builder<EntityAsMap>()
131+
.index("index")
132+
.id(String.valueOf(10000 + i))
133+
.score(random.nextDouble())
134+
.source(source)
135+
.innerHits(Map.of("inner_hit", innerHitsResult))
136+
.build();
137+
results.add(hit);
138+
}
139+
return results;
140+
}
141+
}

src/test/java/org/springframework/data/elasticsearch/client/elc/DocumentAdaptersUnitTests.java

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,23 +15,19 @@
1515
*/
1616
package org.springframework.data.elasticsearch.client.elc;
1717

18-
import co.elastic.clients.elasticsearch.core.search.Hit;
18+
import co.elastic.clients.elasticsearch.core.search.*;
1919
import co.elastic.clients.json.JsonData;
2020
import co.elastic.clients.json.JsonpMapper;
2121
import co.elastic.clients.json.jackson.JacksonJsonpMapper;
22-
23-
import java.util.Arrays;
24-
import java.util.Collections;
25-
import java.util.List;
26-
import java.util.Map;
27-
2822
import org.assertj.core.api.SoftAssertions;
2923
import org.assertj.core.data.Offset;
3024
import org.junit.jupiter.api.DisplayName;
3125
import org.junit.jupiter.api.Test;
3226
import org.springframework.data.elasticsearch.core.document.Explanation;
3327
import org.springframework.data.elasticsearch.core.document.SearchDocument;
3428

29+
import java.util.*;
30+
3531
/**
3632
* @author Peter-Josef Meisch
3733
* @since 4.4

0 commit comments

Comments
 (0)