diff --git a/jetcd-core/src/main/java/io/etcd/jetcd/Cluster.java b/jetcd-core/src/main/java/io/etcd/jetcd/Cluster.java index bd3f1662..3b9a1f98 100644 --- a/jetcd-core/src/main/java/io/etcd/jetcd/Cluster.java +++ b/jetcd-core/src/main/java/io/etcd/jetcd/Cluster.java @@ -22,6 +22,7 @@ import io.etcd.jetcd.cluster.MemberAddResponse; import io.etcd.jetcd.cluster.MemberListResponse; +import io.etcd.jetcd.cluster.MemberPromoteResponse; import io.etcd.jetcd.cluster.MemberRemoveResponse; import io.etcd.jetcd.cluster.MemberUpdateResponse; import io.etcd.jetcd.support.CloseableClient; @@ -72,4 +73,11 @@ public interface Cluster extends CloseableClient { */ CompletableFuture updateMember(long memberID, List peerAddrs); + /** + * Promotes a member from raft learner (non-voting) to raft voting member. + * + * @param memberID the raft learner to be promoted to a raft voting member. + * @return the response + */ + CompletableFuture promoteMember(long memberID); } diff --git a/jetcd-core/src/main/java/io/etcd/jetcd/cluster/MemberPromoteResponse.java b/jetcd-core/src/main/java/io/etcd/jetcd/cluster/MemberPromoteResponse.java new file mode 100644 index 00000000..8a914601 --- /dev/null +++ b/jetcd-core/src/main/java/io/etcd/jetcd/cluster/MemberPromoteResponse.java @@ -0,0 +1,48 @@ +/* + * Copyright 2025 The jetcd authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.etcd.jetcd.cluster; + +import java.util.List; + +import io.etcd.jetcd.Cluster; +import io.etcd.jetcd.impl.AbstractResponse; + +/** + * MemberPromoteResponse returned by {@link Cluster#promoteMember(long)} + * contains a header and a list of members after the member update. + */ +public class MemberPromoteResponse extends AbstractResponse { + + private List members; + + public MemberPromoteResponse(io.etcd.jetcd.api.MemberPromoteResponse response) { + super(response, response.getHeader()); + } + + /** + * Return a list of all members after promoting the member. + * + * @return the list of members. + */ + public synchronized List getMembers() { + if (members == null) { + members = Util.toMembers(getResponse().getMembersList()); + } + + return members; + } +} diff --git a/jetcd-core/src/main/java/io/etcd/jetcd/impl/ClusterImpl.java b/jetcd-core/src/main/java/io/etcd/jetcd/impl/ClusterImpl.java index 2d28212e..43fff6b8 100644 --- a/jetcd-core/src/main/java/io/etcd/jetcd/impl/ClusterImpl.java +++ b/jetcd-core/src/main/java/io/etcd/jetcd/impl/ClusterImpl.java @@ -24,11 +24,13 @@ import io.etcd.jetcd.Cluster; import io.etcd.jetcd.api.MemberAddRequest; import io.etcd.jetcd.api.MemberListRequest; +import io.etcd.jetcd.api.MemberPromoteRequest; import io.etcd.jetcd.api.MemberRemoveRequest; import io.etcd.jetcd.api.MemberUpdateRequest; import io.etcd.jetcd.api.VertxClusterGrpc; import io.etcd.jetcd.cluster.MemberAddResponse; import io.etcd.jetcd.cluster.MemberListResponse; +import io.etcd.jetcd.cluster.MemberPromoteResponse; import io.etcd.jetcd.cluster.MemberRemoveResponse; import io.etcd.jetcd.cluster.MemberUpdateResponse; @@ -116,4 +118,22 @@ public CompletableFuture updateMember(long memberID, List< this.stub.memberUpdate(memberUpdateRequest), MemberUpdateResponse::new); } + + /** + * Promotes a member from raft learner (non-voting) to raft voting member. + * + * @param memberID the raft learner to be promoted to a raft voting member + * @return the response + */ + @Override + public CompletableFuture promoteMember(long memberID) { + MemberPromoteRequest memberPromoteRequest = MemberPromoteRequest.newBuilder() + .setID(memberID) + .build(); + + return completable( + this.stub.memberPromote(memberPromoteRequest), + MemberPromoteResponse::new); + } + } diff --git a/jetcd-core/src/main/proto/rpc.proto b/jetcd-core/src/main/proto/rpc.proto index 6a67e600..2cde5260 100644 --- a/jetcd-core/src/main/proto/rpc.proto +++ b/jetcd-core/src/main/proto/rpc.proto @@ -99,6 +99,9 @@ service Cluster { // MemberList lists all the members in the cluster. rpc MemberList(MemberListRequest) returns (MemberListResponse) {} + + // MemberPromote promotes a member from raft learner (non-voting) to raft voting member. + rpc MemberPromote(MemberPromoteRequest) returns (MemberPromoteResponse) {} } service Maintenance { @@ -659,6 +662,17 @@ message MemberListResponse { repeated Member members = 2; } +message MemberPromoteRequest { + // ID is the member ID of the member to promote. + uint64 ID = 1; +} + +message MemberPromoteResponse { + ResponseHeader header = 1; + // members is a list of all members after promoting the member. + repeated Member members = 2; +} + message DefragmentRequest { } diff --git a/jetcd-core/src/test/java/io/etcd/jetcd/impl/ClusterMembersTest.java b/jetcd-core/src/test/java/io/etcd/jetcd/impl/ClusterMembersTest.java index 2d2e6de3..7b5d1751 100644 --- a/jetcd-core/src/test/java/io/etcd/jetcd/impl/ClusterMembersTest.java +++ b/jetcd-core/src/test/java/io/etcd/jetcd/impl/ClusterMembersTest.java @@ -18,6 +18,7 @@ import java.util.List; import java.util.concurrent.ExecutionException; +import java.util.concurrent.Future; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; @@ -29,9 +30,11 @@ import io.etcd.jetcd.Client; import io.etcd.jetcd.Cluster; import io.etcd.jetcd.cluster.Member; +import io.etcd.jetcd.cluster.MemberPromoteResponse; import io.etcd.jetcd.test.EtcdClusterExtension; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.AssertionsForClassTypes.assertThatExceptionOfType; @Timeout(value = 30, unit = TimeUnit.SECONDS) public class ClusterMembersTest { @@ -112,4 +115,23 @@ public void testMemberManagementAddLearner() throws ExecutionException, Interrup assertThat(members).hasSize(2); assertThat(members.stream().filter(Member::isLearner).findAny()).isPresent(); } + + @Test + public void testMemberManagementAddLearnerAndPromote() throws ExecutionException, InterruptedException, TimeoutException { + final Client client = Client.builder().endpoints(n1.clientEndpoints()).build(); + final Cluster clusterClient = client.getClusterClient(); + + Member m2 = clusterClient.addMember(n2.peerEndpoints(), true) + .get(5, TimeUnit.SECONDS) + .getMember(); + + assertThat(m2).isNotNull(); + assertThat(m2.isLearner()).isTrue(); + + // Now attempt to promote a member; although it fails, it confirms that the API was executed. + Future promoteResponseFuture = clusterClient.promoteMember(m2.getId()); + assertThatExceptionOfType(ExecutionException.class) + .isThrownBy(promoteResponseFuture::get).withMessageEndingWith( + "io.etcd.jetcd.common.exception.EtcdException: etcdserver: can only promote a learner member which is in sync with leader"); + } } diff --git a/jetcd-grpc/src/main/proto/rpc.proto b/jetcd-grpc/src/main/proto/rpc.proto index bf67359f..0726b6f9 100644 --- a/jetcd-grpc/src/main/proto/rpc.proto +++ b/jetcd-grpc/src/main/proto/rpc.proto @@ -99,6 +99,9 @@ service Cluster { // MemberList lists all the members in the cluster. rpc MemberList(MemberListRequest) returns (MemberListResponse) {} + + // MemberPromote promotes a member from raft learner (non-voting) to raft voting member. + rpc MemberPromote(MemberPromoteRequest) returns (MemberPromoteResponse) {} } service Maintenance { @@ -659,6 +662,17 @@ message MemberListResponse { repeated Member members = 2; } +message MemberPromoteRequest { + // ID is the member ID of the member to promote. + uint64 ID = 1; +} + +message MemberPromoteResponse { + ResponseHeader header = 1; + // members is a list of all members after promoting the member. + repeated Member members = 2; +} + message DefragmentRequest { }