Skip to content

Commit b5e1963

Browse files
committed
Replace Scroll API with Search After API for read pagination in serverless mode
Signed-off-by: Sotaro Hikita <bering1814@gmail.com>
1 parent aaccda3 commit b5e1963

6 files changed

Lines changed: 279 additions & 27 deletions

File tree

mr/src/main/java/org/opensearch/hadoop/rest/RestClient.java

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -641,6 +641,35 @@ static BytesArray searchRequest(QueryBuilder query) {
641641
return out.bytes();
642642
}
643643

644+
/**
645+
* Build a search_after request body by appending search_after to the original query body.
646+
*/
647+
public BytesArray buildSearchAfterBody(Object[] searchAfter) {
648+
FastByteArrayOutputStream out = new FastByteArrayOutputStream(256);
649+
JacksonJsonGenerator generator = new JacksonJsonGenerator(out);
650+
try {
651+
generator.writeBeginObject();
652+
generator.writeFieldName("search_after");
653+
generator.writeBeginArray();
654+
for (Object value : searchAfter) {
655+
if (value instanceof Long) {
656+
generator.writeNumber((Long) value);
657+
} else if (value instanceof Double) {
658+
generator.writeNumber((Double) value);
659+
} else if (value instanceof Integer) {
660+
generator.writeNumber((Integer) value);
661+
} else {
662+
generator.writeString(String.valueOf(value));
663+
}
664+
}
665+
generator.writeEndArray();
666+
generator.writeEndObject();
667+
} finally {
668+
generator.close();
669+
}
670+
return out.bytes();
671+
}
672+
644673
public boolean isAlias(String query) {
645674
Map<String, Object> aliases = (Map<String, Object>) get(query, null);
646675
return (aliases.size() > 1);

mr/src/main/java/org/opensearch/hadoop/rest/RestRepository.java

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -348,6 +348,39 @@ Scroll scroll(String scrollId, ScrollReader reader) throws IOException {
348348
}
349349
}
350350

