kevin-wu24 commented on code in PR #22531:
URL: https://github.com/apache/kafka/pull/22531#discussion_r4361088980


##########
clients/src/main/java/org/apache/kafka/common/requests/AddRaftVoterRequest.java:
##########
@@ -34,6 +36,12 @@ public Builder(AddRaftVoterRequestData data) {
 
         @Override
         public AddRaftVoterRequest build(short version) {
+            if (version < 2 && data.voterDirectoryId().equals(Uuid.ZERO_UUID)) 
{
+                throw new UnsupportedVersionException("Version 2 or later is 
required to omit voterDirectoryId");
+            }
+            if (version < 2 && data.listeners().isEmpty()) {
+                throw new UnsupportedVersionException("Version 2 or later is 
required to omit listeners");
+            }

Review Comment:
   These exception messages are a bit confusing. Especially since it does not 
appear it is required to omit these fields from the code in `KafkaRaftClient`, 
and the KIP. How about doing something similar to how 
`CreateTopicsRequest#build` does validation:
   
   ```
   public CreateTopicsRequest build(short version) {
               if (data.validateOnly() && version == 0)
                   throw new UnsupportedProtocolFieldException("validateOnly", 
apiKey().name(), version, 1);
   ```
   So for this code something like:
   
   ```suggestion
               if (version < 2 && 
data.voterDirectoryId().equals(Uuid.ZERO_UUID)) {
                   throw new UnsupportedProtocolFieldException("A non-zero 
voterDirectoryId is required in AddRaftVoter version 0 or 1. Upgrade the 
cluster to support AddRaftVoter version 2 to support a zero voterDirectoryId.");
               }
               if (version < 2 && data.listeners().isEmpty()) {
                   throw new UnsupportedProtocolFieldException("A non-empty 
listeners required in AddRaftVoter version 0 or 1. Upgrade the cluster to 
support AddRaftVoter version 2 to support a zero voterDirectoryId.");
               }
   ```
   
   I see that we do this for `AddRaftVoterRequest#build`, but not for 
`RemoveRaftVoterRequest#build`, can we be consistent? I see that other requests 
like create topics and fetch have these build time validations, so I am not 
opposed to add a validation here for v0 and v1. 



##########
raft/src/main/java/org/apache/kafka/raft/KafkaRaftClient.java:
##########
@@ -2278,39 +2283,84 @@ private CompletionStage<AddRaftVoterResponseData> 
handleAddVoterRequest(
             );
         }
 
-        Optional<ReplicaKey> newVoter = RaftUtil.addVoterRequestVoterKey(data);
-        if (newVoter.isEmpty() || newVoter.get().directoryId().isEmpty()) {
-            return completedFuture(
-                new AddRaftVoterResponseData()
-                    .setErrorCode(Errors.INVALID_REQUEST.code())
-                    .setErrorMessage("Add voter request didn't include a valid 
voter")
-            );
-        }
-
-        Endpoints newVoterEndpoints = 
Endpoints.fromAddVoterRequest(data.listeners());
-        if (newVoterEndpoints.address(channel.listenerName()).isEmpty()) {
-            return completedFuture(
-                new AddRaftVoterResponseData()
-                    .setErrorCode(Errors.INVALID_REQUEST.code())
-                    .setErrorMessage(
-                        String.format(
-                            "Add voter request didn't include the endpoint 
(%s) for the default listener %s",
-                            newVoterEndpoints,
-                            channel.listenerName()
-                        )
-                    )
-            );
+        final ReplicaKey newVoter;
+        final Endpoints newVoterEndpoints;
+        try {
+            newVoter = resolveAddVoterKey(data, requestMetadata.apiVersion(), 
currentTimeMs);
+            newVoterEndpoints = resolveAddVoterEndpoints(data, 
requestMetadata.apiVersion());
+        } catch (IllegalArgumentException e) {
+            return 
completedFuture(RaftUtil.addVoterResponse(Errors.INVALID_REQUEST, 
e.getMessage()));
         }
 
         return addVoterHandler.handleAddVoterRequest(
             quorum.leaderStateOrThrow(),
-            newVoter.get(),
+            newVoter,
             newVoterEndpoints,
             data.ackWhenCommitted(),
             currentTimeMs
         );
     }
 
+    private ReplicaKey resolveAddVoterKey(

Review Comment:
   It would be good to document this behavior for the below cases in the PR 
description. I see it in the KIP: 
https://cwiki.apache.org/confluence/spaces/KAFKA/pages/340037949/KIP-1141+Simplifying+Add+Remove+Voter+in+MetadataQuorumCommand#KIP1141%3ASimplifyingAdd%2FRemoveVoterinMetadataQuorumCommand-Serversidechanges.
   
   - AddRaftVoter v2 and the `voterDirectoryId` is specified, but the 
`endpoints` is not.
   - AddRaftVoter v2 and the `endpoints` is specified, but the 
`voterDirectoryId` is not.
   - AddRaftVoter v2 and both `endpoints` and `voterDirectoryId` are specified.
   - RemoveRaftVoter v1 and `voterDirectoryId` is specified.
   
   From reading the code here, it seems like if these fields are specified in 
the request, that takes precedence in AddRaftVoter v2 and RemoveRaftVoter v1, 
and only if they are empty/not present, do we default on what the 
`nodeProvider` says. This behavior seems reasonable to me, but we should 
document it because it is not clear without reading the code.



##########
clients/src/testFixtures/java/org/apache/kafka/clients/admin/MockAdminClient.java:
##########
@@ -1451,11 +1451,25 @@ public ListClientMetricsResourcesResult 
listClientMetricsResources(ListClientMet
     }
 
     @Override
+    public AddRaftVoterResult addRaftVoter(int voterId, AddRaftVoterOptions 
options) {
+        throw new UnsupportedOperationException("Not implemented yet");
+    }
+
+    @Override
+    @SuppressWarnings("removal")
+    @Deprecated(since = "4.4", forRemoval = true)

Review Comment:
   I think this needs to be updated to 4.5.



##########
raft/src/main/java/org/apache/kafka/raft/KafkaRaftClient.java:
##########
@@ -2278,39 +2283,84 @@ private CompletionStage<AddRaftVoterResponseData> 
handleAddVoterRequest(
             );
         }
 
-        Optional<ReplicaKey> newVoter = RaftUtil.addVoterRequestVoterKey(data);
-        if (newVoter.isEmpty() || newVoter.get().directoryId().isEmpty()) {
-            return completedFuture(
-                new AddRaftVoterResponseData()
-                    .setErrorCode(Errors.INVALID_REQUEST.code())
-                    .setErrorMessage("Add voter request didn't include a valid 
voter")
-            );
-        }
-
-        Endpoints newVoterEndpoints = 
Endpoints.fromAddVoterRequest(data.listeners());
-        if (newVoterEndpoints.address(channel.listenerName()).isEmpty()) {
-            return completedFuture(
-                new AddRaftVoterResponseData()
-                    .setErrorCode(Errors.INVALID_REQUEST.code())
-                    .setErrorMessage(
-                        String.format(
-                            "Add voter request didn't include the endpoint 
(%s) for the default listener %s",
-                            newVoterEndpoints,
-                            channel.listenerName()
-                        )
-                    )
-            );
+        final ReplicaKey newVoter;
+        final Endpoints newVoterEndpoints;
+        try {
+            newVoter = resolveAddVoterKey(data, requestMetadata.apiVersion(), 
currentTimeMs);
+            newVoterEndpoints = resolveAddVoterEndpoints(data, 
requestMetadata.apiVersion());
+        } catch (IllegalArgumentException e) {
+            return 
completedFuture(RaftUtil.addVoterResponse(Errors.INVALID_REQUEST, 
e.getMessage()));
         }
 
         return addVoterHandler.handleAddVoterRequest(
             quorum.leaderStateOrThrow(),
-            newVoter.get(),
+            newVoter,
             newVoterEndpoints,
             data.ackWhenCommitted(),
             currentTimeMs
         );
     }
 
+    private ReplicaKey resolveAddVoterKey(
+        AddRaftVoterRequestData data,
+        short apiVersion,
+        long currentTimeMs
+    ) {
+        if (apiVersion >= 2 && data.voterDirectoryId().equals(Uuid.ZERO_UUID)) 
{
+            List<ReplicaKey> matchingObservers = quorum.leaderStateOrThrow()
+                .observerStates(currentTimeMs)
+                .keySet()
+                .stream()
+                .filter(key -> key.id() == data.voterId())
+                .toList();
+            if (matchingObservers.size() > 1) {
+                throw new IllegalArgumentException(String.format(
+                    "Multiple observers with node ID %d were found: %s. " +
+                        "This can happen if the node restarted with a new log 
directory while a previous " +
+                        "observer remained inactive. Remove stale or inactive 
observer entries so that " +

Review Comment:
   Also, it would be nice if we could add a unit test case for this scenario, 
mainly to ensure the active controller does not remain in a state where it 
cannot add this node's voter id when there are multiple matching observers. 
   
   Here is what the test could look like at a high level:
   
   - The test would set up the local node as the leader. 
   - Deliver a fetch from the "old" node incarnation.
   - Deliver a fetch from the "new" node incarnation. This represents the old 
node getting its log replaced and re-fetching.
   - Try `AddRaftVoter` v2. This should fail with this exception.
   - Advance time to expire the observer session of the old node.
   - Try `AddRaftVoter` v2. Now the request succeeds.



##########
clients/src/test/java/org/apache/kafka/common/requests/RequestResponseTest.java:
##########
@@ -4213,4 +4214,42 @@ public void 
testStreamsGroupDescribeResponseV0RejectsTopologyDescriptionFields()
         StreamsGroupDescribeResponse response = new 
StreamsGroupDescribeResponse(data);
         assertThrows(UnsupportedVersionException.class, () -> 
response.serialize((short) 0));
     }
+
+    @Test
+    public void testAddRaftVoterRequestBeforeV2RejectsZeroUUID() {
+        AddRaftVoterRequest.Builder builder = new AddRaftVoterRequest.Builder(
+            new AddRaftVoterRequestData()
+                .setClusterId("cluster-id")
+                .setTimeoutMs(1000)
+                .setVoterId(1)
+                .setVoterDirectoryId(Uuid.ZERO_UUID)
+                .setListeners(new AddRaftVoterRequestData.ListenerCollection())
+        );
+
+        String errorMsg = "Version 2 or later is required to omit 
voterDirectoryId";

Review Comment:
   This test needs to be updated after my other suggestion. It may be easier to 
just check the exception class type.



##########
raft/src/main/java/org/apache/kafka/raft/KafkaRaftClient.java:
##########
@@ -2278,39 +2283,84 @@ private CompletionStage<AddRaftVoterResponseData> 
handleAddVoterRequest(
             );
         }
 
-        Optional<ReplicaKey> newVoter = RaftUtil.addVoterRequestVoterKey(data);
-        if (newVoter.isEmpty() || newVoter.get().directoryId().isEmpty()) {
-            return completedFuture(
-                new AddRaftVoterResponseData()
-                    .setErrorCode(Errors.INVALID_REQUEST.code())
-                    .setErrorMessage("Add voter request didn't include a valid 
voter")
-            );
-        }
-
-        Endpoints newVoterEndpoints = 
Endpoints.fromAddVoterRequest(data.listeners());
-        if (newVoterEndpoints.address(channel.listenerName()).isEmpty()) {
-            return completedFuture(
-                new AddRaftVoterResponseData()
-                    .setErrorCode(Errors.INVALID_REQUEST.code())
-                    .setErrorMessage(
-                        String.format(
-                            "Add voter request didn't include the endpoint 
(%s) for the default listener %s",
-                            newVoterEndpoints,
-                            channel.listenerName()
-                        )
-                    )
-            );
+        final ReplicaKey newVoter;
+        final Endpoints newVoterEndpoints;
+        try {
+            newVoter = resolveAddVoterKey(data, requestMetadata.apiVersion(), 
currentTimeMs);
+            newVoterEndpoints = resolveAddVoterEndpoints(data, 
requestMetadata.apiVersion());
+        } catch (IllegalArgumentException e) {
+            return 
completedFuture(RaftUtil.addVoterResponse(Errors.INVALID_REQUEST, 
e.getMessage()));
         }
 
         return addVoterHandler.handleAddVoterRequest(
             quorum.leaderStateOrThrow(),
-            newVoter.get(),
+            newVoter,
             newVoterEndpoints,
             data.ackWhenCommitted(),
             currentTimeMs
         );
     }
 
+    private ReplicaKey resolveAddVoterKey(
+        AddRaftVoterRequestData data,
+        short apiVersion,
+        long currentTimeMs
+    ) {
+        if (apiVersion >= 2 && data.voterDirectoryId().equals(Uuid.ZERO_UUID)) 
{
+            List<ReplicaKey> matchingObservers = quorum.leaderStateOrThrow()
+                .observerStates(currentTimeMs)
+                .keySet()
+                .stream()
+                .filter(key -> key.id() == data.voterId())
+                .toList();
+            if (matchingObservers.size() > 1) {
+                throw new IllegalArgumentException(String.format(
+                    "Multiple observers with node ID %d were found: %s. " +
+                        "This can happen if the node restarted with a new log 
directory while a previous " +
+                        "observer remained inactive. Remove stale or inactive 
observer entries so that " +

Review Comment:
   ```suggestion
                           "observer remained active. Remove stale or inactive 
observer entries so that " +
   ```
   Maybe it is worth printing for how long the old incarnation of the node 
remains an active observer by printing:
   ```
   OBSERVER_SESSION_TIMEOUT_MS - (currentTimeMs - 
integerReplicaStateEntry.getValue().lastFetchTimestamp)
   ```
   Also, there is no way to remove the observer entry in the leader state. The 
only way to clear this is to wait for the observer session to expire or the 
leader to change and the new node to fetch from it (assuming the old 
incarnation does not fetch to the new leader too).



##########
server/src/test/java/org/apache/kafka/server/BootstrapControllersIntegrationTest.java:
##########
@@ -205,6 +212,119 @@ private void testDescribeMetadataQuorum(ClusterInstance 
clusterInstance, boolean
         }
     }
 
+    @ClusterTest(controllers = 3, standalone = true)

Review Comment:
   More of a high level question, but why not put these tests in 
`ReconfigurableQuorumIntegrationTest`? That file seems more appropriate to me. 
It seems the purpose of these tests are to test that the 
`--bootstrap-controllers` flags works for CLIs. Meaning, that when clients send 
requests to the brokers, forwarding them to the controller vs sending the 
request to the controller directly should not change the behavior of the 
request.
   
   I think the `kafka-metadata-quorum` CLI is already covered by the 
`testDescribeMetadataQuorum` test above right?



##########
server/src/test/java/org/apache/kafka/server/ReconfigurableQuorumIntegrationTest.java:
##########
@@ -109,6 +109,7 @@ private static Map<Integer, Uuid> findVoterDirs(Admin 
admin) throws Exception {
     }
 
     @Test
+    @SuppressWarnings("removal")

Review Comment:
   Why do we need this annotation? Is it because it is using a deprecated 
method? Can we instead make the call to `admin.remove/addRaftVoter` use the 
updated methods?



##########
raft/src/main/java/org/apache/kafka/raft/KafkaRaftClient.java:
##########
@@ -2435,22 +2485,38 @@ private CompletionStage<RemoveRaftVoterResponseData> 
handleRemoveVoterRequest(
             );
         }
 
-        Optional<ReplicaKey> oldVoter = 
RaftUtil.removeVoterRequestVoterKey(data);
-        if (oldVoter.isEmpty() || oldVoter.get().directoryId().isEmpty()) {
-            return completedFuture(
-                new RemoveRaftVoterResponseData()
-                    .setErrorCode(Errors.INVALID_REQUEST.code())
-                    .setErrorMessage("Remove voter request didn't include a 
valid voter")
-            );
+        final ReplicaKey oldVoter;
+        try {
+            oldVoter = resolveRemoveVoterKey(data, 
requestMetadata.apiVersion());
+        } catch (IllegalArgumentException e) {
+            return 
completedFuture(RaftUtil.removeVoterResponse(Errors.INVALID_REQUEST, 
e.getMessage()));
         }
 
         return removeVoterHandler.handleRemoveVoterRequest(
             quorum.leaderStateOrThrow(),
-            oldVoter.get(),
+            oldVoter,
             currentTimeMs
         );
     }
 
+    private ReplicaKey resolveRemoveVoterKey(RemoveRaftVoterRequestData data, 
short apiVersion) {
+        if (apiVersion >= 1 && data.voterDirectoryId().equals(Uuid.ZERO_UUID)) 
{
+            return partitionState.lastVoterSet()
+                .voterKey(data.voterId())
+                .filter(voterKey -> voterKey.directoryId().isPresent())
+                .orElseThrow(() -> new IllegalArgumentException(
+                    String.format(
+                        "Could not find voter with node ID %d in the current 
voter set to derive its directory ID",

Review Comment:
   There is a convention in the `raft` module to keep line lengths less than 
100 characters. Here is what claude says with line numbers which lines violate 
this convention in your changes:
   ```
   KafkaRaftClient.java: 9 lines, all in the new add/remove voter resolve code
     - :2292 (102): return 
completedFuture(RaftUtil.addVoterResponse(Errors.INVALID_REQUEST, 
e.getMessage()));
     - :2319 (108) and :2320 (106): the "Multiple observers with node ID…" 
message strings
     - :2325 (105): } else if (matchingObservers.isEmpty() || 
matchingObservers.get(0).directoryId().isEmpty()) {
     - :2328 (105): the "Could not find a unique observer with node ID…" 
message string
     - :2340 (111): .orElseThrow(() -> new IllegalArgumentException("Add voter 
request didn't include a valid voter"));
     - :2492 (105): return 
completedFuture(RaftUtil.removeVoterResponse(Errors.INVALID_REQUEST, 
e.getMessage()));
     - :2509 (115): the "Could not find voter with node ID %d in the current 
voter set…" message string
     - :2517 (114): .orElseThrow(() -> new IllegalArgumentException("Remove 
voter request didn't include a valid voter"));
   
     KafkaRaftClientReconfigTest.java: 12 lines over 100, plus 1 at exactly 100
     - :375 (132): the 
testAddVoterDerivesMissingDirectoryIdAndEndpoints(boolean omitDirectoryId, 
boolean omitEndpoints) throws Exception { signature
     - :388, :428, :477, :1318, :1370 (112 each): RaftClientTestContext context 
= new RaftClientTestContext.Builder(local.id(), local.directoryId().get())
     - :391 (107): .withNodeEndpointProvider(nodeId -> nodeId == newVoter.id() 
? newListeners : Endpoints.empty())
     - :489 (102): the "Add two caught-up observer replica keys…" comment
     - :493 (102): context.fetchRequest(epoch, duplicateObserver, 
context.log.endOffset().offset(), epoch, 0)
     - :507 (exactly 100): String errorMsg = 
context.assertSentAddVoterResponse(Errors.INVALID_REQUEST).errorMessage();
     - :508 (120): assertTrue(errorMsg.startsWith(String.format("Multiple 
observers with node ID %d were found:", …)));
     - :510 (123) and :1394 (107): expected error-message strings
    ```



##########
tools/src/main/java/org/apache/kafka/tools/MetadataQuorumCommand.java:
##########
@@ -145,13 +148,19 @@ static void execute(String... args) throws Exception {
                     }
                 }
                 case "add-controller" -> {
-                    if (optionalCommandConfig == null) {
-                        throw new TerseException("You must supply the 
configuration file of the controller you are " +
-                            "adding when using add-controller.");
+                    Integer controllerId = namespace.getInt("controller_id");
+                    if (controllerId != null) {
+                        handleAddControllerById(admin,
+                            namespace.getBoolean("dry_run"),
+                            controllerId);
+                    } else if (optionalCommandConfig != null) {

Review Comment:
   Should these flags be mutually exclusive? Meaning, is the below a valid 
invocation:
   ```
   kafka-metadata-quorum add-controller --bootstrap-server <> --controller-id 1 
--command-config <>
   ```
   In the current code, you will run `handleAddControllerById` and ignore the 
second flag.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to