Skip to content

Commit b13b325

Browse files
author
karenx
committed
[GRPC] Optimize source conversion in gRPC search hits using zero-copy BytesRef
Signed-off-by: karenx <[email protected]>
1 parent bd914cb commit b13b325

File tree

3 files changed

+205
-1
lines changed

3 files changed

+205
-1
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
2929
- Remove cap on Java version used by forbidden APIs ([#19163](https://github.com/opensearch-project/OpenSearch/pull/19163))
3030
- Disable pruning for `doc_values` for the wildcard field mapper ([#18568](https://github.com/opensearch-project/OpenSearch/pull/18568))
3131
- Make all methods in Engine.Result public ([#19276](https://github.com/opensearch-project/OpenSearch/pull/19275))
32+
- Optimize source conversion in gRPC search hits using zero-copy BytesRef ([#19280](https://github.com/opensearch-project/OpenSearch/pull/19280))
3233

3334
### Fixed
3435
- Fix unnecessary refreshes on update preparation failures ([#15261](https://github.com/opensearch-project/OpenSearch/issues/15261))

modules/transport-grpc/src/main/java/org/opensearch/transport/grpc/proto/response/search/SearchHitProtoUtils.java

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,11 @@
88
package org.opensearch.transport.grpc.proto.response.search;
99

1010
import com.google.protobuf.ByteString;
11+
import com.google.protobuf.UnsafeByteOperations;
1112
import org.apache.lucene.search.Explanation;
13+
import org.apache.lucene.util.BytesRef;
1214
import org.opensearch.common.document.DocumentField;
15+
import org.opensearch.core.common.bytes.BytesArray;
1316
import org.opensearch.core.common.bytes.BytesReference;
1417
import org.opensearch.core.xcontent.ToXContent;
1518
import org.opensearch.core.xcontent.XContentBuilder;
@@ -194,7 +197,18 @@ private static void processMetadataFields(SearchHit hit, org.opensearch.protobuf
194197
*/
195198
private static void processSource(SearchHit hit, org.opensearch.protobufs.Hit.Builder hitBuilder) {
196199
if (hit.getSourceRef() != null) {
197-
hitBuilder.setSource(ByteString.copyFrom(BytesReference.toBytes(hit.getSourceRef())));
200+
BytesReference sourceRef = hit.getSourceRef();
201+
BytesRef bytesRef = sourceRef.toBytesRef();
202+
203+
if (sourceRef instanceof BytesArray) {
204+
if (bytesRef.offset == 0 && bytesRef.length == bytesRef.bytes.length) {
205+
hitBuilder.setSource(UnsafeByteOperations.unsafeWrap(bytesRef.bytes));
206+
} else {
207+
hitBuilder.setSource(UnsafeByteOperations.unsafeWrap(bytesRef.bytes, bytesRef.offset, bytesRef.length));
208+
}
209+
} else {
210+
hitBuilder.setSource(ByteString.copyFrom(bytesRef.bytes, bytesRef.offset, bytesRef.length));
211+
}
198212
}
199213
}
200214

modules/transport-grpc/src/test/java/org/opensearch/transport/grpc/proto/response/search/SearchHitProtoUtilsTests.java

Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
import org.apache.lucene.search.TotalHits;
1313
import org.opensearch.common.document.DocumentField;
1414
import org.opensearch.core.common.bytes.BytesArray;
15+
import org.opensearch.core.common.bytes.CompositeBytesReference;
1516
import org.opensearch.core.common.text.Text;
1617
import org.opensearch.core.index.shard.ShardId;
1718
import org.opensearch.index.seqno.SequenceNumbers;
@@ -250,4 +251,192 @@ public void testToProtoWithNestedIdentity() throws Exception {
250251
assertEquals("Nested field should match", "parent_field", hit.getNested().getField());
251252
assertEquals("Nested offset should match", 5, hit.getNested().getOffset());
252253
}
254+
255+
public void testToProtoWithBytesArrayZeroCopyOptimization() throws IOException {
256+
// Create a SearchHit with BytesArray source (should use zero-copy optimization)
257+
SearchHit searchHit = new SearchHit(1);
258+
byte[] sourceBytes = "{\"field\":\"value\",\"number\":42}".getBytes(StandardCharsets.UTF_8);
259+
BytesArray bytesArray = new BytesArray(sourceBytes);
260+
searchHit.sourceRef(bytesArray);
261+
262+
// Call the method under test
263+
Hit hit = SearchHitProtoUtils.toProto(searchHit);
264+
265+
// Verify the result
266+
assertNotNull("Hit should not be null", hit);
267+
assertTrue("Source should be set", hit.hasSource());
268+
assertArrayEquals("Source bytes should match exactly", sourceBytes, hit.getSource().toByteArray());
269+
270+
// Verify that the ByteString was created using UnsafeByteOperations.unsafeWrap
271+
// This is an indirect test - we verify the content is correct and assume the optimization was used
272+
assertEquals("Source size should match", sourceBytes.length, hit.getSource().size());
273+
}
274+
275+
public void testToProtoWithBytesArrayWithOffsetZeroCopyOptimization() throws IOException {
276+
// Create a SearchHit with BytesArray source that has offset/length (should use zero-copy with offset)
277+
SearchHit searchHit = new SearchHit(1);
278+
byte[] fullBytes = "prefix{\"field\":\"value\"}suffix".getBytes(StandardCharsets.UTF_8);
279+
byte[] expectedBytes = "{\"field\":\"value\"}".getBytes(StandardCharsets.UTF_8);
280+
int offset = 6; // "prefix".length()
281+
int length = expectedBytes.length;
282+
BytesArray bytesArray = new BytesArray(fullBytes, offset, length);
283+
searchHit.sourceRef(bytesArray);
284+
285+
// Call the method under test
286+
Hit hit = SearchHitProtoUtils.toProto(searchHit);
287+
288+
// Verify the result
289+
assertNotNull("Hit should not be null", hit);
290+
assertTrue("Source should be set", hit.hasSource());
291+
assertArrayEquals("Source bytes should match the sliced portion", expectedBytes, hit.getSource().toByteArray());
292+
assertEquals("Source size should match expected length", length, hit.getSource().size());
293+
}
294+
295+
public void testToProtoWithCompositeBytesReferenceUsesDeepCopy() throws IOException {
296+
// Create a SearchHit with CompositeBytesReference source (should use ByteString.copyFrom)
297+
SearchHit searchHit = new SearchHit(1);
298+
byte[] bytes1 = "{\"field1\":".getBytes(StandardCharsets.UTF_8);
299+
byte[] bytes2 = "\"value1\"}".getBytes(StandardCharsets.UTF_8);
300+
BytesArray part1 = new BytesArray(bytes1);
301+
BytesArray part2 = new BytesArray(bytes2);
302+
CompositeBytesReference compositeBytesRef = (CompositeBytesReference) CompositeBytesReference.of(part1, part2);
303+
searchHit.sourceRef(compositeBytesRef);
304+
305+
// Call the method under test
306+
Hit hit = SearchHitProtoUtils.toProto(searchHit);
307+
308+
// Verify the result
309+
assertNotNull("Hit should not be null", hit);
310+
assertTrue("Source should be set", hit.hasSource());
311+
312+
// Verify the combined content is correct
313+
String expectedJson = "{\"field1\":\"value1\"}";
314+
byte[] expectedBytes = expectedJson.getBytes(StandardCharsets.UTF_8);
315+
assertArrayEquals("Source bytes should match the combined content", expectedBytes, hit.getSource().toByteArray());
316+
assertEquals("Source size should match combined length", expectedBytes.length, hit.getSource().size());
317+
}
318+
319+
public void testToProtoWithEmptyBytesArraySource() throws IOException {
320+
// Create a SearchHit with minimal valid JSON as source (empty object)
321+
SearchHit searchHit = new SearchHit(1);
322+
byte[] emptyJsonBytes = "{}".getBytes(StandardCharsets.UTF_8);
323+
BytesArray emptyJsonBytesArray = new BytesArray(emptyJsonBytes);
324+
searchHit.sourceRef(emptyJsonBytesArray);
325+
326+
// Call the method under test
327+
Hit hit = SearchHitProtoUtils.toProto(searchHit);
328+
329+
// Verify the result
330+
assertNotNull("Hit should not be null", hit);
331+
assertTrue("Source should be set", hit.hasSource());
332+
assertEquals("Source should contain empty JSON object", emptyJsonBytes.length, hit.getSource().size());
333+
assertArrayEquals("Source bytes should match empty JSON object", emptyJsonBytes, hit.getSource().toByteArray());
334+
}
335+
336+
public void testToProtoWithLargeBytesArrayZeroCopyOptimization() throws IOException {
337+
// Create a SearchHit with large BytesArray source to test performance benefit
338+
SearchHit searchHit = new SearchHit(1);
339+
StringBuilder largeJsonBuilder = new StringBuilder("{\"data\":[");
340+
for (int i = 0; i < 1000; i++) {
341+
if (i > 0) largeJsonBuilder.append(",");
342+
largeJsonBuilder.append("{\"id\":").append(i).append(",\"value\":\"item").append(i).append("\"}");
343+
}
344+
largeJsonBuilder.append("]}");
345+
346+
byte[] largeSourceBytes = largeJsonBuilder.toString().getBytes(StandardCharsets.UTF_8);
347+
BytesArray largeBytesArray = new BytesArray(largeSourceBytes);
348+
searchHit.sourceRef(largeBytesArray);
349+
350+
// Call the method under test
351+
Hit hit = SearchHitProtoUtils.toProto(searchHit);
352+
353+
// Verify the result
354+
assertNotNull("Hit should not be null", hit);
355+
assertTrue("Source should be set", hit.hasSource());
356+
assertEquals("Source size should match large content", largeSourceBytes.length, hit.getSource().size());
357+
assertArrayEquals("Source bytes should match exactly", largeSourceBytes, hit.getSource().toByteArray());
358+
}
359+
360+
public void testToProtoWithBytesArraySliceZeroCopyOptimization() throws IOException {
361+
// Test the optimization with a BytesArray that represents a slice of a larger array
362+
SearchHit searchHit = new SearchHit(1);
363+
364+
// Create a larger byte array
365+
String fullContent = "HEADER{\"important\":\"data\",\"field\":\"value\"}FOOTER";
366+
byte[] fullBytes = fullContent.getBytes(StandardCharsets.UTF_8);
367+
368+
// Create a BytesArray that represents just the JSON part
369+
int jsonStart = 6; // "HEADER".length()
370+
String jsonContent = "{\"important\":\"data\",\"field\":\"value\"}";
371+
int jsonLength = jsonContent.length();
372+
BytesArray slicedBytesArray = new BytesArray(fullBytes, jsonStart, jsonLength);
373+
searchHit.sourceRef(slicedBytesArray);
374+
375+
// Call the method under test
376+
Hit hit = SearchHitProtoUtils.toProto(searchHit);
377+
378+
// Verify the result
379+
assertNotNull("Hit should not be null", hit);
380+
assertTrue("Source should be set", hit.hasSource());
381+
assertEquals("Source size should match JSON length", jsonLength, hit.getSource().size());
382+
383+
byte[] expectedJsonBytes = jsonContent.getBytes(StandardCharsets.UTF_8);
384+
assertArrayEquals("Source bytes should match only the JSON portion", expectedJsonBytes, hit.getSource().toByteArray());
385+
}
386+
387+
public void testToProtoSourceOptimizationBehaviorComparison() throws IOException {
388+
// Test to demonstrate the difference in behavior between BytesArray and other BytesReference types
389+
String jsonContent = "{\"test\":\"optimization\"}";
390+
byte[] jsonBytes = jsonContent.getBytes(StandardCharsets.UTF_8);
391+
392+
// Test with BytesArray (should use zero-copy optimization)
393+
SearchHit searchHitWithBytesArray = new SearchHit(1);
394+
BytesArray bytesArray = new BytesArray(jsonBytes);
395+
searchHitWithBytesArray.sourceRef(bytesArray);
396+
Hit hitWithBytesArray = SearchHitProtoUtils.toProto(searchHitWithBytesArray);
397+
398+
// Test with CompositeBytesReference (should use deep copy)
399+
SearchHit searchHitWithComposite = new SearchHit(2);
400+
BytesArray part1 = new BytesArray(jsonBytes, 0, jsonBytes.length / 2);
401+
BytesArray part2 = new BytesArray(jsonBytes, jsonBytes.length / 2, jsonBytes.length - jsonBytes.length / 2);
402+
CompositeBytesReference composite = (CompositeBytesReference) CompositeBytesReference.of(part1, part2);
403+
searchHitWithComposite.sourceRef(composite);
404+
Hit hitWithComposite = SearchHitProtoUtils.toProto(searchHitWithComposite);
405+
406+
// Both should produce the same result
407+
assertArrayEquals(
408+
"Both approaches should produce identical byte content",
409+
hitWithBytesArray.getSource().toByteArray(),
410+
hitWithComposite.getSource().toByteArray()
411+
);
412+
assertEquals("Both approaches should produce same size", hitWithBytesArray.getSource().size(), hitWithComposite.getSource().size());
413+
}
414+
415+
public void testToProtoWithNullSourceRef() throws IOException {
416+
// Test behavior when source reference is null
417+
SearchHit searchHit = new SearchHit(1);
418+
// Don't set any source reference (sourceRef remains null)
419+
420+
// Call the method under test
421+
Hit hit = SearchHitProtoUtils.toProto(searchHit);
422+
423+
// Verify the result
424+
assertNotNull("Hit should not be null", hit);
425+
assertFalse("Source should not be set when sourceRef is null", hit.hasSource());
426+
}
427+
428+
public void testToProtoWithActuallyEmptyBytesArray() throws IOException {
429+
// Test the edge case of truly empty bytes - this should be handled gracefully
430+
// by checking if the source is null before processing
431+
SearchHit searchHit = new SearchHit(1);
432+
// Explicitly set source to null to test null handling
433+
searchHit.sourceRef(null);
434+
435+
// Call the method under test
436+
Hit hit = SearchHitProtoUtils.toProto(searchHit);
437+
438+
// Verify the result
439+
assertNotNull("Hit should not be null", hit);
440+
assertFalse("Source should not be set when explicitly null", hit.hasSource());
441+
}
253442
}

0 commit comments

Comments
 (0)