351+
Scroll searchAfter(String queryUri, BytesArray baseBody, Object[] searchAfter, ScrollReader reader) throws IOException {
352+
BytesArray body = mergeSearchAfterIntoBody(baseBody, searchAfter);
353+
InputStream response = client.execute(Request.Method.POST, queryUri, body).body();
354+
try {
355+
return reader.read(response);
356+
} finally {
357+
if (response instanceof StatsAware) {
358+
stats.aggregate(((StatsAware) response).stats());
359+
}
360+
}
361+
}
362+
363+
/**
364+
* Merge search_after values into the base request body.
365+
* Inserts "search_after":[...] before the closing brace of the JSON body.
366+
*/
367+
private BytesArray mergeSearchAfterIntoBody(BytesArray baseBody, Object[] searchAfter) {
368+
BytesArray searchAfterJson = client.buildSearchAfterBody(searchAfter);
369+
// baseBody: {...} searchAfterJson: {"search_after":[...]}
370+
// Result: base body with search_after inserted
371+
String base = baseBody.toString().trim();
372+
String saFragment = searchAfterJson.toString().trim();
373+
// Remove outer braces from search_after fragment: {"search_after":[...]} -> "search_after":[...]
374+
String saContent = saFragment.substring(1, saFragment.length() - 1);
375+
// Insert before closing brace of base body
376+
String merged = base.substring(0, base.length() - 1) + "," + saContent + "}";
377+
return new BytesArray(merged);
378+
}
379+
380+
ScrollQuery scanLimitSearchAfter(String query, BytesArray body, long limit, ScrollReader reader) {
381+
return new ScrollQuery(this, query, body, limit, reader, true);
382+
}
383+
351384
public boolean resourceExists(boolean read) {
352385
Resource res = (read ? resources.getResourceRead() : resources.getResourceWrite());
353386
// cheap hit - works for exact index names, index patterns, the `_all` resource, and alias names

mr/src/main/java/org/opensearch/hadoop/rest/RestService.java

Lines changed: 16 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -78,15 +78,21 @@ public static class PartitionReader implements Closeable {
7878
public final ScrollReader scrollReader;
7979
public final RestRepository client;
8080
public final SearchRequestBuilder queryBuilder;
81+
private final boolean serverlessMode;
8182

8283
private ScrollQuery scrollQuery;
8384

8485
private boolean closed = false;
8586

8687
PartitionReader(ScrollReader scrollReader, RestRepository client, SearchRequestBuilder queryBuilder) {
88+
this(scrollReader, client, queryBuilder, false);
89+
}
90+
91+
PartitionReader(ScrollReader scrollReader, RestRepository client, SearchRequestBuilder queryBuilder, boolean serverlessMode) {
8792
this.scrollReader = scrollReader;
8893
this.client = client;
8994
this.queryBuilder = queryBuilder;
95+
this.serverlessMode = serverlessMode;
9096
}
9197

9298
@Override
@@ -102,7 +108,11 @@ public void close() {
102108

103109
public ScrollQuery scrollQuery() {
104110
if (scrollQuery == null) {
105-
scrollQuery = queryBuilder.build(client, scrollReader);
111+
if (serverlessMode) {
112+
scrollQuery = queryBuilder.buildSearchAfter(client, scrollReader);
113+
} else {
114+
scrollQuery = queryBuilder.build(client, scrollReader);
115+
}
106116
}
107117

108118
return scrollQuery;
@@ -222,9 +232,10 @@ public static List<PartitionDefinition> findPartitions(Settings settings, Log lo
222232
* This function creates a single partition for each index as serverless doesn't expose shard information.
223233
*/
224234
static List<PartitionDefinition> findServerlessPartitions(RestRepository client, Settings settings, MappingSet mappingSet, Log log) {
225-
// Check if maxDocsPerPartition is set and throw exception if it is
226235
if (settings.getMaxDocsPerPartition() != null) {
227-
throw new OpenSearchHadoopIllegalArgumentException("maxDocsPerPartition setting is not supported in OpenSearch Serverless mode");
236+
throw new OpenSearchHadoopIllegalArgumentException(
237+
"maxDocsPerPartition setting is not supported in OpenSearch Serverless mode. " +
238+
"Serverless does not support Slice API which is required for parallel partition reads.");
228239
}
229240

230241
Resource readResource = new Resource(settings, true);
@@ -234,25 +245,13 @@ static List<PartitionDefinition> findServerlessPartitions(RestRepository client,
234245
log.info(String.format("Reading from [%s] in serverless mode", settings.getResourceRead()));
235246

236247
List<PartitionDefinition> partitions = new ArrayList<PartitionDefinition>();
237-
238-
// For serverless mode, we create partitions based on the index names in the resource
239-
// Since we cannot get shard information, we treat each index as a single partition
240248
String[] indices = readResource.index().split(",");
241-
249+
242250
for (String indexName : indices) {
243251
indexName = indexName.trim();
244-
// Create a single partition per index with dummy shard ID 0 (serverless mode detection via settings)
245252
partitions.add(partitionBuilder.build(indexName, 0, new String[0]));
246253
}
247254

248-
if (partitions.isEmpty()) {
249-
if (log.isDebugEnabled()) {
250-
log.debug("No partitions created for serverless mode - creating default partition");
251-
}
252-
// Fallback: create at least one partition for the primary index
253-
partitions.add(partitionBuilder.build(readResource.index(), 0, new String[0]));
254-
}
255-
256255
return partitions;
257256
}
258257

@@ -462,7 +461,7 @@ public static PartitionReader createReader(Settings settings, PartitionDefinitio
462461
requestBuilder = applyAliasMetadata(clusterInfo.getMajorVersion(), aliases, requestBuilder, partition.getIndex(), indices);
463462
}
464463
}
465-
return new PartitionReader(scrollReader, repository, requestBuilder);
464+
return new PartitionReader(scrollReader, repository, requestBuilder, settings.getServerlessMode());
466465
}
467466

468467
/**

mr/src/main/java/org/opensearch/hadoop/rest/ScrollQuery.java

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -66,13 +66,23 @@ public class ScrollQuery implements Iterator<Object>, Closeable, StatsAware {
6666
private boolean initialized = false;
6767
private String query;
6868
private BytesArray body;
69+
private final boolean serverlessMode;
70+
private Object[] searchAfter;
71+
// Retained for search_after continuation in serverless mode
72+
private String searchAfterUri;
73+
private BytesArray searchAfterBaseBody;
6974

7075
ScrollQuery(RestRepository client, String query, BytesArray body, long size, ScrollReader reader) {
76+
this(client, query, body, size, reader, false);
77+
}
78+
79+
ScrollQuery(RestRepository client, String query, BytesArray body, long size, ScrollReader reader, boolean serverlessMode) {
7180
this.repository = client;
7281
this.size = size;
7382
this.reader = reader;
7483
this.query = query;
7584
this.body = body;
85+
this.serverlessMode = serverlessMode;
7686
}
7787

7888
@Override
@@ -84,7 +94,7 @@ public void close() {
8494
reader.close();
8595
// typically the scroll is closed after it is consumed so this will trigger a 404
8696
// however we're closing it either way
87-
if (StringUtils.hasText(scrollId)) {
97+
if (!serverlessMode && StringUtils.hasText(scrollId)) {
8898
repository.getRestClient().deleteScroll(scrollId);
8999
}
90100
repository.close();
@@ -111,12 +121,18 @@ public boolean hasNext() {
111121
scrollId = scroll.getScrollId();
112122
batch = scroll.getHits();
113123
finished = scroll.isConcluded();
124+
searchAfter = scroll.getSearchAfter();
114125
} catch (IOException ex) {
115126
throw new OpenSearchHadoopIllegalStateException(String.format("Cannot create scroll for query [%s/%s]", query, body), ex);
116127
}
117128
read += batch.size();
118129
stats.docsReceived += batch.size();
119-
// no longer needed
130+
if (serverlessMode) {
131+
// Retain query URI and body for search_after continuation
132+
searchAfterUri = query;
133+
searchAfterBaseBody = body;
134+
}
135+
// no longer needed for scroll mode
120136
body = null;
121137
query = null;
122138
}
@@ -128,14 +144,25 @@ public boolean hasNext() {
128144
}
129145

130146
try {
131-
Scroll scroll = repository.scroll(scrollId, reader);
147+
Scroll scroll;
148+
if (serverlessMode) {
149+
scroll = repository.searchAfter(searchAfterUri, searchAfterBaseBody, searchAfter, reader);
150+
} else {
151+
scroll = repository.scroll(scrollId, reader);
152+
}
132153
if (scroll == null) {
133154
finished = true;
134155
return false;
135156
}
136157
scrollId = scroll.getScrollId();
137158
batch = scroll.getHits();
138159
finished = scroll.isConcluded();
160+
if (serverlessMode) {
161+
searchAfter = scroll.getSearchAfter();
162+
if (searchAfter == null) {
163+
finished = true;
164+
}
165+
}
139166
} catch (IOException ex) {
140167
throw new OpenSearchHadoopIllegalStateException("Cannot retrieve scroll [" + scrollId + "]", ex);
141168
}

mr/src/main/java/org/opensearch/hadoop/rest/SearchRequestBuilder.java

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -314,6 +314,95 @@ public ScrollQuery build(RestRepository client, ScrollReader reader) {
314314
return client.scanLimit(scrollUri, requestBody, limit, reader);
315315
}
316316

317+
/**
318+
* Build a ScrollQuery using Search After API for serverless mode.
319+
* Differs from build(): no scroll parameter in URI, sort added to body.
320+
*/
321+
public ScrollQuery buildSearchAfter(RestRepository client, ScrollReader reader) {
322+
String searchUri = assembleSearchAfterUri();
323+
BytesArray requestBody = assembleSearchAfterBody();
324+
return client.scanLimitSearchAfter(searchUri, requestBody, limit, reader);
325+
}
326+
327+
private String assembleSearchAfterUri() {
328+
if (limit > 0 && size > limit) {
329+
size = limit;
330+
}
331+
Map<String, String> uriParams = new LinkedHashMap<String, String>();
332+
StringBuilder sb = new StringBuilder();
333+
sb.append(indices);
334+
if (StringUtils.hasLength(types)) {
335+
sb.append("/");
336+
sb.append(types);
337+
}
338+
sb.append("/_search?");
339+
340+
uriParams.put("size", String.valueOf(size));
341+
if (includeVersion) {
342+
uriParams.put("version", "true");
343+
}
344+
if (routing != null) {
345+
uriParams.put("routing", HttpEncodingTools.encode(routing));
346+
}
347+
uriParams.put("track_total_hits", "true");
348+
if (readMetadata) {
349+
uriParams.put("track_scores", "true");
350+
}
351+
352+
for (Iterator<Entry<String, String>> it = uriParams.entrySet().iterator(); it.hasNext();) {
353+
Entry<String, String> entry = it.next();
354+
sb.append(entry.getKey());
355+
if (StringUtils.hasText(entry.getValue())) {
356+
sb.append("=");
357+
sb.append(entry.getValue());
358+
}
359+
if (it.hasNext()) {
360+
sb.append("&");
361+
}
362+
}
363+
return sb.toString();
364+
}
365+
366+
private BytesArray assembleSearchAfterBody() {
367+
QueryBuilder root = query;
368+
if (root == null) {
369+
root = MatchAllQueryBuilder.MATCH_ALL;
370+
}
371+
if (filters.isEmpty() == false) {
372+
root = new BoolQueryBuilder().must(root).filters(filters);
373+
}
374+
FastByteArrayOutputStream out = new FastByteArrayOutputStream(256);
375+
JacksonJsonGenerator generator = new JacksonJsonGenerator(out);
376+
try {
377+
generator.writeBeginObject();
378+
generator.writeFieldName("query");
379+
generator.writeBeginObject();
380+
root.toJson(generator);
381+
generator.writeEndObject();
382+
// sort by _doc for search_after pagination
383+
generator.writeFieldName("sort");
384+
generator.writeBeginArray();
385+
generator.writeString("_doc");
386+
generator.writeEndArray();
387+
if (StringUtils.hasText(fields)) {
388+
generator.writeFieldName("_source");
389+
generator.writeBeginArray();
390+
final List<String> fieldsArray = StringUtils.tokenize(fields, StringUtils.DEFAULT_DELIMITER);
391+
for (String field : fieldsArray) {
392+
generator.writeString(field);
393+
}
394+
generator.writeEndArray();
395+
} else if (excludeSource) {
396+
generator.writeFieldName("_source");
397+
generator.writeBoolean(false);
398+
}
399+
generator.writeEndObject();
400+
} finally {
401+
generator.close();
402+
}
403+
return out.bytes();
404+
}
405+
317406
@Override
318407
public String toString() {
319408
return "QueryBuilder [" + assemble() + "][" + assembleBody() + "]";

0 commit comments

Comments
 (0)