Browse Source

Introduce Next Field in Paginated GetSnapshots Response (#74236)

Follow up to #73952 adding documentation for the `after` query parameter
and the related `next` response field.
Armin Braun 4 years ago
parent
commit
5f89f8be3f

+ 142 - 1
docs/reference/snapshot-restore/apis/get-snapshot-api.asciidoc

@@ -19,7 +19,9 @@ PUT /_snapshot/my_repository
 
 PUT /_snapshot/my_repository/my_snapshot?wait_for_completion=true
 
+PUT /_snapshot/my_repository/snapshot_1?wait_for_completion=true
 PUT /_snapshot/my_repository/snapshot_2?wait_for_completion=true
+PUT /_snapshot/my_repository/snapshot_3?wait_for_completion=true
 ----
 // TESTSETUP
 ////
@@ -128,7 +130,15 @@ Allows setting a sort order for the result. Defaults to `start_time`, i.e. sorti
 (Optional, string)
 Sort order. Valid values are `asc` for ascending and `desc` for descending order. Defaults to `asc`, meaning ascending order.
 
-NOTE: The pagination parameters `size`, `order`, and `sort` are not supported when using `verbose=false` and the sort order for
+`after`::
+(Optional, string)
+Offset identifier to start pagination from as returned by the `next` field in the response body.
+
+NOTE: The `after` parameter and `next` field allow for iterating through snapshots with some consistency guarantees regarding concurrent
+creation or deletion of snapshots. It is guaranteed that  any snapshot that exists at the beginning of the iteration and not concurrently
+deleted will be seen during the iteration. Snapshots concurrently created may be seen during an iteration.
+
+NOTE: The pagination parameters `size`, `order`, `after` and `sort` are not supported when using `verbose=false` and the sort order for
 requests with `verbose=false` is undefined.
 
 [role="child_attributes"]
@@ -268,6 +278,10 @@ The snapshot `state` can be one of the following values:
   that were not processed correctly.
 ====
 --
+`next`::
+(string)
+If the request contained a size limit and there might be more results, a `next` field will be added to the response and can be used as the
+`after` query parameter to fetch additional results.
 
 [[get-snapshot-api-example]]
 ==== {api-examples-title}
@@ -319,3 +333,130 @@ The API returns the following response:
 // TESTRESPONSE[s/"end_time": "2020-07-06T21:55:18.129Z"/"end_time": $body.snapshots.0.end_time/]
 // TESTRESPONSE[s/"end_time_in_millis": 1593094752018/"end_time_in_millis": $body.snapshots.0.end_time_in_millis/]
 // TESTRESPONSE[s/"duration_in_millis": 0/"duration_in_millis": $body.snapshots.0.duration_in_millis/]
+
+The following request returns information for all snapshots with prefix `snapshot` in the `my_repository` repository,
+limiting the response size to 2 and sorting by snapshot name.
+
+[source,console]
+----
+GET /_snapshot/my_repository/snapshot*?size=2&sort=name
+----
+
+The API returns the following response:
+
+[source,console-result]
+----
+{
+  "snapshots": [
+    {
+      "snapshot": "snapshot_1",
+      "uuid": "dKb54xw67gvdRctLCxSket",
+      "repository": "my_repository",
+      "version_id": <version_id>,
+      "version": <version>,
+      "indices": [],
+      "data_streams": [],
+      "feature_states": [],
+      "include_global_state": true,
+      "state": "SUCCESS",
+      "start_time": "2020-07-06T21:55:18.129Z",
+      "start_time_in_millis": 1593093628850,
+      "end_time": "2020-07-06T21:55:18.129Z",
+      "end_time_in_millis": 1593094752018,
+      "duration_in_millis": 0,
+      "failures": [],
+      "shards": {
+        "total": 0,
+        "failed": 0,
+        "successful": 0
+      }
+    },
+    {
+      "snapshot": "snapshot_2",
+      "uuid": "vdRctLCxSketdKb54xw67g",
+      "repository": "my_repository",
+      "version_id": <version_id>,
+      "version": <version>,
+      "indices": [],
+      "data_streams": [],
+      "feature_states": [],
+      "include_global_state": true,
+      "state": "SUCCESS",
+      "start_time": "2020-07-06T21:55:18.130Z",
+      "start_time_in_millis": 1593093628851,
+      "end_time": "2020-07-06T21:55:18.130Z",
+      "end_time_in_millis": 1593094752019,
+      "duration_in_millis": 1,
+      "failures": [],
+      "shards": {
+        "total": 0,
+        "failed": 0,
+        "successful": 0
+      }
+    }
+  ],
+  "next": "c25hcHNob3RfMixteV9yZXBvc2l0b3J5LHNuYXBzaG90XzI="
+}
+----
+// TESTRESPONSE[s/"uuid": "dKb54xw67gvdRctLCxSket"/"uuid": $body.snapshots.0.uuid/]
+// TESTRESPONSE[s/"uuid": "vdRctLCxSketdKb54xw67g"/"uuid": $body.snapshots.1.uuid/]
+// TESTRESPONSE[s/"version_id": <version_id>/"version_id": $body.snapshots.0.version_id/]
+// TESTRESPONSE[s/"version": <version>/"version": $body.snapshots.0.version/]
+// TESTRESPONSE[s/"start_time": "2020-07-06T21:55:18.129Z"/"start_time": $body.snapshots.0.start_time/]
+// TESTRESPONSE[s/"start_time": "2020-07-06T21:55:18.130Z"/"start_time": $body.snapshots.1.start_time/]
+// TESTRESPONSE[s/"start_time_in_millis": 1593093628850/"start_time_in_millis": $body.snapshots.0.start_time_in_millis/]
+// TESTRESPONSE[s/"start_time_in_millis": 1593093628851/"start_time_in_millis": $body.snapshots.1.start_time_in_millis/]
+// TESTRESPONSE[s/"end_time": "2020-07-06T21:55:18.129Z"/"end_time": $body.snapshots.0.end_time/]
+// TESTRESPONSE[s/"end_time": "2020-07-06T21:55:18.130Z"/"end_time": $body.snapshots.1.end_time/]
+// TESTRESPONSE[s/"end_time_in_millis": 1593094752018/"end_time_in_millis": $body.snapshots.0.end_time_in_millis/]
+// TESTRESPONSE[s/"end_time_in_millis": 1593094752019/"end_time_in_millis": $body.snapshots.1.end_time_in_millis/]
+// TESTRESPONSE[s/"duration_in_millis": 0/"duration_in_millis": $body.snapshots.0.duration_in_millis/]
+// TESTRESPONSE[s/"duration_in_millis": 1/"duration_in_millis": $body.snapshots.1.duration_in_millis/]
+
+A subsequent request for the remaining snapshots can then be made using the `next` value from the previous response as `after` parameter.
+
+[source,console]
+----
+GET /_snapshot/my_repository/snapshot*?size=2&sort=name&after=c25hcHNob3RfMixteV9yZXBvc2l0b3J5LHNuYXBzaG90XzI=
+----
+
+The API returns the following response:
+
+[source,console-result]
+----
+{
+  "snapshots": [
+    {
+      "snapshot": "snapshot_3",
+      "uuid": "dRctdKb54xw67gvLCxSket",
+      "repository": "my_repository",
+      "version_id": <version_id>,
+      "version": <version>,
+      "indices": [],
+      "data_streams": [],
+      "feature_states": [],
+      "include_global_state": true,
+      "state": "SUCCESS",
+      "start_time": "2020-07-06T21:55:18.129Z",
+      "start_time_in_millis": 1593093628850,
+      "end_time": "2020-07-06T21:55:18.129Z",
+      "end_time_in_millis": 1593094752018,
+      "duration_in_millis": 0,
+      "failures": [],
+      "shards": {
+        "total": 0,
+        "failed": 0,
+        "successful": 0
+      }
+    }
+  ]
+}
+----
+// TESTRESPONSE[s/"uuid": "dRctdKb54xw67gvLCxSket"/"uuid": $body.snapshots.0.uuid/]
+// TESTRESPONSE[s/"version_id": <version_id>/"version_id": $body.snapshots.0.version_id/]
+// TESTRESPONSE[s/"version": <version>/"version": $body.snapshots.0.version/]
+// TESTRESPONSE[s/"start_time": "2020-07-06T21:55:18.129Z"/"start_time": $body.snapshots.0.start_time/]
+// TESTRESPONSE[s/"start_time_in_millis": 1593093628850/"start_time_in_millis": $body.snapshots.0.start_time_in_millis/]
+// TESTRESPONSE[s/"end_time": "2020-07-06T21:55:18.129Z"/"end_time": $body.snapshots.0.end_time/]
+// TESTRESPONSE[s/"end_time_in_millis": 1593094752018/"end_time_in_millis": $body.snapshots.0.end_time_in_millis/]
+// TESTRESPONSE[s/"duration_in_millis": 0/"duration_in_millis": $body.snapshots.0.duration_in_millis/]

+ 35 - 42
qa/smoke-test-http/src/test/java/org/elasticsearch/http/snapshots/RestGetSnapshotsIT.java

@@ -20,6 +20,7 @@ import org.elasticsearch.common.xcontent.DeprecationHandler;
 import org.elasticsearch.common.xcontent.NamedXContentRegistry;
 import org.elasticsearch.common.xcontent.XContentParser;
 import org.elasticsearch.common.xcontent.json.JsonXContent;
+import org.elasticsearch.core.Tuple;
 import org.elasticsearch.search.sort.SortOrder;
 import org.elasticsearch.snapshots.AbstractSnapshotIntegTestCase;
 import org.elasticsearch.snapshots.SnapshotInfo;
@@ -64,8 +65,7 @@ public class RestGetSnapshotsIT extends AbstractSnapshotRestTestCase {
     }
 
     private void doTestSortOrder(String repoName, Collection<String> allSnapshotNames, SortOrder order) throws IOException {
-        final List<SnapshotInfo> defaultSorting =
-            clusterAdmin().prepareGetSnapshots(repoName).setOrder(order).get().getSnapshots();
+        final List<SnapshotInfo> defaultSorting = clusterAdmin().prepareGetSnapshots(repoName).setOrder(order).get().getSnapshots();
         assertSnapshotListSorted(defaultSorting, null, order);
         assertSnapshotListSorted(
                 allSnapshotsSorted(allSnapshotNames, repoName, GetSnapshotsRequest.SortBy.NAME, order),
@@ -106,24 +106,31 @@ public class RestGetSnapshotsIT extends AbstractSnapshotRestTestCase {
                                   GetSnapshotsRequest.SortBy sort,
                                   SortOrder order) throws IOException {
         final List<SnapshotInfo> allSnapshotsSorted = allSnapshotsSorted(names, repoName, sort, order);
-        final List<SnapshotInfo> batch1 = sortedWithLimit(repoName, sort, 2, order);
-        assertEquals(batch1, allSnapshotsSorted.subList(0, 2));
-        final List<SnapshotInfo> batch2 = sortedWithLimit(repoName, sort, batch1.get(1), 2, order);
-        assertEquals(batch2, allSnapshotsSorted.subList(2, 4));
-        final int lastBatch = names.size() - batch1.size() - batch2.size();
-        final List<SnapshotInfo> batch3 = sortedWithLimit(repoName, sort, batch2.get(1), lastBatch, order);
-        assertEquals(batch3, allSnapshotsSorted.subList(batch1.size() + batch2.size(), names.size()));
-        final List<SnapshotInfo> batch3NoLimit =
-            sortedWithLimit(repoName, sort, batch2.get(1), GetSnapshotsRequest.NO_LIMIT, order);
-        assertEquals(batch3, batch3NoLimit);
-        final List<SnapshotInfo> batch3LargeLimit = sortedWithLimit(
+        final Tuple<String, List<SnapshotInfo>> batch1 = sortedWithLimit(repoName, sort, null, 2, order);
+        assertEquals(allSnapshotsSorted.subList(0, 2), batch1.v2());
+        final Tuple<String, List<SnapshotInfo>> batch2 = sortedWithLimit(repoName, sort, batch1.v1(), 2, order);
+        assertEquals(allSnapshotsSorted.subList(2, 4), batch2.v2());
+        final int lastBatch = names.size() - batch1.v2().size() - batch2.v2().size();
+        final Tuple<String, List<SnapshotInfo>> batch3 = sortedWithLimit(repoName, sort, batch2.v1(), lastBatch, order);
+        assertEquals(batch3.v2(), allSnapshotsSorted.subList(batch1.v2().size() + batch2.v2().size(), names.size()));
+        final Tuple<String, List<SnapshotInfo>> batch3NoLimit = sortedWithLimit(
                 repoName,
                 sort,
-                batch2.get(1),
+                batch2.v1(),
+                GetSnapshotsRequest.NO_LIMIT,
+                order
+        );
+        assertNull(batch3NoLimit.v1());
+        assertEquals(batch3.v2(), batch3NoLimit.v2());
+        final Tuple<String, List<SnapshotInfo>> batch3LargeLimit = sortedWithLimit(
+                repoName,
+                sort,
+                batch2.v1(),
                 lastBatch + randomIntBetween(1, 100),
                 order
         );
-        assertEquals(batch3, batch3LargeLimit);
+        assertEquals(batch3.v2(), batch3LargeLimit.v2());
+        assertNull(batch3LargeLimit.v1());
     }
 
     public void testSortAndPaginateWithInProgress() throws Exception {
@@ -173,14 +180,15 @@ public class RestGetSnapshotsIT extends AbstractSnapshotRestTestCase {
         final List<SnapshotInfo> allSorted = allSnapshotsSorted(allSnapshotNames, repoName, sort, order);
 
         for (int i = 1; i <= allSnapshotNames.size(); i++) {
-            final List<SnapshotInfo> subsetSorted = sortedWithLimit(repoName, sort, i, order);
+            final List<SnapshotInfo> subsetSorted = sortedWithLimit(repoName, sort, null, i, order).v2();
             assertEquals(subsetSorted, allSorted.subList(0, i));
         }
 
         for (int j = 0; j < allSnapshotNames.size(); j++) {
             final SnapshotInfo after = allSorted.get(j);
             for (int i = 1; i < allSnapshotNames.size() - j; i++) {
-                final List<SnapshotInfo> subsetSorted = sortedWithLimit(repoName, sort, after, i, order);
+                final List<SnapshotInfo> subsetSorted = sortedWithLimit(
+                        repoName, sort, GetSnapshotsRequest.After.from(after, sort).asQueryParam(), i, order).v2();
                 assertEquals(subsetSorted, allSorted.subList(j + 1, j + i + 1));
             }
         }
@@ -196,7 +204,7 @@ public class RestGetSnapshotsIT extends AbstractSnapshotRestTestCase {
             request.addParameter("order", order.toString());
         }
         final Response response = getRestClient().performRequest(request);
-        final List<SnapshotInfo> snapshotInfos = readSnapshotInfos(response);
+        final List<SnapshotInfo> snapshotInfos = readSnapshotInfos(response).v2();
         assertEquals(snapshotInfos.size(), allSnapshotNames.size());
         for (SnapshotInfo snapshotInfo : snapshotInfos) {
             assertThat(snapshotInfo.snapshotId().getName(), is(in(allSnapshotNames)));
@@ -208,42 +216,27 @@ public class RestGetSnapshotsIT extends AbstractSnapshotRestTestCase {
         return new Request(HttpGet.METHOD_NAME, "/_snapshot/" + repoName + "/*");
     }
 
-    private static List<SnapshotInfo> sortedWithLimit(String repoName,
-                                                      GetSnapshotsRequest.SortBy sortBy,
-                                                      int size,
-                                                      SortOrder order) throws IOException {
-        final Request request = baseGetSnapshotsRequest(repoName);
-        request.addParameter("sort", sortBy.toString());
-        if (order == SortOrder.DESC || randomBoolean()) {
-            request.addParameter("order", order.toString());
-        }
-        request.addParameter("size", String.valueOf(size));
-        final Response response = getRestClient().performRequest(request);
-        return readSnapshotInfos(response);
-    }
-
-    private static List<SnapshotInfo> readSnapshotInfos(Response response) throws IOException {
-        final List<SnapshotInfo> snapshotInfos;
+    private static Tuple<String, List<SnapshotInfo>> readSnapshotInfos(Response response) throws IOException {
         try (InputStream input = response.getEntity().getContent();
              XContentParser parser = JsonXContent.jsonXContent.createParser(
                      NamedXContentRegistry.EMPTY, DeprecationHandler.THROW_UNSUPPORTED_OPERATION, input)) {
-            snapshotInfos = GetSnapshotsResponse.fromXContent(parser).getSnapshots();
+            final GetSnapshotsResponse getSnapshotsResponse = GetSnapshotsResponse.fromXContent(parser);
+            return Tuple.tuple(getSnapshotsResponse.next(), getSnapshotsResponse.getSnapshots());
         }
-        return snapshotInfos;
     }
 
-    private static List<SnapshotInfo> sortedWithLimit(String repoName,
-                                                      GetSnapshotsRequest.SortBy sortBy,
-                                                      SnapshotInfo after,
-                                                      int size,
-                                                      SortOrder order) throws IOException {
+    private static Tuple<String, List<SnapshotInfo>> sortedWithLimit(String repoName,
+                                                                     GetSnapshotsRequest.SortBy sortBy,
+                                                                     String after,
+                                                                     int size,
+                                                                     SortOrder order) throws IOException {
         final Request request = baseGetSnapshotsRequest(repoName);
         request.addParameter("sort", sortBy.toString());
         if (size != GetSnapshotsRequest.NO_LIMIT || randomBoolean()) {
             request.addParameter("size", String.valueOf(size));
         }
         if (after != null) {
-            request.addParameter("after", GetSnapshotsRequest.After.from(after, sortBy).value() + "," + after.snapshotId().getName());
+            request.addParameter("after", after);
         }
         if (order == SortOrder.DESC || randomBoolean()) {
             request.addParameter("order", order.toString());

+ 40 - 19
server/src/internalClusterTest/java/org/elasticsearch/snapshots/GetSnapshotsIT.java

@@ -13,7 +13,9 @@ import org.elasticsearch.action.ActionRequestValidationException;
 import org.elasticsearch.action.admin.cluster.snapshots.create.CreateSnapshotResponse;
 import org.elasticsearch.action.admin.cluster.snapshots.get.GetSnapshotsRequest;
 import org.elasticsearch.action.admin.cluster.snapshots.get.GetSnapshotsRequestBuilder;
+import org.elasticsearch.action.admin.cluster.snapshots.get.GetSnapshotsResponse;
 import org.elasticsearch.common.settings.Settings;
+import org.elasticsearch.core.Tuple;
 import org.elasticsearch.search.sort.SortOrder;
 import org.elasticsearch.threadpool.ThreadPool;
 
@@ -95,23 +97,31 @@ public class GetSnapshotsIT extends AbstractSnapshotIntegTestCase {
 
     private void doTestPagination(String repoName, List<String> names, GetSnapshotsRequest.SortBy sort, SortOrder order) {
         final List<SnapshotInfo> allSnapshotsSorted = allSnapshotsSorted(names, repoName, sort, order);
-        final List<SnapshotInfo> batch1 = sortedWithLimit(repoName, sort, null, 2, order);
-        assertEquals(batch1, allSnapshotsSorted.subList(0, 2));
-        final List<SnapshotInfo> batch2 = sortedWithLimit(repoName, sort, batch1.get(1), 2, order);
-        assertEquals(batch2, allSnapshotsSorted.subList(2, 4));
-        final int lastBatch = names.size() - batch1.size() - batch2.size();
-        final List<SnapshotInfo> batch3 = sortedWithLimit(repoName, sort, batch2.get(1), lastBatch, order);
-        assertEquals(batch3, allSnapshotsSorted.subList(batch1.size() + batch2.size(), names.size()));
-        final List<SnapshotInfo> batch3NoLimit = sortedWithLimit(repoName, sort, batch2.get(1), GetSnapshotsRequest.NO_LIMIT, order);
-        assertEquals(batch3, batch3NoLimit);
-        final List<SnapshotInfo> batch3LargeLimit = sortedWithLimit(
+        final Tuple<String, List<SnapshotInfo>> batch1 = sortedWithLimit(repoName, sort, null, 2, order);
+        assertEquals(allSnapshotsSorted.subList(0, 2), batch1.v2());
+        final Tuple<String, List<SnapshotInfo>> batch2 = sortedWithLimit(repoName, sort, batch1.v1(), 2, order);
+        assertEquals(allSnapshotsSorted.subList(2, 4), batch2.v2());
+        final int lastBatch = names.size() - batch1.v2().size() - batch2.v2().size();
+        final Tuple<String, List<SnapshotInfo>> batch3 = sortedWithLimit(repoName, sort, batch2.v1(), lastBatch, order);
+        assertEquals(batch3.v2(), allSnapshotsSorted.subList(batch1.v2().size() + batch2.v2().size(), names.size()));
+        final Tuple<String, List<SnapshotInfo>> batch3NoLimit = sortedWithLimit(
             repoName,
             sort,
-            batch2.get(1),
+            batch2.v1(),
+            GetSnapshotsRequest.NO_LIMIT,
+            order
+        );
+        assertNull(batch3NoLimit.v1());
+        assertEquals(batch3.v2(), batch3NoLimit.v2());
+        final Tuple<String, List<SnapshotInfo>> batch3LargeLimit = sortedWithLimit(
+            repoName,
+            sort,
+            batch2.v1(),
             lastBatch + randomIntBetween(1, 100),
             order
         );
-        assertEquals(batch3, batch3LargeLimit);
+        assertEquals(batch3.v2(), batch3LargeLimit.v2());
+        assertNull(batch3LargeLimit.v1());
     }
 
     public void testSortAndPaginateWithInProgress() throws Exception {
@@ -177,14 +187,20 @@ public class GetSnapshotsIT extends AbstractSnapshotIntegTestCase {
         final List<SnapshotInfo> allSorted = allSnapshotsSorted(allSnapshotNames, repoName, sort, order);
 
         for (int i = 1; i <= allSnapshotNames.size(); i++) {
-            final List<SnapshotInfo> subsetSorted = sortedWithLimit(repoName, sort, null, i, order);
-            assertEquals(subsetSorted, allSorted.subList(0, i));
+            final Tuple<String, List<SnapshotInfo>> subsetSorted = sortedWithLimit(repoName, sort, null, i, order);
+            assertEquals(allSorted.subList(0, i), subsetSorted.v2());
         }
 
         for (int j = 0; j < allSnapshotNames.size(); j++) {
             final SnapshotInfo after = allSorted.get(j);
             for (int i = 1; i < allSnapshotNames.size() - j; i++) {
-                final List<SnapshotInfo> subsetSorted = sortedWithLimit(repoName, sort, after, i, order);
+                final List<SnapshotInfo> subsetSorted = sortedWithLimit(
+                    repoName,
+                    sort,
+                    GetSnapshotsRequest.After.from(after, sort).asQueryParam(),
+                    i,
+                    order
+                ).v2();
                 assertEquals(subsetSorted, allSorted.subList(j + 1, j + i + 1));
             }
         }
@@ -196,7 +212,7 @@ public class GetSnapshotsIT extends AbstractSnapshotIntegTestCase {
         GetSnapshotsRequest.SortBy sortBy,
         SortOrder order
     ) {
-        final List<SnapshotInfo> snapshotInfos = sortedWithLimit(repoName, sortBy, null, GetSnapshotsRequest.NO_LIMIT, order);
+        final List<SnapshotInfo> snapshotInfos = sortedWithLimit(repoName, sortBy, null, GetSnapshotsRequest.NO_LIMIT, order).v2();
         assertEquals(snapshotInfos.size(), allSnapshotNames.size());
         for (SnapshotInfo snapshotInfo : snapshotInfos) {
             assertThat(snapshotInfo.snapshotId().getName(), is(in(allSnapshotNames)));
@@ -204,14 +220,19 @@ public class GetSnapshotsIT extends AbstractSnapshotIntegTestCase {
         return snapshotInfos;
     }
 
-    private static List<SnapshotInfo> sortedWithLimit(
+    private static Tuple<String, List<SnapshotInfo>> sortedWithLimit(
         String repoName,
         GetSnapshotsRequest.SortBy sortBy,
-        SnapshotInfo after,
+        String after,
         int size,
         SortOrder order
     ) {
-        return baseGetSnapshotsRequest(repoName).setAfter(after, sortBy).setSize(size).setOrder(order).get().getSnapshots();
+        final GetSnapshotsResponse response = baseGetSnapshotsRequest(repoName).setAfter(after)
+            .setSort(sortBy)
+            .setSize(size)
+            .setOrder(order)
+            .get();
+        return Tuple.tuple(response.next(), response.getSnapshots());
     }
 
     private static GetSnapshotsRequestBuilder baseGetSnapshotsRequest(String repoName) {

+ 25 - 3
server/src/main/java/org/elasticsearch/action/admin/cluster/snapshots/get/GetSnapshotsRequest.java

@@ -24,6 +24,8 @@ import org.elasticsearch.tasks.Task;
 import org.elasticsearch.tasks.TaskId;
 
 import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.util.Base64;
 import java.util.Map;
 
 import static org.elasticsearch.action.ValidateActions.addValidationError;
@@ -319,10 +321,20 @@ public class GetSnapshotsRequest extends MasterNodeRequest<GetSnapshotsRequest>
 
         private final String value;
 
+        private final String repoName;
+
         private final String snapshotName;
 
         After(StreamInput in) throws IOException {
-            this(in.readString(), in.readString());
+            this(in.readString(), in.readString(), in.readString());
+        }
+
+        public static After fromQueryParam(String param) {
+            final String[] parts = new String(Base64.getUrlDecoder().decode(param), StandardCharsets.UTF_8).split(",");
+            if (parts.length != 3) {
+                throw new IllegalArgumentException("invalid ?after parameter [" + param + "]");
+            }
+            return new After(parts[0], parts[1], parts[2]);
         }
 
         @Nullable
@@ -347,11 +359,12 @@ public class GetSnapshotsRequest extends MasterNodeRequest<GetSnapshotsRequest>
                 default:
                     throw new AssertionError("unknown sort column [" + sortBy + "]");
             }
-            return new After(afterValue, snapshotInfo.snapshotId().getName());
+            return new After(afterValue, snapshotInfo.repository(), snapshotInfo.snapshotId().getName());
         }
 
-        public After(String value, String snapshotName) {
+        public After(String value, String repoName, String snapshotName) {
             this.value = value;
+            this.repoName = repoName;
             this.snapshotName = snapshotName;
         }
 
@@ -363,9 +376,18 @@ public class GetSnapshotsRequest extends MasterNodeRequest<GetSnapshotsRequest>
             return snapshotName;
         }
 
+        public String repoName() {
+            return repoName;
+        }
+
+        public String asQueryParam() {
+            return Base64.getUrlEncoder().encodeToString((value + "," + repoName + "," + snapshotName).getBytes(StandardCharsets.UTF_8));
+        }
+
         @Override
         public void writeTo(StreamOutput out) throws IOException {
             out.writeString(value);
+            out.writeString(repoName);
             out.writeString(snapshotName);
         }
     }

+ 2 - 3
server/src/main/java/org/elasticsearch/action/admin/cluster/snapshots/get/GetSnapshotsRequestBuilder.java

@@ -13,7 +13,6 @@ import org.elasticsearch.client.ElasticsearchClient;
 import org.elasticsearch.common.util.ArrayUtils;
 import org.elasticsearch.core.Nullable;
 import org.elasticsearch.search.sort.SortOrder;
-import org.elasticsearch.snapshots.SnapshotInfo;
 
 /**
  * Get snapshots request builder
@@ -98,8 +97,8 @@ public class GetSnapshotsRequestBuilder extends MasterNodeOperationRequestBuilde
         return this;
     }
 
-    public GetSnapshotsRequestBuilder setAfter(@Nullable SnapshotInfo after, GetSnapshotsRequest.SortBy sortBy) {
-        return setAfter(GetSnapshotsRequest.After.from(after, sortBy)).setSort(sortBy);
+    public GetSnapshotsRequestBuilder setAfter(String after) {
+        return setAfter(after == null ? null : GetSnapshotsRequest.After.fromQueryParam(after));
     }
 
     public GetSnapshotsRequestBuilder setAfter(@Nullable GetSnapshotsRequest.After after) {

+ 21 - 4
server/src/main/java/org/elasticsearch/action/admin/cluster/snapshots/get/GetSnapshotsResponse.java

@@ -19,6 +19,7 @@ import org.elasticsearch.common.xcontent.ToXContent;
 import org.elasticsearch.common.xcontent.ToXContentObject;
 import org.elasticsearch.common.xcontent.XContentBuilder;
 import org.elasticsearch.common.xcontent.XContentParser;
+import org.elasticsearch.core.Nullable;
 import org.elasticsearch.snapshots.SnapshotInfo;
 
 import java.io.IOException;
@@ -37,7 +38,7 @@ public class GetSnapshotsResponse extends ActionResponse implements ToXContentOb
     private static final ConstructingObjectParser<GetSnapshotsResponse, Void> GET_SNAPSHOT_PARSER = new ConstructingObjectParser<>(
         GetSnapshotsResponse.class.getName(),
         true,
-        (args) -> new GetSnapshotsResponse((List<SnapshotInfo>) args[0], (Map<String, ElasticsearchException>) args[1])
+        (args) -> new GetSnapshotsResponse((List<SnapshotInfo>) args[0], (Map<String, ElasticsearchException>) args[1], (String) args[2])
     );
 
     static {
@@ -51,15 +52,20 @@ public class GetSnapshotsResponse extends ActionResponse implements ToXContentOb
             (p, c) -> p.map(HashMap::new, ElasticsearchException::fromXContent),
             new ParseField("failures")
         );
+        GET_SNAPSHOT_PARSER.declareStringOrNull(ConstructingObjectParser.optionalConstructorArg(), new ParseField("next"));
     }
 
     private final List<SnapshotInfo> snapshots;
 
     private final Map<String, ElasticsearchException> failures;
 
-    public GetSnapshotsResponse(List<SnapshotInfo> snapshots, Map<String, ElasticsearchException> failures) {
+    @Nullable
+    private final String next;
+
+    public GetSnapshotsResponse(List<SnapshotInfo> snapshots, Map<String, ElasticsearchException> failures, @Nullable String next) {
         this.snapshots = List.copyOf(snapshots);
         this.failures = failures == null ? Map.of() : Map.copyOf(failures);
+        this.next = next;
     }
 
     public GetSnapshotsResponse(StreamInput in) throws IOException {
@@ -67,8 +73,10 @@ public class GetSnapshotsResponse extends ActionResponse implements ToXContentOb
         if (in.getVersion().onOrAfter(GetSnapshotsRequest.MULTIPLE_REPOSITORIES_SUPPORT_ADDED)) {
             final Map<String, ElasticsearchException> failedResponses = in.readMap(StreamInput::readString, StreamInput::readException);
             this.failures = Collections.unmodifiableMap(failedResponses);
+            this.next = in.readOptionalString();
         } else {
             this.failures = Collections.emptyMap();
+            this.next = null;
         }
     }
 
@@ -88,6 +96,11 @@ public class GetSnapshotsResponse extends ActionResponse implements ToXContentOb
         return failures;
     }
 
+    @Nullable
+    public String next() {
+        return next;
+    }
+
     /**
      * Returns true if there is a least one failed response.
      */
@@ -100,6 +113,7 @@ public class GetSnapshotsResponse extends ActionResponse implements ToXContentOb
         out.writeList(snapshots);
         if (out.getVersion().onOrAfter(GetSnapshotsRequest.MULTIPLE_REPOSITORIES_SUPPORT_ADDED)) {
             out.writeMap(failures, StreamOutput::writeString, StreamOutput::writeException);
+            out.writeOptionalString(next);
         } else {
             if (failures.isEmpty() == false) {
                 assert false : "transport action should have thrown directly for old version but saw " + failures;
@@ -128,6 +142,9 @@ public class GetSnapshotsResponse extends ActionResponse implements ToXContentOb
             }
             builder.endObject();
         }
+        if (next != null) {
+            builder.field("next", next);
+        }
         builder.endObject();
         return builder;
     }
@@ -141,12 +158,12 @@ public class GetSnapshotsResponse extends ActionResponse implements ToXContentOb
         if (this == o) return true;
         if (o == null || getClass() != o.getClass()) return false;
         GetSnapshotsResponse that = (GetSnapshotsResponse) o;
-        return Objects.equals(snapshots, that.snapshots) && Objects.equals(failures, that.failures);
+        return Objects.equals(snapshots, that.snapshots) && Objects.equals(failures, that.failures) && Objects.equals(next, that.next);
     }
 
     @Override
     public int hashCode() {
-        return Objects.hash(snapshots, failures);
+        return Objects.hash(snapshots, failures, next);
     }
 
     @Override

+ 67 - 24
server/src/main/java/org/elasticsearch/action/admin/cluster/snapshots/get/TransportGetSnapshotsAction.java

@@ -140,22 +140,30 @@ public class TransportGetSnapshotsAction extends TransportMasterNodeAction<GetSn
     ) {
         // short-circuit if there are no repos, because we can not create GroupedActionListener of size 0
         if (repos.isEmpty()) {
-            listener.onResponse(new GetSnapshotsResponse(Collections.emptyList(), Collections.emptyMap()));
+            listener.onResponse(new GetSnapshotsResponse(Collections.emptyList(), Collections.emptyMap(), null));
             return;
         }
-        final GroupedActionListener<Tuple<Tuple<String, ElasticsearchException>, List<SnapshotInfo>>> groupedActionListener =
+        final GroupedActionListener<Tuple<Tuple<String, ElasticsearchException>, SnapshotsInRepo>> groupedActionListener =
             new GroupedActionListener<>(listener.map(responses -> {
                 assert repos.size() == responses.size();
                 final List<SnapshotInfo> allSnapshots = responses.stream()
                     .map(Tuple::v2)
                     .filter(Objects::nonNull)
-                    .flatMap(Collection::stream)
+                    .flatMap(snapshotsInRepo -> snapshotsInRepo.snapshotInfos.stream())
                     .collect(Collectors.toUnmodifiableList());
                 final Map<String, ElasticsearchException> failures = responses.stream()
                     .map(Tuple::v1)
                     .filter(Objects::nonNull)
                     .collect(Collectors.toMap(Tuple::v1, Tuple::v2));
-                return new GetSnapshotsResponse(sortSnapshots(allSnapshots, sortBy, after, size, order), failures);
+                final SnapshotsInRepo snInfos = sortSnapshots(allSnapshots, sortBy, after, size, order);
+                final List<SnapshotInfo> snapshotInfos = snInfos.snapshotInfos;
+                return new GetSnapshotsResponse(
+                    snapshotInfos,
+                    failures,
+                    snInfos.hasMore || responses.stream().anyMatch(r -> r.v2() != null && r.v2().hasMore)
+                        ? GetSnapshotsRequest.After.from(snapshotInfos.get(snapshotInfos.size() - 1), sortBy).asQueryParam()
+                        : null
+                );
             }), repos.size());
 
         for (final RepositoryMetadata repo : repos) {
@@ -193,11 +201,11 @@ public class TransportGetSnapshotsAction extends TransportMasterNodeAction<GetSn
         @Nullable final GetSnapshotsRequest.After after,
         int size,
         SortOrder order,
-        ActionListener<List<SnapshotInfo>> listener
+        ActionListener<SnapshotsInRepo> listener
     ) {
         final Map<String, Snapshot> allSnapshotIds = new HashMap<>();
         final List<SnapshotInfo> currentSnapshots = new ArrayList<>();
-        for (SnapshotInfo snapshotInfo : sortedCurrentSnapshots(snapshotsInProgress, repo, sortBy, after, size, order)) {
+        for (SnapshotInfo snapshotInfo : sortedCurrentSnapshots(snapshotsInProgress, repo, sortBy, after, size, order).snapshotInfos) {
             Snapshot snapshot = snapshotInfo.snapshot();
             allSnapshotIds.put(snapshot.getSnapshotId().getName(), snapshot);
             currentSnapshots.add(snapshotInfo);
@@ -238,7 +246,7 @@ public class TransportGetSnapshotsAction extends TransportMasterNodeAction<GetSn
      * @param repositoryName repository name
      * @return list of snapshots
      */
-    private static List<SnapshotInfo> sortedCurrentSnapshots(
+    private static SnapshotsInRepo sortedCurrentSnapshots(
         SnapshotsInProgress snapshotsInProgress,
         String repositoryName,
         GetSnapshotsRequest.SortBy sortBy,
@@ -272,7 +280,7 @@ public class TransportGetSnapshotsAction extends TransportMasterNodeAction<GetSn
         @Nullable final GetSnapshotsRequest.After after,
         int size,
         SortOrder order,
-        ActionListener<List<SnapshotInfo>> listener
+        ActionListener<SnapshotsInRepo> listener
     ) {
         if (task.isCancelled()) {
             listener.onFailure(new TaskCancelledException("task cancelled"));
@@ -326,7 +334,7 @@ public class TransportGetSnapshotsAction extends TransportMasterNodeAction<GetSn
                 listener
             );
         } else {
-            final List<SnapshotInfo> snapshotInfos;
+            final SnapshotsInRepo snapshotInfos;
             if (repositoryData != null) {
                 // want non-current snapshots as well, which are found in the repository data
                 snapshotInfos = buildSimpleSnapshotInfos(toResolve, repo, repositoryData, currentSnapshots, sortBy, after, size, order);
@@ -361,7 +369,7 @@ public class TransportGetSnapshotsAction extends TransportMasterNodeAction<GetSn
         @Nullable GetSnapshotsRequest.After after,
         int size,
         SortOrder order,
-        ActionListener<List<SnapshotInfo>> listener
+        ActionListener<SnapshotsInRepo> listener
     ) {
         if (task.isCancelled()) {
             listener.onFailure(new TaskCancelledException("task cancelled"));
@@ -433,7 +441,7 @@ public class TransportGetSnapshotsAction extends TransportMasterNodeAction<GetSn
         return (snapshots.length == 1 && GetSnapshotsRequest.CURRENT_SNAPSHOT.equalsIgnoreCase(snapshots[0]));
     }
 
-    private static List<SnapshotInfo> buildSimpleSnapshotInfos(
+    private static SnapshotsInRepo buildSimpleSnapshotInfos(
         final Set<Snapshot> toResolve,
         final String repoName,
         final RepositoryData repositoryData,
@@ -485,7 +493,7 @@ public class TransportGetSnapshotsAction extends TransportMasterNodeAction<GetSn
 
     private static final Comparator<SnapshotInfo> BY_NAME = Comparator.comparing(sni -> sni.snapshotId().getName());
 
-    private static List<SnapshotInfo> sortSnapshots(
+    private static SnapshotsInRepo sortSnapshots(
         List<SnapshotInfo> snapshotInfos,
         GetSnapshotsRequest.SortBy sortBy,
         @Nullable GetSnapshotsRequest.After after,
@@ -514,19 +522,34 @@ public class TransportGetSnapshotsAction extends TransportMasterNodeAction<GetSn
 
         if (after != null) {
             final Predicate<SnapshotInfo> isAfter;
-            final String name = after.snapshotName();
+            final String snapshotName = after.snapshotName();
+            final String repoName = after.repoName();
             switch (sortBy) {
                 case START_TIME:
-                    isAfter = filterByLongOffset(SnapshotInfo::startTime, Long.parseLong(after.value()), name, order);
+                    isAfter = filterByLongOffset(SnapshotInfo::startTime, Long.parseLong(after.value()), snapshotName, repoName, order);
                     break;
                 case NAME:
-                    isAfter = order == SortOrder.ASC ? (info -> compareName(name, info) < 0) : (info -> compareName(name, info) > 0);
+                    isAfter = order == SortOrder.ASC
+                        ? (info -> compareName(snapshotName, repoName, info) < 0)
+                        : (info -> compareName(snapshotName, repoName, info) > 0);
                     break;
                 case DURATION:
-                    isAfter = filterByLongOffset(info -> info.endTime() - info.startTime(), Long.parseLong(after.value()), name, order);
+                    isAfter = filterByLongOffset(
+                        info -> info.endTime() - info.startTime(),
+                        Long.parseLong(after.value()),
+                        snapshotName,
+                        repoName,
+                        order
+                    );
                     break;
                 case INDICES:
-                    isAfter = filterByLongOffset(info -> info.indices().size(), Integer.parseInt(after.value()), name, order);
+                    isAfter = filterByLongOffset(
+                        info -> info.indices().size(),
+                        Integer.parseInt(after.value()),
+                        snapshotName,
+                        repoName,
+                        order
+                    );
                     break;
                 default:
                     throw new AssertionError("unexpected sort column [" + sortBy + "]");
@@ -535,27 +558,47 @@ public class TransportGetSnapshotsAction extends TransportMasterNodeAction<GetSn
         }
         infos = infos.sorted(order == SortOrder.DESC ? comparator.reversed() : comparator);
         if (size != GetSnapshotsRequest.NO_LIMIT) {
-            infos = infos.limit(size);
+            infos = infos.limit(size + 1);
         }
-        return infos.collect(Collectors.toUnmodifiableList());
+        final List<SnapshotInfo> snapshots = infos.collect(Collectors.toUnmodifiableList());
+        boolean hasMore = size != GetSnapshotsRequest.NO_LIMIT && size < snapshots.size();
+        return new SnapshotsInRepo(hasMore ? snapshots.subList(0, size) : snapshots, hasMore);
     }
 
     private static Predicate<SnapshotInfo> filterByLongOffset(
         ToLongFunction<SnapshotInfo> extractor,
         long after,
-        String name,
+        String snapshotName,
+        String repoName,
         SortOrder order
     ) {
         return order == SortOrder.ASC ? info -> {
             final long val = extractor.applyAsLong(info);
-            return after < val || (after == val && compareName(name, info) < 0);
+
+            return after < val || (after == val && compareName(snapshotName, repoName, info) < 0);
         } : info -> {
             final long val = extractor.applyAsLong(info);
-            return after > val || (after == val && compareName(name, info) > 0);
+            return after > val || (after == val && compareName(snapshotName, repoName, info) > 0);
         };
     }
 
-    private static int compareName(String name, SnapshotInfo info) {
-        return name.compareTo(info.snapshotId().getName());
+    private static int compareName(String name, String repoName, SnapshotInfo info) {
+        final int res = name.compareTo(info.snapshotId().getName());
+        if (res != 0) {
+            return res;
+        }
+        return repoName.compareTo(info.repository());
+    }
+
+    private static final class SnapshotsInRepo {
+
+        private final boolean hasMore;
+
+        private final List<SnapshotInfo> snapshotInfos;
+
+        SnapshotsInRepo(List<SnapshotInfo> snapshotInfos, boolean hasMore) {
+            this.hasMore = hasMore;
+            this.snapshotInfos = snapshotInfos;
+        }
     }
 }

+ 3 - 10
server/src/main/java/org/elasticsearch/rest/action/admin/cluster/RestGetSnapshotsAction.java

@@ -58,17 +58,10 @@ public class RestGetSnapshotsAction extends BaseRestHandler {
         getSnapshotsRequest.sort(sort);
         final int size = request.paramAsInt("size", getSnapshotsRequest.size());
         getSnapshotsRequest.size(size);
-        final String[] afterString = request.paramAsStringArray("after", Strings.EMPTY_ARRAY);
-        final GetSnapshotsRequest.After after;
-        if (afterString.length == 0) {
-            after = null;
-        } else if (afterString.length == 2) {
-            after = new GetSnapshotsRequest.After(afterString[0], afterString[1]);
-        } else {
-            throw new IllegalArgumentException("illegal ?after value [" + Strings.arrayToCommaDelimitedString(afterString) +
-                    "] must be of the form '${sort_value},${snapshot_name}'");
+        final String afterString = request.param("after");
+        if (afterString != null) {
+            getSnapshotsRequest.after(GetSnapshotsRequest.After.fromQueryParam(afterString));
         }
-        getSnapshotsRequest.after(after);
         final SortOrder order = SortOrder.fromString(request.param("order", getSnapshotsRequest.order().toString()));
         getSnapshotsRequest.order(order);
         getSnapshotsRequest.masterNodeTimeout(request.paramAsTime("master_timeout", getSnapshotsRequest.masterNodeTimeout()));

+ 1 - 1
server/src/test/java/org/elasticsearch/action/admin/cluster/snapshots/get/GetSnapshotsRequestTests.java

@@ -45,7 +45,7 @@ public class GetSnapshotsRequestTests extends ESTestCase {
         }
         {
             final GetSnapshotsRequest request = new GetSnapshotsRequest("repo", "snapshot").verbose(false)
-                .after(new GetSnapshotsRequest.After("foo", "bar"));
+                .after(new GetSnapshotsRequest.After("foo", "repo", "bar"));
             final ActionRequestValidationException e = request.validate();
             assertThat(e.getMessage(), containsString("can't use after with verbose=false"));
         }

+ 14 - 1
server/src/test/java/org/elasticsearch/action/admin/cluster/snapshots/get/GetSnapshotsResponseTests.java

@@ -26,8 +26,10 @@ import org.elasticsearch.snapshots.SnapshotShardFailure;
 import org.elasticsearch.test.ESTestCase;
 
 import java.io.IOException;
+import java.nio.charset.StandardCharsets;
 import java.util.ArrayList;
 import java.util.Arrays;
+import java.util.Base64;
 import java.util.Collections;
 import java.util.HashMap;
 import java.util.HashSet;
@@ -65,6 +67,7 @@ public class GetSnapshotsResponseTests extends ESTestCase {
 
     private void assertEqualInstances(GetSnapshotsResponse expectedInstance, GetSnapshotsResponse newInstance) {
         assertEquals(expectedInstance.getSnapshots(), newInstance.getSnapshots());
+        assertEquals(expectedInstance.next(), newInstance.next());
         assertEquals(expectedInstance.getFailures().keySet(), newInstance.getFailures().keySet());
         for (Map.Entry<String, ElasticsearchException> expectedEntry : expectedInstance.getFailures().entrySet()) {
             ElasticsearchException expectedException = expectedEntry.getValue();
@@ -119,7 +122,17 @@ public class GetSnapshotsResponseTests extends ESTestCase {
             failures.put(repository, new ElasticsearchException(randomAlphaOfLength(10)));
         }
 
-        return new GetSnapshotsResponse(responses, failures);
+        return new GetSnapshotsResponse(
+            responses,
+            failures,
+            randomBoolean()
+                ? Base64.getUrlEncoder()
+                    .encodeToString(
+                        (randomAlphaOfLengthBetween(1, 5) + "," + randomAlphaOfLengthBetween(1, 5) + "," + randomAlphaOfLengthBetween(1, 5))
+                            .getBytes(StandardCharsets.UTF_8)
+                    )
+                : null
+        );
     }
 
     public void testSerialization() throws IOException {

+ 1 - 1
x-pack/plugin/ilm/src/test/java/org/elasticsearch/xpack/slm/SnapshotRetentionTaskTests.java

@@ -316,7 +316,7 @@ public class SnapshotRetentionTaskTests extends ESTestCase {
                  void doExecute(ActionType<Response> action, Request request, ActionListener<Response> listener) {
                      if (request instanceof GetSnapshotsRequest) {
                          logger.info("--> called");
-                         listener.onResponse((Response) new GetSnapshotsResponse(Collections.emptyList(), Collections.emptyMap()));
+                         listener.onResponse((Response) new GetSnapshotsResponse(Collections.emptyList(), Collections.emptyMap(), null));
                      } else {
                          super.doExecute(action, request, listener);
                      }