Skip to content

Commit 68a04a3

Browse files
author
Lukas Wegmann
authored
SQL: Replace scroll cursors with point-in-time and search_after (#83381)
Resolves #61873 The goal of this PR is to remove the use of the deprecated scroll cursors in SQL. Functionality and APIs should remain the same with one notable difference: The last page of a search hit query used to always include a scroll cursor if it is non-empty. This is no longer the case, if a result set is exhausted, the PIT will be closed and the last page does not include a cursor. Note, PIT can also be used for aggregation and PIVOT queries but this is not in the scope of this PR and will be implemented in a follow up. Additionally, this PR resolves #80523 because the total doc count is no longer required.
1 parent 5d84217 commit 68a04a3

File tree

25 files changed

+585
-426
lines changed

25 files changed

+585
-426
lines changed

docs/changelog/83381.yaml

+7
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
pr: 83381
2+
summary: Replace scroll cursors with point-in-time and `search_after`
3+
area: SQL
4+
type: enhancement
5+
issues:
6+
- 61873
7+
- 80523

x-pack/plugin/build.gradle

+38-17
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import org.elasticsearch.gradle.Version
2+
import org.elasticsearch.gradle.VersionProperties
23
import org.elasticsearch.gradle.internal.info.BuildParams
3-
import org.elasticsearch.gradle.util.GradleUtils
44
import org.elasticsearch.gradle.internal.test.RestIntegTestTask
5-
import org.elasticsearch.gradle.VersionProperties
5+
import org.elasticsearch.gradle.util.GradleUtils
66

77
apply plugin: 'elasticsearch.internal-yaml-rest-test'
88
apply plugin: 'elasticsearch.yaml-rest-compat-test'
@@ -77,54 +77,75 @@ tasks.named("yamlRestTest").configure {
7777
}
7878

7979
tasks.named("yamlRestTestV7CompatTest").configure {
80-
systemProperty 'tests.rest.blacklist', [
81-
'unsigned_long/50_script_values/Scripted sort values',
82-
'unsigned_long/50_script_values/script_score query',
83-
'unsigned_long/50_script_values/Script query',
84-
'data_stream/140_data_stream_aliases/Fix IndexNotFoundException error when handling remove alias action',
85-
].join(',')
80+
systemProperty 'tests.rest.blacklist', [
81+
'unsigned_long/50_script_values/Scripted sort values',
82+
'unsigned_long/50_script_values/script_score query',
83+
'unsigned_long/50_script_values/Script query',
84+
'data_stream/140_data_stream_aliases/Fix IndexNotFoundException error when handling remove alias action',
85+
].join(',')
8686
}
8787

88-
tasks.named("yamlRestTestV7CompatTransform").configure{ task ->
89-
task.skipTest("vectors/10_dense_vector_basic/Deprecated function signature", "to support it, it would require to almost revert back the #48725 and complicate the code" )
88+
tasks.named("yamlRestTestV7CompatTransform").configure { task ->
89+
task.skipTest(
90+
"vectors/10_dense_vector_basic/Deprecated function signature",
91+
"to support it, it would require to almost revert back the #48725 and complicate the code"
92+
)
9093
task.skipTest("vectors/30_sparse_vector_basic/Cosine Similarity", "not supported for compatibility")
9194
task.skipTest("vectors/30_sparse_vector_basic/Deprecated function signature", "not supported for compatibility")
9295
task.skipTest("vectors/30_sparse_vector_basic/Dot Product", "not supported for compatibility")
9396
task.skipTest("vectors/35_sparse_vector_l1l2/L1 norm", "not supported for compatibility")
9497
task.skipTest("vectors/35_sparse_vector_l1l2/L2 norm", "not supported for compatibility")
9598
task.skipTest("vectors/40_sparse_vector_special_cases/Dimensions can be sorted differently", "not supported for compatibility")
9699
task.skipTest("vectors/40_sparse_vector_special_cases/Documents missing a vector field", "not supported for compatibility")
97-
task.skipTest("vectors/40_sparse_vector_special_cases/Query vector has different dimensions from documents' vectors", "not supported for compatibility")
100+
task.skipTest(
101+
"vectors/40_sparse_vector_special_cases/Query vector has different dimensions from documents' vectors",
102+
"not supported for compatibility"
103+
)
98104
task.skipTest("vectors/40_sparse_vector_special_cases/Sparse vectors should error with dense vector functions", "not supported for compatibility")
99105
task.skipTest("vectors/40_sparse_vector_special_cases/Vectors of different dimensions and data types", "not supported for compatibility")
100106
task.skipTest("vectors/50_vector_stats/Usage stats on vector fields", "not supported for compatibility")
101-
task.skipTest("roles/30_prohibited_role_query/Test use prohibited query inside role query", "put role request with a term lookup (deprecated) and type. Requires validation in REST layer")
107+
task.skipTest(
108+
"roles/30_prohibited_role_query/Test use prohibited query inside role query",
109+
"put role request with a term lookup (deprecated) and type. Requires validation in REST layer"
110+
)
102111
task.skipTest("ml/jobs_crud/Test create job with delimited format", "removing undocumented functionality")
103112
task.skipTest("ml/datafeeds_crud/Test update datafeed to point to missing job", "behaviour change #44752 - not allowing to update datafeed job_id")
104-
task.skipTest("ml/datafeeds_crud/Test update datafeed to point to different job", "behaviour change #44752 - not allowing to update datafeed job_id")
105-
task.skipTest("ml/datafeeds_crud/Test update datafeed to point to job already attached to another datafeed", "behaviour change #44752 - not allowing to update datafeed job_id")
113+
task.skipTest(
114+
"ml/datafeeds_crud/Test update datafeed to point to different job",
115+
"behaviour change #44752 - not allowing to update datafeed job_id"
116+
)
117+
task.skipTest(
118+
"ml/datafeeds_crud/Test update datafeed to point to job already attached to another datafeed",
119+
"behaviour change #44752 - not allowing to update datafeed job_id"
120+
)
106121
task.skipTest("rollup/delete_job/Test basic delete_job", "rollup was an experimental feature, also see #41227")
107122
task.skipTest("rollup/delete_job/Test delete job twice", "rollup was an experimental feature, also see #41227")
108123
task.skipTest("rollup/delete_job/Test delete running job", "rollup was an experimental feature, also see #41227")
109124
task.skipTest("rollup/get_jobs/Test basic get_jobs", "rollup was an experimental feature, also see #41227")
110125
task.skipTest("rollup/put_job/Test basic put_job", "rollup was an experimental feature, also see #41227")
111126
task.skipTest("rollup/start_job/Test start job twice", "rollup was an experimental feature, also see #41227")
112-
task.skipTest("ml/trained_model_cat_apis/Test cat trained models", "A type field was added to cat.ml_trained_models #73660, this is a backwards compatible change. Still this is a cat api, and we don't support them with rest api compatibility. (the test would be very hard to transform too)")
127+
task.skipTest(
128+
"ml/trained_model_cat_apis/Test cat trained models",
129+
"A type field was added to cat.ml_trained_models #73660, this is a backwards compatible change. Still this is a cat api, and we don't support them with rest api compatibility. (the test would be very hard to transform too)"
130+
)
113131
task.skipTest("indices.freeze/30_usage/Usage stats on frozen indices", "#70192 -- the freeze index API is removed from 8.0")
114132
task.skipTest("indices.freeze/20_stats/Translog stats on frozen indices", "#70192 -- the freeze index API is removed from 8.0")
115133
task.skipTest("indices.freeze/10_basic/Basic", "#70192 -- the freeze index API is removed from 8.0")
116134
task.skipTest("indices.freeze/10_basic/Test index options", "#70192 -- the freeze index API is removed from 8.0")
135+
task.skipTest("sql/sql/Paging through results", "scrolling through search hit queries no longer produces empty last page in 8.2")
117136
task.skipTest("service_accounts/10_basic/Test get service accounts", "new service accounts are added")
118137

119138
task.replaceValueInMatch("_type", "_doc")
120139
task.addAllowedWarningRegex("\\[types removal\\].*")
121140
task.addAllowedWarningRegexForTest("Including \\[accept_enterprise\\] in get license.*", "Installing enterprise license")
122141
task.addAllowedWarningRegex("bucket_span .* is not an integral .* of the number of seconds in 1d.* This is now deprecated.*")
123142

124-
task.replaceValueTextByKeyValue("catch",
143+
task.replaceValueTextByKeyValue(
144+
"catch",
125145
'bad_request',
126146
'/It is no longer possible to freeze indices, but existing frozen indices can still be unfrozen/',
127-
"Cannot freeze write index for data stream")
147+
"Cannot freeze write index for data stream"
148+
)
128149
}
129150

130151

x-pack/plugin/sql/qa/jdbc/src/main/java/org/elasticsearch/xpack/sql/qa/jdbc/JdbcIntegrationTestCase.java

+1-1
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ public abstract class JdbcIntegrationTestCase extends ESRestTestCase {
3131

3232
@After
3333
public void checkSearchContent() throws IOException {
34-
// Some context might linger due to fire and forget nature of scroll cleanup
34+
// Some context might linger due to fire and forget nature of PIT cleanup
3535
assertNoSearchContexts();
3636
}
3737

x-pack/plugin/sql/qa/mixed-node/src/test/java/org/elasticsearch/xpack/sql/qa/mixed_node/SqlCompatIT.java

+48-7
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
import org.elasticsearch.Version;
1212
import org.elasticsearch.client.Request;
1313
import org.elasticsearch.client.Response;
14+
import org.elasticsearch.client.ResponseException;
1415
import org.elasticsearch.client.RestClient;
1516
import org.elasticsearch.common.Strings;
1617
import org.elasticsearch.common.xcontent.XContentHelper;
@@ -21,6 +22,7 @@
2122
import org.elasticsearch.xpack.ql.TestNode;
2223
import org.elasticsearch.xpack.ql.TestNodes;
2324
import org.elasticsearch.xpack.sql.qa.rest.BaseRestSqlTestCase;
25+
import org.hamcrest.Matchers;
2426
import org.junit.AfterClass;
2527
import org.junit.Before;
2628

@@ -111,8 +113,7 @@ private void testNullsOrderWithMissingOrderSupport(RestClient client) throws IOE
111113
assertNull(result.get(2));
112114
}
113115

114-
@SuppressWarnings("unchecked")
115-
private List<Integer> runOrderByNullsLastQuery(RestClient queryClient) throws IOException {
116+
private void indexDocs() throws IOException {
116117
Request putIndex = new Request("PUT", "/test");
117118
putIndex.setJsonEntity("""
118119
{"settings":{"index":{"number_of_shards":3}}}""");
@@ -124,17 +125,19 @@ private List<Integer> runOrderByNullsLastQuery(RestClient queryClient) throws IO
124125
for (String doc : Arrays.asList("{\"int\":1,\"kw\":\"foo\"}", "{\"int\":2,\"kw\":\"bar\"}", "{\"kw\":\"bar\"}")) {
125126
bulk.append("{\"index\":{}}\n").append(doc).append("\n");
126127
}
128+
127129
indexDocs.setJsonEntity(bulk.toString());
128130
client().performRequest(indexDocs);
131+
}
132+
133+
@SuppressWarnings("unchecked")
134+
private List<Integer> runOrderByNullsLastQuery(RestClient queryClient) throws IOException {
135+
indexDocs();
129136

130137
Request query = new Request("POST", "_sql");
131138
query.setJsonEntity(sqlQueryEntityWithOptionalMode("SELECT int FROM test GROUP BY 1 ORDER BY 1 NULLS LAST", bwcVersion));
132-
Response queryResponse = queryClient.performRequest(query);
133-
134-
assertEquals(200, queryResponse.getStatusLine().getStatusCode());
139+
Map<String, Object> result = performRequestAndReadBodyAsJson(queryClient, query);
135140

136-
InputStream content = queryResponse.getEntity().getContent();
137-
Map<String, Object> result = XContentHelper.convertToMap(JsonXContent.jsonXContent, content, false);
138141
List<List<Object>> rows = (List<List<Object>>) result.get("rows");
139142
return rows.stream().map(row -> (Integer) row.get(0)).collect(Collectors.toList());
140143
}
@@ -156,4 +159,42 @@ public static String sqlQueryEntityWithOptionalMode(String query, Version bwcVer
156159
return Strings.toString(json);
157160
}
158161

162+
public void testCursorFromOldNodeFailsOnNewNode() throws IOException {
163+
assertCursorNotCompatibleAcrossVersions(bwcVersion, oldNodesClient, Version.CURRENT, newNodesClient);
164+
}
165+
166+
@AwaitsFix(bugUrl = "https://github.com/elastic/elasticsearch/issues/83726")
167+
public void testCursorFromNewNodeFailsOnOldNode() throws IOException {
168+
assertCursorNotCompatibleAcrossVersions(Version.CURRENT, newNodesClient, bwcVersion, oldNodesClient);
169+
}
170+
171+
private void assertCursorNotCompatibleAcrossVersions(Version version1, RestClient client1, Version version2, RestClient client2)
172+
throws IOException {
173+
indexDocs();
174+
175+
Request req = new Request("POST", "_sql");
176+
// GROUP BY queries always return a cursor
177+
req.setJsonEntity(sqlQueryEntityWithOptionalMode("SELECT int FROM test GROUP BY 1", bwcVersion));
178+
Map<String, Object> json = performRequestAndReadBodyAsJson(client1, req);
179+
String cursor = (String) json.get("cursor");
180+
assertThat(cursor, Matchers.not(Matchers.emptyString()));
181+
182+
Request scrollReq = new Request("POST", "_sql");
183+
scrollReq.setJsonEntity("{\"cursor\": \"%s\"}".formatted(cursor));
184+
ResponseException exception = expectThrows(ResponseException.class, () -> client2.performRequest(scrollReq));
185+
186+
assertThat(
187+
exception.getMessage(),
188+
Matchers.containsString("Unsupported cursor version [" + version1 + "], expected [" + version2 + "]")
189+
);
190+
}
191+
192+
private Map<String, Object> performRequestAndReadBodyAsJson(RestClient client, Request request) throws IOException {
193+
Response response = client.performRequest(request);
194+
assertEquals(200, response.getStatusLine().getStatusCode());
195+
try (InputStream content = response.getEntity().getContent()) {
196+
return XContentHelper.convertToMap(JsonXContent.jsonXContent, content, false);
197+
}
198+
}
199+
159200
}

x-pack/plugin/sql/qa/server/security/src/test/java/org/elasticsearch/xpack/sql/qa/security/RestSqlSecurityIT.java

+19-12
Original file line numberDiff line numberDiff line change
@@ -281,18 +281,27 @@ protected AuditLogAsserter createAuditLogAsserter() {
281281
}
282282

283283
/**
284-
* Test the hijacking a scroll fails. This test is only implemented for
285-
* REST because it is the only API where it is simple to hijack a scroll.
284+
* Test the hijacking a cursor fails. This test is only implemented for
285+
* REST because it is the only API where it is simple to hijack a cursor.
286286
* It should exercise the same code as the other APIs but if we were truly
287287
* paranoid we'd hack together something to test the others as well.
288288
*/
289-
public void testHijackScrollFails() throws Exception {
290-
createUser("full_access", "rest_minimal");
289+
public void testHijackCursorFails() throws Exception {
290+
createUser("no_read", "read_nothing");
291291
final String mode = randomMode();
292292

293+
final String query = randomFrom(
294+
List.of(
295+
"SELECT * FROM test",
296+
"SELECT a FROM test GROUP BY a",
297+
"SELECT MAX(a) FROM test GROUP BY a ORDER BY 1",
298+
"SHOW COLUMNS IN test"
299+
)
300+
);
301+
293302
Map<String, Object> adminResponse = RestActions.runSql(
294303
null,
295-
new StringEntity(query("SELECT * FROM test").mode(mode).fetchSize(1).toString(), ContentType.APPLICATION_JSON),
304+
new StringEntity(query(query).mode(mode).fetchSize(1).toString(), ContentType.APPLICATION_JSON),
296305
mode,
297306
false
298307
);
@@ -303,20 +312,18 @@ public void testHijackScrollFails() throws Exception {
303312
ResponseException e = expectThrows(
304313
ResponseException.class,
305314
() -> RestActions.runSql(
306-
"full_access",
315+
"no_read",
307316
new StringEntity(cursor(cursor).mode(mode).toString(), ContentType.APPLICATION_JSON),
308317
mode,
309318
false
310319
)
311320
);
312-
// TODO return a better error message for bad scrolls
313-
assertThat(e.getMessage(), containsString("No search context found for id"));
314-
assertEquals(404, e.getResponse().getStatusLine().getStatusCode());
321+
322+
assertThat(e.getMessage(), containsString("is unauthorized for user"));
323+
assertEquals(403, e.getResponse().getStatusLine().getStatusCode());
315324

316325
createAuditLogAsserter().expectSqlCompositeActionFieldCaps("test_admin", "test")
317-
.expect(true, SQL_ACTION_NAME, "full_access", empty())
318-
// one scroll access denied per shard
319-
.expect("access_denied", SQL_ACTION_NAME, "full_access", "default_native", empty(), "InternalScrollSearchRequest")
326+
.expect("access_denied", SQL_ACTION_NAME, "no_read", "default_native", empty(), "SqlQueryRequest")
320327
.assertLogs();
321328
}
322329

x-pack/plugin/sql/qa/server/src/main/java/org/elasticsearch/xpack/sql/qa/jdbc/JdbcIntegrationTestCase.java

+1-1
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ public abstract class JdbcIntegrationTestCase extends RemoteClusterAwareSqlRestT
3131

3232
@After
3333
public void checkSearchContent() throws Exception {
34-
// Some context might linger due to fire and forget nature of scroll cleanup
34+
// Some context might linger due to fire and forget nature of PIT cleanup
3535
assertNoSearchContexts(provisioningClient());
3636
}
3737

x-pack/plugin/sql/qa/server/src/main/java/org/elasticsearch/xpack/sql/qa/rest/RestSqlTestCase.java

+14-25
Original file line numberDiff line numberDiff line change
@@ -254,6 +254,7 @@ public void testNextPageWithDatetimeAndTimezoneParam() throws IOException {
254254
expected.put("columns", singletonList(columnInfo(mode, "tz", "integer", JDBCType.INTEGER, 11)));
255255
response = runSql(new StringEntity(sqlRequest, ContentType.APPLICATION_JSON), "", mode);
256256
} else {
257+
assertNotNull(cursor);
257258
response = runSql(
258259
new StringEntity(cursor(cursor).mode(mode).toString(), ContentType.APPLICATION_JSON),
259260
StringUtils.EMPTY,
@@ -270,16 +271,12 @@ public void testNextPageWithDatetimeAndTimezoneParam() throws IOException {
270271
);
271272
}
272273
expected.put("rows", values);
274+
assertTrue(response.containsKey("cursor") == false || response.get("cursor") != null);
273275
cursor = (String) response.remove("cursor");
274276
assertResponse(expected, response);
275-
assertNotNull(cursor);
276277
}
277-
Map<String, Object> expected = new HashMap<>();
278-
expected.put("rows", emptyList());
279-
assertResponse(
280-
expected,
281-
runSql(new StringEntity(cursor(cursor).mode(mode).toString(), ContentType.APPLICATION_JSON), StringUtils.EMPTY, mode)
282-
);
278+
279+
assertNull(cursor);
283280

284281
deleteIndex("test_date_timezone");
285282
}
@@ -1182,7 +1179,7 @@ private void executeQueryWithNextPage(String format, String expectedHeader, Stri
11821179
.toString();
11831180

11841181
String cursor = null;
1185-
for (int i = 0; i < 20; i += 2) {
1182+
for (int i = 0; i <= 20; i += 2) {
11861183
Tuple<String, String> response;
11871184
if (i == 0) {
11881185
response = runSqlAsText(StringUtils.EMPTY, new StringEntity(request, ContentType.APPLICATION_JSON), format);
@@ -1201,25 +1198,17 @@ private void executeQueryWithNextPage(String format, String expectedHeader, Stri
12011198
expected.append("---------------+---------------+---------------\n");
12021199
}
12031200
}
1204-
expected.append(String.format(Locale.ROOT, expectedLineFormat, "text" + i, i, i + 5));
1205-
expected.append(String.format(Locale.ROOT, expectedLineFormat, "text" + (i + 1), i + 1, i + 6));
1201+
12061202
cursor = response.v2();
1207-
assertEquals(expected.toString(), response.v1());
1208-
assertNotNull(cursor);
1203+
if (i < 20) {
1204+
expected.append(String.format(Locale.ROOT, expectedLineFormat, "text" + i, i, i + 5));
1205+
expected.append(String.format(Locale.ROOT, expectedLineFormat, "text" + (i + 1), i + 1, i + 6));
1206+
assertEquals(expected.toString(), response.v1());
1207+
assertNotNull(cursor);
1208+
} else {
1209+
assertNull(cursor);
1210+
}
12091211
}
1210-
Map<String, Object> expected = new HashMap<>();
1211-
expected.put("rows", emptyList());
1212-
assertResponse(
1213-
expected,
1214-
runSql(new StringEntity(cursor(cursor).toString(), ContentType.APPLICATION_JSON), StringUtils.EMPTY, Mode.PLAIN.toString())
1215-
);
1216-
1217-
Map<String, Object> response = runSql(
1218-
new StringEntity(cursor(cursor).toString(), ContentType.APPLICATION_JSON),
1219-
"/close",
1220-
Mode.PLAIN.toString()
1221-
);
1222-
assertEquals(true, response.get("succeeded"));
12231212

12241213
assertEquals(0, getNumberOfSearchContexts(provisioningClient(), "test"));
12251214
}

0 commit comments

Comments
 (0)