Add 'state' query param to GET snapshots API (#128635)

This change introduces a new optional 'state' query parameter for the Get Snapshots API,
allowing users to filter snapshots by state.  The parameter accepts comma-separated
values for states: SUCCESS, IN_PROGRESS, FAILED, PARTIAL, INCOMPATIBLE (case-insensitive).

A new 'snapshots.get.state_parameter' NodeFeature has been added with this change.
The new state query parameter will only be supported in clusters where all nodes support
this feature.

---------

Co-authored-by: Elena Stoeva <elenastoeva99@gmail.com>
This commit is contained in:
Jeremy Dahlgren 2025-06-16 17:07:39 -04:00 committed by GitHub
parent 217275c229
commit d43198ea3e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 293 additions and 8 deletions

View File

@ -0,0 +1,6 @@
pr: 128635
summary: Add `state` query param to Get snapshots API
area: Snapshot/Restore
type: enhancement
issues:
- 97446

View File

@ -85,6 +85,10 @@
"verbose":{
"type":"boolean",
"description":"Whether to show verbose snapshot info or only show the basic info found in the repository index blob"
},
"state": {
"type": "list",
"description": "Filter snapshots by a comma-separated list of states. Valid state values are 'SUCCESS', 'IN_PROGRESS', 'FAILED', 'PARTIAL', or 'INCOMPATIBLE'."
}
}
}

View File

@ -303,3 +303,72 @@ setup:
snapshot.delete:
repository: test_repo_get_1
snapshot: test_snapshot_no_repo_name
---
"Get snapshot using state parameter":
- requires:
cluster_features: "snapshots.get.state_parameter"
test_runner_features: capabilities
capabilities:
- method: GET
path: /_snapshot/{repository}/{snapshot}
parameters: [ state ]
reason: "state parameter was introduced in 9.1"
- do:
indices.create:
index: test_index
body:
settings:
number_of_shards: 1
number_of_replicas: 0
- do:
snapshot.create:
repository: test_repo_get_1
snapshot: test_snapshot_with_state_param
wait_for_completion: true
- do:
snapshot.get:
repository: test_repo_get_1
snapshot: test_snapshot_with_state_param
state: SUCCESS
- is_true: snapshots
- match: { snapshots.0.snapshot: test_snapshot_with_state_param }
- match: { snapshots.0.state: SUCCESS }
- do:
snapshot.get:
repository: test_repo_get_1
snapshot: test_snapshot_with_state_param
state: SUCCESS,PARTIAL
- is_true: snapshots
- match: { snapshots.0.snapshot: test_snapshot_with_state_param }
- match: { snapshots.0.state: SUCCESS }
- do:
snapshot.get:
repository: test_repo_get_1
snapshot: test_snapshot_with_state_param
state: FAILED
- is_true: snapshots
- length: { snapshots: 0 }
- do:
catch: bad_request
snapshot.get:
repository: test_repo_get_1
snapshot: test_snapshot_with_state_param
state: FOO
- match: { error.type: "illegal_argument_exception" }
- match: { error.reason: "No enum constant org.elasticsearch.snapshots.SnapshotState.FOO" }
- do:
snapshot.delete:
repository: test_repo_get_1
snapshot: test_snapshot_with_state_param

View File

@ -55,13 +55,16 @@ import org.elasticsearch.xcontent.json.JsonXContent;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.EnumSet;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.function.Function;
import java.util.function.Predicate;
import java.util.stream.Collectors;
@ -635,6 +638,63 @@ public class GetSnapshotsIT extends AbstractSnapshotIntegTestCase {
expectThrows(RepositoryMissingException.class, multiRepoFuture::actionGet);
}
public void testFilterByState() throws Exception {
final String repoName = "test-repo";
final Path repoPath = randomRepoPath();
createRepository(repoName, "mock", repoPath);
// Create a successful snapshot
createFullSnapshot(repoName, "snapshot-success");
final Function<EnumSet<SnapshotState>, List<SnapshotInfo>> getSnapshotsForStates = (states) -> {
return clusterAdmin().prepareGetSnapshots(TEST_REQUEST_TIMEOUT, repoName).setStates(states).get().getSnapshots();
};
// Fetch snapshots with state=SUCCESS
var snapshots = getSnapshotsForStates.apply(EnumSet.of(SnapshotState.SUCCESS));
assertThat(snapshots, hasSize(1));
assertThat(snapshots.getFirst().state(), is(SnapshotState.SUCCESS));
// Create a snapshot in progress
blockAllDataNodes(repoName);
startFullSnapshot(repoName, "snapshot-in-progress");
awaitNumberOfSnapshotsInProgress(1);
// Fetch snapshots with state=IN_PROGRESS
snapshots = getSnapshotsForStates.apply(EnumSet.of(SnapshotState.IN_PROGRESS));
assertThat(snapshots, hasSize(1));
assertThat(snapshots.getFirst().state(), is(SnapshotState.IN_PROGRESS));
// Fetch snapshots with multiple states (SUCCESS, IN_PROGRESS)
snapshots = getSnapshotsForStates.apply(EnumSet.of(SnapshotState.SUCCESS, SnapshotState.IN_PROGRESS));
assertThat(snapshots, hasSize(2));
var states = snapshots.stream().map(SnapshotInfo::state).collect(Collectors.toSet());
assertTrue(states.contains(SnapshotState.SUCCESS));
assertTrue(states.contains(SnapshotState.IN_PROGRESS));
// Fetch all snapshots (without state)
snapshots = clusterAdmin().prepareGetSnapshots(TEST_REQUEST_TIMEOUT, repoName).get().getSnapshots();
assertThat(snapshots, hasSize(2));
// Fetch snapshots with an invalid state
IllegalArgumentException e = expectThrows(
IllegalArgumentException.class,
() -> getSnapshotsForStates.apply(EnumSet.of(SnapshotState.valueOf("FOO")))
);
assertThat(e.getMessage(), is("No enum constant org.elasticsearch.snapshots.SnapshotState.FOO"));
// Allow the IN_PROGRESS snapshot to finish, then verify GET using SUCCESS has results and IN_PROGRESS does not.
unblockAllDataNodes(repoName);
awaitNumberOfSnapshotsInProgress(0);
snapshots = clusterAdmin().prepareGetSnapshots(TEST_REQUEST_TIMEOUT, repoName).get().getSnapshots();
assertThat(snapshots, hasSize(2));
states = snapshots.stream().map(SnapshotInfo::state).collect(Collectors.toSet());
assertThat(states, hasSize(1));
assertTrue(states.contains(SnapshotState.SUCCESS));
snapshots = getSnapshotsForStates.apply(EnumSet.of(SnapshotState.IN_PROGRESS));
assertThat(snapshots, hasSize(0));
}
public void testRetrievingSnapshotsWhenRepositoryIsUnreadable() throws Exception {
final String repoName = randomIdentifier();
final Path repoPath = randomRepoPath();
@ -956,6 +1016,12 @@ public class GetSnapshotsIT extends AbstractSnapshotIntegTestCase {
// INDICES and by SHARDS. The actual sorting behaviour for these cases is tested elsewhere, here we're just checking that sorting
// interacts correctly with the other parameters to the API.
final EnumSet<SnapshotState> states = EnumSet.copyOf(randomNonEmptySubsetOf(Arrays.asList(SnapshotState.values())));
// Note: The selected state(s) may not match any existing snapshots.
// The actual filtering behaviour for such cases is tested in the dedicated test.
// Here we're just checking that states interacts correctly with the other parameters to the API.
snapshotInfoPredicate = snapshotInfoPredicate.and(si -> states.contains(si.state()));
// compute the ordered sequence of snapshots which match the repository/snapshot name filters and SLM policy filter
final var selectedSnapshots = snapshotInfos.stream()
.filter(snapshotInfoPredicate)
@ -967,7 +1033,8 @@ public class GetSnapshotsIT extends AbstractSnapshotIntegTestCase {
)
// apply sorting params
.sort(sortKey)
.order(order);
.order(order)
.states(states);
// sometimes use ?from_sort_value to skip some items; note that snapshots skipped in this way are subtracted from
// GetSnapshotsResponse.totalCount whereas snapshots skipped by ?after and ?offset are not
@ -1054,7 +1121,8 @@ public class GetSnapshotsIT extends AbstractSnapshotIntegTestCase {
.sort(sortKey)
.order(order)
.size(nextSize)
.after(SnapshotSortKey.decodeAfterQueryParam(nextRequestAfter));
.after(SnapshotSortKey.decodeAfterQueryParam(nextRequestAfter))
.states(states);
final GetSnapshotsResponse nextResponse = safeAwait(l -> client().execute(TransportGetSnapshotsAction.TYPE, nextRequest, l));
assertEquals(

View File

@ -425,6 +425,7 @@ module org.elasticsearch.server {
org.elasticsearch.action.bulk.BulkFeatures,
org.elasticsearch.features.InfrastructureFeatures,
org.elasticsearch.rest.action.admin.cluster.ClusterRerouteFeatures,
org.elasticsearch.rest.action.admin.cluster.GetSnapshotsFeatures,
org.elasticsearch.index.mapper.MapperFeatures,
org.elasticsearch.index.IndexFeatures,
org.elasticsearch.search.SearchFeatures,

View File

@ -299,6 +299,7 @@ public class TransportVersions {
public static final TransportVersion NONE_CHUNKING_STRATEGY = def(9_097_0_00);
public static final TransportVersion PROJECT_DELETION_GLOBAL_BLOCK = def(9_098_0_00);
public static final TransportVersion SECURITY_CLOUD_API_KEY_REALM_AND_TYPE = def(9_099_0_00);
public static final TransportVersion STATE_PARAM_GET_SNAPSHOT = def(9_100_0_00);
/*
* STOP! READ THIS FIRST! No, really,

View File

@ -864,7 +864,7 @@ public class ActionModule extends AbstractModule {
registerHandler.accept(new RestDeleteRepositoryAction());
registerHandler.accept(new RestVerifyRepositoryAction());
registerHandler.accept(new RestCleanupRepositoryAction());
registerHandler.accept(new RestGetSnapshotsAction());
registerHandler.accept(new RestGetSnapshotsAction(clusterSupportsFeature));
registerHandler.accept(new RestCreateSnapshotAction());
registerHandler.accept(new RestCloneSnapshotAction());
registerHandler.accept(new RestRestoreSnapshotAction());

View File

@ -19,13 +19,16 @@ import org.elasticsearch.common.io.stream.StreamOutput;
import org.elasticsearch.core.Nullable;
import org.elasticsearch.core.TimeValue;
import org.elasticsearch.search.sort.SortOrder;
import org.elasticsearch.snapshots.SnapshotState;
import org.elasticsearch.tasks.CancellableTask;
import org.elasticsearch.tasks.Task;
import org.elasticsearch.tasks.TaskId;
import java.io.IOException;
import java.util.Arrays;
import java.util.EnumSet;
import java.util.Map;
import java.util.Objects;
import static org.elasticsearch.action.ValidateActions.addValidationError;
@ -39,6 +42,7 @@ public class GetSnapshotsRequest extends MasterNodeRequest<GetSnapshotsRequest>
public static final boolean DEFAULT_VERBOSE_MODE = true;
private static final TransportVersion INDICES_FLAG_VERSION = TransportVersions.V_8_3_0;
private static final TransportVersion STATE_FLAG_VERSION = TransportVersions.STATE_PARAM_GET_SNAPSHOT;
public static final int NO_LIMIT = -1;
@ -77,6 +81,8 @@ public class GetSnapshotsRequest extends MasterNodeRequest<GetSnapshotsRequest>
private boolean includeIndexNames = true;
private EnumSet<SnapshotState> states = EnumSet.allOf(SnapshotState.class);
public GetSnapshotsRequest(TimeValue masterNodeTimeout) {
super(masterNodeTimeout);
}
@ -118,6 +124,11 @@ public class GetSnapshotsRequest extends MasterNodeRequest<GetSnapshotsRequest>
if (in.getTransportVersion().onOrAfter(INDICES_FLAG_VERSION)) {
includeIndexNames = in.readBoolean();
}
if (in.getTransportVersion().onOrAfter(STATE_FLAG_VERSION)) {
states = in.readEnumSet(SnapshotState.class);
} else {
states = EnumSet.allOf(SnapshotState.class);
}
}
@Override
@ -137,6 +148,13 @@ public class GetSnapshotsRequest extends MasterNodeRequest<GetSnapshotsRequest>
if (out.getTransportVersion().onOrAfter(INDICES_FLAG_VERSION)) {
out.writeBoolean(includeIndexNames);
}
if (out.getTransportVersion().onOrAfter(STATE_FLAG_VERSION)) {
out.writeEnumSet(states);
} else if (states.equals(EnumSet.allOf(SnapshotState.class)) == false) {
final var errorString = "GetSnapshotsRequest [states] field is not supported on all nodes in the cluster";
assert false : errorString;
throw new IllegalStateException(errorString);
}
}
@Override
@ -177,6 +195,9 @@ public class GetSnapshotsRequest extends MasterNodeRequest<GetSnapshotsRequest>
} else if (after != null && fromSortValue != null) {
validationException = addValidationError("can't use after and from_sort_value simultaneously", validationException);
}
if (states.isEmpty()) {
validationException = addValidationError("states is empty", validationException);
}
return validationException;
}
@ -342,6 +363,15 @@ public class GetSnapshotsRequest extends MasterNodeRequest<GetSnapshotsRequest>
return verbose;
}
public EnumSet<SnapshotState> states() {
return states;
}
public GetSnapshotsRequest states(EnumSet<SnapshotState> states) {
this.states = Objects.requireNonNull(states);
return this;
}
@Override
public Task createTask(long id, String type, String action, TaskId parentTaskId, Map<String, String> headers) {
return new CancellableTask(id, type, action, getDescription(), parentTaskId, headers);

View File

@ -15,6 +15,9 @@ import org.elasticsearch.common.util.ArrayUtils;
import org.elasticsearch.core.Nullable;
import org.elasticsearch.core.TimeValue;
import org.elasticsearch.search.sort.SortOrder;
import org.elasticsearch.snapshots.SnapshotState;
import java.util.EnumSet;
/**
* Get snapshots request builder
@ -150,4 +153,8 @@ public class GetSnapshotsRequestBuilder extends MasterNodeOperationRequestBuilde
}
public GetSnapshotsRequestBuilder setStates(EnumSet<SnapshotState> states) {
request.states(states);
return this;
}
}

View File

@ -46,6 +46,7 @@ import org.elasticsearch.snapshots.Snapshot;
import org.elasticsearch.snapshots.SnapshotId;
import org.elasticsearch.snapshots.SnapshotInfo;
import org.elasticsearch.snapshots.SnapshotMissingException;
import org.elasticsearch.snapshots.SnapshotState;
import org.elasticsearch.snapshots.SnapshotsService;
import org.elasticsearch.tasks.CancellableTask;
import org.elasticsearch.tasks.Task;
@ -55,6 +56,7 @@ import org.elasticsearch.transport.TransportService;
import java.util.ArrayList;
import java.util.Collections;
import java.util.EnumSet;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Iterator;
@ -161,7 +163,8 @@ public class TransportGetSnapshotsAction extends TransportMasterNodeAction<GetSn
request.size(),
SnapshotsInProgress.get(state),
request.verbose(),
request.includeIndexNames()
request.includeIndexNames(),
request.states()
).runOperation(listener);
}
@ -182,6 +185,7 @@ public class TransportGetSnapshotsAction extends TransportMasterNodeAction<GetSn
private final SnapshotNamePredicate snapshotNamePredicate;
private final SnapshotPredicates fromSortValuePredicates;
private final Predicate<String> slmPolicyPredicate;
private final EnumSet<SnapshotState> states;
// snapshot ordering/pagination
private final SnapshotSortKey sortBy;
@ -225,7 +229,8 @@ public class TransportGetSnapshotsAction extends TransportMasterNodeAction<GetSn
int size,
SnapshotsInProgress snapshotsInProgress,
boolean verbose,
boolean indices
boolean indices,
EnumSet<SnapshotState> states
) {
this.cancellableTask = cancellableTask;
this.repositories = repositories;
@ -238,6 +243,7 @@ public class TransportGetSnapshotsAction extends TransportMasterNodeAction<GetSn
this.snapshotsInProgress = snapshotsInProgress;
this.verbose = verbose;
this.indices = indices;
this.states = states;
this.snapshotNamePredicate = SnapshotNamePredicate.forSnapshots(ignoreUnavailable, snapshots);
this.fromSortValuePredicates = SnapshotPredicates.forFromSortValue(fromSortValue, sortBy, order);
@ -572,11 +578,16 @@ public class TransportGetSnapshotsAction extends TransportMasterNodeAction<GetSn
return false;
}
final var details = repositoryData.getSnapshotDetails(snapshotId);
if (details != null && details.getSnapshotState() != null && states.contains(details.getSnapshotState()) == false) {
return false;
}
if (slmPolicyPredicate == SlmPolicyPredicate.MATCH_ALL_POLICIES) {
return true;
}
final var details = repositoryData.getSnapshotDetails(snapshotId);
return details == null || details.getSlmPolicy() == null || slmPolicyPredicate.test(details.getSlmPolicy());
}
@ -585,6 +596,10 @@ public class TransportGetSnapshotsAction extends TransportMasterNodeAction<GetSn
return false;
}
if (snapshotInfo.state() != null && states.contains(snapshotInfo.state()) == false) {
return false;
}
if (slmPolicyPredicate == SlmPolicyPredicate.MATCH_ALL_POLICIES) {
return true;
}

View File

@ -154,6 +154,8 @@ public abstract class BaseRestHandler implements RestHandler {
supportedAndCommon.removeAll(RestRequest.INTERNAL_MARKER_REQUEST_PARAMETERS);
final var consumed = new TreeSet<>(request.consumedParams());
consumed.removeAll(RestRequest.INTERNAL_MARKER_REQUEST_PARAMETERS);
// Response parameters are implicitly consumed since they are made available to response renderings.
consumed.addAll(responseParams(request.getRestApiVersion()));
assert supportedAndCommon.equals(consumed)
: getName() + ": consumed params " + consumed + " while supporting " + supportedAndCommon;
}

View File

@ -0,0 +1,24 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
package org.elasticsearch.rest.action.admin.cluster;
import org.elasticsearch.features.FeatureSpecification;
import org.elasticsearch.features.NodeFeature;
import java.util.Set;
public class GetSnapshotsFeatures implements FeatureSpecification {
public static final NodeFeature GET_SNAPSHOTS_STATE_PARAMETER = new NodeFeature("snapshots.get.state_parameter");
@Override
public Set<NodeFeature> getFeatures() {
return Set.of(GET_SNAPSHOTS_STATE_PARAMETER);
}
}

View File

@ -13,17 +13,24 @@ import org.elasticsearch.action.admin.cluster.snapshots.get.GetSnapshotsRequest;
import org.elasticsearch.action.admin.cluster.snapshots.get.SnapshotSortKey;
import org.elasticsearch.client.internal.node.NodeClient;
import org.elasticsearch.common.Strings;
import org.elasticsearch.common.util.set.Sets;
import org.elasticsearch.features.NodeFeature;
import org.elasticsearch.rest.BaseRestHandler;
import org.elasticsearch.rest.RestRequest;
import org.elasticsearch.rest.RestUtils;
import org.elasticsearch.rest.Scope;
import org.elasticsearch.rest.ServerlessScope;
import org.elasticsearch.rest.action.RestCancellableNodeClient;
import org.elasticsearch.rest.action.RestRefCountedChunkedToXContentListener;
import org.elasticsearch.search.sort.SortOrder;
import org.elasticsearch.snapshots.SnapshotState;
import java.io.IOException;
import java.util.Arrays;
import java.util.EnumSet;
import java.util.List;
import java.util.Set;
import java.util.function.Predicate;
import static org.elasticsearch.rest.RestRequest.Method.GET;
import static org.elasticsearch.rest.RestUtils.getMasterNodeTimeout;
@ -37,7 +44,35 @@ import static org.elasticsearch.snapshots.SnapshotInfo.INDEX_NAMES_XCONTENT_PARA
@ServerlessScope(Scope.INTERNAL)
public class RestGetSnapshotsAction extends BaseRestHandler {
public RestGetSnapshotsAction() {}
private static final Set<String> SUPPORTED_RESPONSE_PARAMETERS = Set.of(
INCLUDE_REPOSITORY_XCONTENT_PARAM,
INDEX_DETAILS_XCONTENT_PARAM,
INDEX_NAMES_XCONTENT_PARAM
);
private static final Set<String> SUPPORTED_QUERY_PARAMETERS = Set.of(
RestUtils.REST_MASTER_TIMEOUT_PARAM,
"after",
"from_sort_value",
"ignore_unavailable",
"offset",
"order",
"size",
"slm_policy_filter",
"sort",
"state",
"verbose"
);
private static final Set<String> ALL_SUPPORTED_PARAMETERS = Set.copyOf(
Sets.union(SUPPORTED_QUERY_PARAMETERS, SUPPORTED_RESPONSE_PARAMETERS, Set.of("repository", "snapshot"))
);
private final Predicate<NodeFeature> clusterSupportsFeature;
public RestGetSnapshotsAction(Predicate<NodeFeature> clusterSupportsFeature) {
this.clusterSupportsFeature = clusterSupportsFeature;
}
@Override
public List<Route> routes() {
@ -51,7 +86,17 @@ public class RestGetSnapshotsAction extends BaseRestHandler {
@Override
protected Set<String> responseParams() {
return Set.of(INDEX_DETAILS_XCONTENT_PARAM, INCLUDE_REPOSITORY_XCONTENT_PARAM, INDEX_NAMES_XCONTENT_PARAM);
return SUPPORTED_RESPONSE_PARAMETERS;
}
@Override
public Set<String> supportedQueryParameters() {
return SUPPORTED_QUERY_PARAMETERS;
}
@Override
public Set<String> allSupportedParameters() {
return ALL_SUPPORTED_PARAMETERS;
}
@Override
@ -82,6 +127,18 @@ public class RestGetSnapshotsAction extends BaseRestHandler {
final SortOrder order = SortOrder.fromString(request.param("order", getSnapshotsRequest.order().toString()));
getSnapshotsRequest.order(order);
getSnapshotsRequest.includeIndexNames(request.paramAsBoolean(INDEX_NAMES_XCONTENT_PARAM, getSnapshotsRequest.includeIndexNames()));
final String stateString = request.param("state");
if (stateString == null) {
getSnapshotsRequest.states(EnumSet.allOf(SnapshotState.class));
} else if (Strings.hasText(stateString) == false) {
throw new IllegalArgumentException("[state] parameter must not be empty");
} else if (clusterSupportsFeature.test(GetSnapshotsFeatures.GET_SNAPSHOTS_STATE_PARAMETER)) {
getSnapshotsRequest.states(EnumSet.copyOf(Arrays.stream(stateString.split(",")).map(SnapshotState::valueOf).toList()));
} else {
throw new IllegalArgumentException("[state] parameter is not supported on all nodes in the cluster");
}
return channel -> new RestCancellableNodeClient(client, request.getHttpChannel()).admin()
.cluster()
.getSnapshots(getSnapshotsRequest, new RestRefCountedChunkedToXContentListener<>(channel));

View File

@ -10,6 +10,7 @@
org.elasticsearch.action.bulk.BulkFeatures
org.elasticsearch.features.InfrastructureFeatures
org.elasticsearch.rest.action.admin.cluster.ClusterRerouteFeatures
org.elasticsearch.rest.action.admin.cluster.GetSnapshotsFeatures
org.elasticsearch.index.IndexFeatures
org.elasticsearch.index.mapper.MapperFeatures
org.elasticsearch.search.SearchFeatures