Repository: apache/kafka
Pull request #18983: KAFKA-14121: AlterPartitionReassignments API should allow callers to specify the option of preserving the replication factor
Author: login: brandboat
Labels: name: core
description: Kafka Broker
name: tools
name: kraft
name: clients
Created: 2025-02-20T17:06:20Z | Merged: 2025-03-05T11:23:12Z

KAFKA-14121

Add new option `allowReplicationFactorChange` to alterPartitionReassignments. Set this option to false prevents partition replication factor being changed during partition reassignment.
See https://cwiki.apache.org/confluence/display/KAFKA/KIP-860%3A+Add+client-provided+option+to+guard+against+replication+factor+change+during+partition+reassignments for more detail


### Committer Checklist (excluded from commit message)
- [ ] Verify design and implementation 
- [ ] Verify test coverage and CI build status
- [ ] Verify documentation (including upgrade notes)



Diff:
diff --git a/clients/src/main/java/org/apache/kafka/clients/admin/Admin.java b/clients/src/main/java/org/apache/kafka/clients/admin/Admin.java
index dcffef60d09f7..a0bb85899f872 100644
--- a/clients/src/main/java/org/apache/kafka/clients/admin/Admin.java
+++ b/clients/src/main/java/org/apache/kafka/clients/admin/Admin.java
@@ -1083,6 +1083,13 @@ default AlterPartitionReassignmentsResult alterPartitionReassignments(
      *   if the request timed out before the controller could record the new assignments.</li>
      *   <li>{@link org.apache.kafka.common.errors.InvalidReplicaAssignmentException}
      *   If the specified assignment was not valid.</li>
+     *   <li>{@link org.apache.kafka.common.errors.InvalidReplicationFactorException}
+     *   If the replication factor was changed in an invalid way.
+     *   Only thrown when {@link AlterPartitionReassignmentsOptions#allowReplicationFactorChange()} is set to false and
+     *   the request is attempting to alter reassignments (not cancel)</li>
+     *   <li>{@link org.apache.kafka.common.errors.UnsupportedVersionException}
+     *   If {@link AlterPartitionReassignmentsOptions#allowReplicationFactorChange()} was changed outside the default
+     *   and the server does not support the option (e.g due to an old Kafka version).</li>
      *   <li>{@link org.apache.kafka.common.errors.NoReassignmentInProgressException}
      *   If there was an attempt to cancel a reassignment for a partition which was not being reassigned.</li>
      * </ul>
diff --git a/clients/src/main/java/org/apache/kafka/clients/admin/AlterPartitionReassignmentsOptions.java b/clients/src/main/java/org/apache/kafka/clients/admin/AlterPartitionReassignmentsOptions.java
index 166e90404c336..74c9f3dcdec8a 100644
--- a/clients/src/main/java/org/apache/kafka/clients/admin/AlterPartitionReassignmentsOptions.java
+++ b/clients/src/main/java/org/apache/kafka/clients/admin/AlterPartitionReassignmentsOptions.java
@@ -23,4 +23,25 @@
  * Options for {@link AdminClient#alterPartitionReassignments(Map, AlterPartitionReassignmentsOptions)}
  */
 public class AlterPartitionReassignmentsOptions extends AbstractOptions<AlterPartitionReassignmentsOptions> {
+
+    private boolean allowReplicationFactorChange = true;
+
+    /**
+     * Set the option indicating if the alter partition reassignments call should be
+     * allowed to alter the replication factor of a partition.
+     * In cases where it is not allowed, any replication factor change will result in an exception thrown by the API.
+     */
+    public AlterPartitionReassignmentsOptions allowReplicationFactorChange(boolean allow) {
+        this.allowReplicationFactorChange = allow;
+        return this;
+    }
+
+    /**
+     * A boolean indicating if the alter partition reassignments should be
+     * allowed to alter the replication factor of a partition.
+     * In cases where it is not allowed, any replication factor change will result in an exception thrown by the API.
+     */
+    public boolean allowReplicationFactorChange() {
+        return this.allowReplicationFactorChange;
+    }
 }
diff --git a/clients/src/main/java/org/apache/kafka/clients/admin/KafkaAdminClient.java b/clients/src/main/java/org/apache/kafka/clients/admin/KafkaAdminClient.java
index 725e48c36561a..8432ec754a0fe 100644
--- a/clients/src/main/java/org/apache/kafka/clients/admin/KafkaAdminClient.java
+++ b/clients/src/main/java/org/apache/kafka/clients/admin/KafkaAdminClient.java
@@ -3944,6 +3944,7 @@ public AlterPartitionReassignmentsRequest.Builder createRequest(int timeoutMs) {
                     data.topics().add(reassignableTopic);
                 }
                 data.setTimeoutMs(timeoutMs);
+                data.setAllowReplicationFactorChange(options.allowReplicationFactorChange());
                 return new AlterPartitionReassignmentsRequest.Builder(data);
             }
 
diff --git a/clients/src/main/java/org/apache/kafka/common/requests/AlterPartitionReassignmentsRequest.java b/clients/src/main/java/org/apache/kafka/common/requests/AlterPartitionReassignmentsRequest.java
index 2d289cc1497e1..8032207943a6d 100644
--- a/clients/src/main/java/org/apache/kafka/common/requests/AlterPartitionReassignmentsRequest.java
+++ b/clients/src/main/java/org/apache/kafka/common/requests/AlterPartitionReassignmentsRequest.java
@@ -17,6 +17,7 @@
 
 package org.apache.kafka.common.requests;
 
+import org.apache.kafka.common.errors.UnsupportedVersionException;
 import org.apache.kafka.common.message.AlterPartitionReassignmentsRequestData;
 import org.apache.kafka.common.message.AlterPartitionReassignmentsRequestData.ReassignableTopic;
 import org.apache.kafka.common.message.AlterPartitionReassignmentsResponseData;
@@ -42,6 +43,11 @@ public Builder(AlterPartitionReassignmentsRequestData data) {
 
         @Override
         public AlterPartitionReassignmentsRequest build(short version) {
+            if (!data.allowReplicationFactorChange() && version < 1) {
+                throw new UnsupportedVersionException("The broker does not support the AllowReplicationFactorChange " +
+                        "option for the AlterPartitionReassignments API. Consider re-sending the request without the " +
+                        "option or updating the server version");
+            }
             return new AlterPartitionReassignmentsRequest(data, version);
         }
 
diff --git a/clients/src/main/resources/common/message/AlterPartitionReassignmentsRequest.json b/clients/src/main/resources/common/message/AlterPartitionReassignmentsRequest.json
index f3047feb0a3ad..d0ccc1c088ea8 100644
--- a/clients/src/main/resources/common/message/AlterPartitionReassignmentsRequest.json
+++ b/clients/src/main/resources/common/message/AlterPartitionReassignmentsRequest.json
@@ -18,11 +18,14 @@
   "type": "request",
   "listeners": ["broker", "controller"],
   "name": "AlterPartitionReassignmentsRequest",
-  "validVersions": "0",
+  // Version 1 adds the ability to allow/disallow changing the replication factor as part of the request.
+  "validVersions": "0-1",
   "flexibleVersions": "0+",
   "fields": [
     { "name": "TimeoutMs", "type": "int32", "versions": "0+", "default": "60000",
       "about": "The time in ms to wait for the request to complete." },
+    { "name": "AllowReplicationFactorChange", "type": "bool", "versions": "1+", "default": "true",
+      "about": "The option indicating whether changing the replication factor of any given partition as part of this request is a valid move." },
     { "name": "Topics", "type": "[]ReassignableTopic", "versions": "0+",
       "about": "The topics to reassign.", "fields": [
       { "name": "Name", "type": "string", "versions": "0+", "entityType": "topicName",
diff --git a/clients/src/main/resources/common/message/AlterPartitionReassignmentsResponse.json b/clients/src/main/resources/common/message/AlterPartitionReassignmentsResponse.json
index 0b8f60b0baba8..36ce87968ecc8 100644
--- a/clients/src/main/resources/common/message/AlterPartitionReassignmentsResponse.json
+++ b/clients/src/main/resources/common/message/AlterPartitionReassignmentsResponse.json
@@ -17,11 +17,14 @@
   "apiKey": 45,
   "type": "response",
   "name": "AlterPartitionReassignmentsResponse",
-  "validVersions": "0",
+  // Version 1 adds the ability to allow/disallow changing the replication factor as part of the request.
+  "validVersions": "0-1",
   "flexibleVersions": "0+",
   "fields": [
     { "name": "ThrottleTimeMs", "type": "int32", "versions": "0+",
       "about": "The duration in milliseconds for which the request was throttled due to a quota violation, or zero if the request did not violate any quota." },
+    { "name": "AllowReplicationFactorChange", "type": "bool", "versions": "1+", "default": "true", "ignorable": true,
+      "about": "The option indicating whether changing the replication factor of any given partition as part of the request was allowed." },
     { "name": "ErrorCode", "type": "int16", "versions": "0+",
       "about": "The top-level error code, or 0 if there was no error." },
     { "name": "ErrorMessage", "type": "string", "versions": "0+", "nullableVersions": "0+",
diff --git a/core/src/test/scala/integration/kafka/api/PlaintextAdminIntegrationTest.scala b/core/src/test/scala/integration/kafka/api/PlaintextAdminIntegrationTest.scala
index 461e15aa59034..42082c66ffbc3 100644
--- a/core/src/test/scala/integration/kafka/api/PlaintextAdminIntegrationTest.scala
+++ b/core/src/test/scala/integration/kafka/api/PlaintextAdminIntegrationTest.scala
@@ -3450,13 +3450,28 @@ class PlaintextAdminIntegrationTest extends BaseAdminIntegrationTest {
     val tp1 = new TopicPartition(topic, 0)
     val tp2 = new TopicPartition(topic, 1)
     val tp3 = new TopicPartition(topic, 2)
-    createTopic(topic, numPartitions = 4)
-
+    createTopic(topic, numPartitions = 4, replicationFactor = 2)
 
     val validAssignment = Optional.of(new NewPartitionReassignment(
       (0 until brokerCount).map(_.asInstanceOf[Integer]).asJava
     ))
 
+    val alterOptions = new AlterPartitionReassignmentsOptions
+    alterOptions.allowReplicationFactorChange(false)
+    val alterReplicaNumberTo1 = Optional.of(new NewPartitionReassignment(List(1.asInstanceOf[Integer]).asJava))
+    val alterReplicaNumberTo2 = Optional.of(new NewPartitionReassignment((0 until brokerCount - 1).map(_.asInstanceOf[Integer]).asJava))
+    val alterReplicaNumberTo3 = Optional.of(new NewPartitionReassignment((0 until brokerCount).map(_.asInstanceOf[Integer]).asJava))
+    val alterReplicaResults = client.alterPartitionReassignments(Map(
+      tp1 -> alterReplicaNumberTo1,
+      tp2 -> alterReplicaNumberTo2,
+      tp3 -> alterReplicaNumberTo3,
+    ).asJava, alterOptions).values()
+    assertDoesNotThrow(() => alterReplicaResults.get(tp2).get())
+    assertEquals("The replication factor is changed from 2 to 1",
+      assertFutureThrows(classOf[InvalidReplicationFactorException], alterReplicaResults.get(tp1)).getMessage)
+    assertEquals("The replication factor is changed from 2 to 3",
+      assertFutureThrows(classOf[InvalidReplicationFactorException], alterReplicaResults.get(tp3)).getMessage)
+
     val nonExistentTp1 = new TopicPartition("topicA", 0)
     val nonExistentTp2 = new TopicPartition(topic, 4)
     val nonExistentPartitionsResult = client.alterPartitionReassignments(Map(
diff --git a/metadata/src/main/java/org/apache/kafka/controller/ReplicationControlManager.java b/metadata/src/main/java/org/apache/kafka/controller/ReplicationControlManager.java
index b5d4993f90b3b..d4c613003c322 100644
--- a/metadata/src/main/java/org/apache/kafka/controller/ReplicationControlManager.java
+++ b/metadata/src/main/java/org/apache/kafka/controller/ReplicationControlManager.java
@@ -2058,8 +2058,10 @@ void generateLeaderAndIsrUpdates(String context,
     ControllerResult<AlterPartitionReassignmentsResponseData>
             alterPartitionReassignments(AlterPartitionReassignmentsRequestData request) {
         List<ApiMessageAndVersion> records = BoundedList.newArrayBacked(MAX_RECORDS_PER_USER_OP);
+        boolean allowRFChange = request.allowReplicationFactorChange();
         AlterPartitionReassignmentsResponseData result =
-                new AlterPartitionReassignmentsResponseData().setErrorMessage(null);
+                new AlterPartitionReassignmentsResponseData().setErrorMessage(null)
+                        .setAllowReplicationFactorChange(allowRFChange);
         int successfulAlterations = 0, totalAlterations = 0;
         for (ReassignableTopic topic : request.topics()) {
             ReassignableTopicResponse topicResponse = new ReassignableTopicResponse().
@@ -2067,7 +2069,7 @@ void generateLeaderAndIsrUpdates(String context,
             for (ReassignablePartition partition : topic.partitions()) {
                 ApiError error = ApiError.NONE;
                 try {
-                    alterPartitionReassignment(topic.name(), partition, records);
+                    alterPartitionReassignment(topic.name(), partition, records, allowRFChange);
                     successfulAlterations++;
                 } catch (Throwable e) {
                     log.info("Unable to alter partition reassignment for " +
@@ -2090,7 +2092,8 @@ void generateLeaderAndIsrUpdates(String context,
 
     void alterPartitionReassignment(String topicName,
                                     ReassignablePartition target,
-                                    List<ApiMessageAndVersion> records) {
+                                    List<ApiMessageAndVersion> records,
+                                    boolean allowRFChange) {
         Uuid topicId = topicsByName.get(topicName);
         if (topicId == null) {
             throw new UnknownTopicOrPartitionException("Unable to find a topic " +
@@ -2111,7 +2114,7 @@ void alterPartitionReassignment(String topicName,
         if (target.replicas() == null) {
             record = cancelPartitionReassignment(topicName, tp, part);
         } else {
-            record = changePartitionReassignment(tp, part, target);
+            record = changePartitionReassignment(tp, part, target, allowRFChange);
         }
         record.ifPresent(records::add);
     }
@@ -2175,18 +2178,23 @@ Optional<ApiMessageAndVersion> cancelPartitionReassignment(String topicName,
      * @param tp                The topic id and partition id.
      * @param part              The existing partition info.
      * @param target            The target partition info.
+     * @param allowRFChange     Validate if partition replication factor can change. KIP-860
      *
      * @return                  The ChangePartitionRecord for the new partition assignment,
      *                          or empty if no change is needed.
      */
     Optional<ApiMessageAndVersion> changePartitionReassignment(TopicIdPartition tp,
                                                                PartitionRegistration part,
-                                                               ReassignablePartition target) {
+                                                               ReassignablePartition target,
+                                                               boolean allowRFChange) {
         // Check that the requested partition assignment is valid.
         PartitionAssignment currentAssignment = new PartitionAssignment(Replicas.toList(part.replicas), part::directory);
         PartitionAssignment targetAssignment = new PartitionAssignment(target.replicas(), clusterDescriber);
 
         validateManualPartitionAssignment(targetAssignment, OptionalInt.empty());
+        if (!allowRFChange) {
+            validatePartitionReplicationFactorUnchanged(part, target);
+        }
 
         List<Integer> currentReplicas = Replicas.toList(part.replicas);
         PartitionReassignmentReplicas reassignment =
@@ -2406,6 +2414,30 @@ private void updatePartitionInfo(
             newPartInfo.elr);
     }
 
+    private void validatePartitionReplicationFactorUnchanged(PartitionRegistration part,
+                                                             ReassignablePartition target) {
+        int currentReassignmentSetSize;
+        if (isReassignmentInProgress(part)) {
+            Set<Integer> set = new HashSet<>();
+            for (int r : part.replicas) {
+                set.add(r);
+            }
+            for (int r : part.addingReplicas) {
+                set.add(r);
+            }
+            for (int r : part.removingReplicas) {
+                set.remove(r);
+            }
+            currentReassignmentSetSize = set.size();
+        } else {
+            currentReassignmentSetSize = part.replicas.length;
+        }
+        if (currentReassignmentSetSize != target.replicas().size()) {
+            throw new InvalidReplicationFactorException("The replication factor is changed from " +
+                    currentReassignmentSetSize + " to " + target.replicas().size());
+        }
+    }
+
     private static final class IneligibleReplica {
         private final int replicaId;
         private final String reason;
diff --git a/metadata/src/test/java/org/apache/kafka/controller/ReplicationControlManagerTest.java b/metadata/src/test/java/org/apache/kafka/controller/ReplicationControlManagerTest.java
index ef58e2bc7f5f7..0516b134c78cf 100644
--- a/metadata/src/test/java/org/apache/kafka/controller/ReplicationControlManagerTest.java
+++ b/metadata/src/test/java/org/apache/kafka/controller/ReplicationControlManagerTest.java
@@ -1922,6 +1922,135 @@ public void testReassignPartitions(short version) {
         assertEquals(NONE_REASSIGNING, replication.listPartitionReassignments(null, Long.MAX_VALUE));
     }
 
+    @ParameterizedTest
+    @ApiKeyVersionsSource(apiKey = ApiKeys.ALTER_PARTITION)
+    public void testAlterPartitionDisallowReplicationFactorChange(short version) {
+        MetadataVersion metadataVersion = MetadataVersion.latestTesting();
+        ReplicationControlTestContext ctx = new ReplicationControlTestContext.Builder()
+                .setMetadataVersion(metadataVersion)
+                .build();
+        ReplicationControlManager replication = ctx.replicationControl;
+        ctx.registerBrokers(0, 1, 2, 3);
+        ctx.unfenceBrokers(0, 1, 2, 3);
+        ctx.createTestTopic("foo", new int[][] {new int[] {0, 1, 2}, new int[] {0, 1, 2}, new int[] {0, 1, 2}});
+
+        ControllerResult<AlterPartitionReassignmentsResponseData> alterResult =
+                replication.alterPartitionReassignments(
+                        new AlterPartitionReassignmentsRequestData().setTopics(singletonList(
+                                new ReassignableTopic().setName("foo").setPartitions(asList(
+                                        new ReassignablePartition().setPartitionIndex(0).
+                                                setReplicas(asList(1, 2, 3)),
+                                        new ReassignablePartition().setPartitionIndex(1).
+                                                setReplicas(asList(0, 1)),
+                                        new ReassignablePartition().setPartitionIndex(2).
+                                                setReplicas(asList(0, 1, 2, 3)))))).
+                                setAllowReplicationFactorChange(false));
+        assertEquals(new AlterPartitionReassignmentsResponseData().
+                        setErrorMessage(null).setAllowReplicationFactorChange(false).setResponses(singletonList(
+                                new ReassignableTopicResponse().setName("foo").setPartitions(asList(
+                                        new ReassignablePartitionResponse().setPartitionIndex(0).
+                                                setErrorMessage(null),
+                                        new ReassignablePartitionResponse().setPartitionIndex(1).
+                                                setErrorCode(INVALID_REPLICATION_FACTOR.code()).
+                                                setErrorMessage("The replication factor is changed from 3 to 2"),
+                                        new ReassignablePartitionResponse().setPartitionIndex(2).
+                                                setErrorCode(INVALID_REPLICATION_FACTOR.code()).
+                                                setErrorMessage("The replication factor is changed from 3 to 4"))))),
+                alterResult.response());
+        ctx.replay(alterResult.records());
+        ListPartitionReassignmentsResponseData currentReassigning =
+                new ListPartitionReassignmentsResponseData().setErrorMessage(null).
+                        setTopics(singletonList(new OngoingTopicReassignment().
+                                setName("foo").setPartitions(singletonList(
+                                        new OngoingPartitionReassignment().setPartitionIndex(0).
+                                                setRemovingReplicas(singletonList(0)).
+                                                setAddingReplicas(singletonList(3)).
+                                                setReplicas(asList(1, 2, 3, 0))))));
+        assertEquals(currentReassigning, replication.listPartitionReassignments(singletonList(
+                new ListPartitionReassignmentsTopics().setName("foo").
+                        setPartitionIndexes(asList(0, 1, 2))), Long.MAX_VALUE));
+
+        // test alter replica factor not allow to change when partition reassignment is ongoing
+        ControllerResult<AlterPartitionReassignmentsResponseData> alterReassigningResult =
+                replication.alterPartitionReassignments(
+                        new AlterPartitionReassignmentsRequestData().setTopics(singletonList(
+                                new ReassignableTopic().setName("foo").setPartitions(singletonList(
+                                        new ReassignablePartition().setPartitionIndex(0).setReplicas(asList(0, 1)))))).
+                                setAllowReplicationFactorChange(false));
+        assertEquals(new AlterPartitionReassignmentsResponseData().
+                        setErrorMessage(null).setAllowReplicationFactorChange(false).setResponses(singletonList(
+                                new ReassignableTopicResponse().setName("foo").setPartitions(singletonList(
+                                        new ReassignablePartitionResponse().setPartitionIndex(0).
+                                                setErrorCode(INVALID_REPLICATION_FACTOR.code()).
+                                                setErrorMessage("The replication factor is changed from 3 to 2"))))),
+                alterReassigningResult.response());
+
+        ControllerResult<AlterPartitionReassignmentsResponseData> alterReassigningResult2 =
+                replication.alterPartitionReassignments(
+                        new AlterPartitionReassignmentsRequestData().setTopics(singletonList(
+                                        new ReassignableTopic().setName("foo").setPartitions(singletonList(
+                                                new ReassignablePartition().setPartitionIndex(0).setReplicas(asList(0, 2, 3)))))).
+                                setAllowReplicationFactorChange(false));
+        assertEquals(new AlterPartitionReassignmentsResponseData().
+                        setErrorMessage(null).setAllowReplicationFactorChange(false).setResponses(singletonList(
+                                new ReassignableTopicResponse().setName("foo").setPartitions(singletonList(
+                                        new ReassignablePartitionResponse().setPartitionIndex(0).
+                                                setErrorMessage(null))))),
+                alterReassigningResult2.response());
+    }
+
+    @ParameterizedTest
+    @ApiKeyVersionsSource(apiKey = ApiKeys.ALTER_PARTITION)
+    public void testDisallowReplicationFactorChangeNoEffectWhenCancelAlterPartition(short version) {
+        MetadataVersion metadataVersion = MetadataVersion.latestTesting();
+        ReplicationControlTestContext ctx = new ReplicationControlTestContext.Builder()
+                .setMetadataVersion(metadataVersion)
+                .build();
+        ReplicationControlManager replication = ctx.replicationControl;
+        ctx.registerBrokers(0, 1, 2, 3);
+        ctx.unfenceBrokers(0, 1, 2, 3);
+        ctx.createTestTopic("foo", new int[][] {new int[] {0, 1, 2}}).topicId();
+
+        ControllerResult<AlterPartitionReassignmentsResponseData> alterResult =
+                replication.alterPartitionReassignments(
+                        new AlterPartitionReassignmentsRequestData().setTopics(singletonList(
+                                        new ReassignableTopic().setName("foo").setPartitions(singletonList(
+                                                new ReassignablePartition().setPartitionIndex(0).
+                                                        setReplicas(asList(1, 2, 3)))))));
+        assertEquals(new AlterPartitionReassignmentsResponseData().
+                        setErrorMessage(null).setResponses(singletonList(
+                                new ReassignableTopicResponse().setName("foo").setPartitions(singletonList(
+                                        new ReassignablePartitionResponse().setPartitionIndex(0).setErrorMessage(null))))),
+                alterResult.response());
+        ctx.replay(alterResult.records());
+        ListPartitionReassignmentsResponseData currentReassigning =
+                new ListPartitionReassignmentsResponseData().setErrorMessage(null).
+                        setTopics(singletonList(new OngoingTopicReassignment().
+                                setName("foo").setPartitions(singletonList(
+                                        new OngoingPartitionReassignment().setPartitionIndex(0).
+                                                setRemovingReplicas(singletonList(0)).
+                                                setAddingReplicas(singletonList(3)).
+                                                setReplicas(asList(1, 2, 3, 0))))));
+        assertEquals(currentReassigning, replication.listPartitionReassignments(singletonList(
+                new ListPartitionReassignmentsTopics().setName("foo").
+                        setPartitionIndexes(asList(0, 1, 2))), Long.MAX_VALUE));
+
+        // test replica factor change check takes no effect when partition reassignment is ongoing
+        ControllerResult<AlterPartitionReassignmentsResponseData> cancelResult =
+                replication.alterPartitionReassignments(
+                        new AlterPartitionReassignmentsRequestData().setTopics(singletonList(
+                                new ReassignableTopic().setName("foo").setPartitions(singletonList(
+                                        new ReassignablePartition().setPartitionIndex(0).setReplicas(null))))).
+                                setAllowReplicationFactorChange(false));
+        assertEquals(new AlterPartitionReassignmentsResponseData().setAllowReplicationFactorChange(false).setErrorMessage(null).
+                        setResponses(singletonList(
+                                new ReassignableTopicResponse().setName("foo").setPartitions(singletonList(
+                                        new ReassignablePartitionResponse().setPartitionIndex(0).setErrorMessage(null))))),
+                cancelResult.response());
+        ctx.replay(cancelResult.records());
+        assertEquals(NONE_REASSIGNING, replication.listPartitionReassignments(null, Long.MAX_VALUE));
+    }
+
     @ParameterizedTest
     @ApiKeyVersionsSource(apiKey = ApiKeys.ALTER_PARTITION)
     public void testAlterPartitionShouldRejectFencedBrokers(short version) {
diff --git a/tools/src/main/java/org/apache/kafka/tools/reassign/ReassignPartitionsCommand.java b/tools/src/main/java/org/apache/kafka/tools/reassign/ReassignPartitionsCommand.java
index 137092da32c52..33bf23d13d1fc 100644
--- a/tools/src/main/java/org/apache/kafka/tools/reassign/ReassignPartitionsCommand.java
+++ b/tools/src/main/java/org/apache/kafka/tools/reassign/ReassignPartitionsCommand.java
@@ -20,6 +20,7 @@
 import org.apache.kafka.clients.admin.Admin;
 import org.apache.kafka.clients.admin.AdminClientConfig;
 import org.apache.kafka.clients.admin.AlterConfigOp;
+import org.apache.kafka.clients.admin.AlterPartitionReassignmentsOptions;
 import org.apache.kafka.clients.admin.ConfigEntry;
 import org.apache.kafka.clients.admin.DescribeReplicaLogDirsResult;
 import org.apache.kafka.clients.admin.NewPartitionReassignment;
@@ -182,7 +183,8 @@ private static void handleAction(Admin adminClient, ReassignPartitionsCommandOpt
                 opts.options.valueOf(opts.interBrokerThrottleOpt),
                 opts.options.valueOf(opts.replicaAlterLogDirsThrottleOpt),
                 opts.options.valueOf(opts.timeoutOpt),
-                Time.SYSTEM);
+                Time.SYSTEM,
+                opts.options.has(opts.disallowReplicationFactorChangeOpt));
         } else if (opts.options.has(opts.cancelOpt)) {
             cancelAssignment(adminClient,
                 Utils.readFileAsString(opts.options.valueOf(opts.reassignmentJsonFileOpt)),
@@ -761,7 +763,8 @@ public static void executeAssignment(Admin adminClient,
                                   Long interBrokerThrottle,
                                   Long logDirThrottle,
                                   Long timeoutMs,
-                                  Time time
+                                  Time time,
+                                  boolean disallowReplicationFactorChange
     ) throws ExecutionException, InterruptedException, JsonProcessingException, TerseException {
         Entry<Map<TopicPartition, List<Integer>>, Map<TopicPartitionReplica, String>> t0 = parseExecuteAssignmentArgs(reassignmentJson);
 
@@ -796,7 +799,7 @@ public static void executeAssignment(Admin adminClient,
         }
 
         // Execute the partition reassignments.
-        Map<TopicPartition, Throwable> errors = alterPartitionReassignments(adminClient, proposedParts);
+        Map<TopicPartition, Throwable> errors = alterPartitionReassignments(adminClient, proposedParts, disallowReplicationFactorChange);
         if (!errors.isEmpty()) {
             throw new TerseException(
                 String.format("Error reassigning partition(s):%n%s",
@@ -941,15 +944,19 @@ static String currentPartitionReplicaAssignmentToString(Map<TopicPartition, List
     /**
      * Execute the given partition reassignments.
      *
-     * @param adminClient       The admin client object to use.
-     * @param reassignments     A map from topic names to target replica assignments.
-     * @return                  A map from partition objects to error strings.
+     * @param adminClient                        The admin client object to use.
+     * @param reassignments                      A map from topic names to target replica assignments.
+     * @param disallowReplicationFactorChange    Disallow replication factor change or not.
+     * @return                                   A map from partition objects to error strings.
      */
     static Map<TopicPartition, Throwable> alterPartitionReassignments(Admin adminClient,
-                                                                      Map<TopicPartition, List<Integer>> reassignments) throws InterruptedException {
+                                                                      Map<TopicPartition, List<Integer>> reassignments,
+                                                                      boolean disallowReplicationFactorChange) throws InterruptedException {
         Map<TopicPartition, Optional<NewPartitionReassignment>> args = new HashMap<>();
         reassignments.forEach((part, replicas) -> args.put(part, Optional.of(new NewPartitionReassignment(replicas))));
-        Map<TopicPartition, KafkaFuture<Void>> results = adminClient.alterPartitionReassignments(args).values();
+        AlterPartitionReassignmentsOptions options = new AlterPartitionReassignmentsOptions();
+        options.allowReplicationFactorChange(!disallowReplicationFactorChange);
+        Map<TopicPartition, KafkaFuture<Void>> results = adminClient.alterPartitionReassignments(args, options).values();
         Map<TopicPartition, Throwable> errors = new HashMap<>();
         for (Entry<TopicPartition, KafkaFuture<Void>> e :  results.entrySet()) {
             try {
@@ -1485,7 +1492,8 @@ else if (!opts.options.has(opts.bootstrapServerOpt) && !opts.options.has(opts.bo
             opts.commandConfigOpt,
             opts.interBrokerThrottleOpt,
             opts.replicaAlterLogDirsThrottleOpt,
-            opts.timeoutOpt
+            opts.timeoutOpt,
+            opts.disallowReplicationFactorChangeOpt
         ));
         permittedArgs.put(opts.cancelOpt, Arrays.asList(
             isBootstrapServer ? opts.bootstrapServerOpt : opts.bootstrapControllerOpt,
diff --git a/tools/src/main/java/org/apache/kafka/tools/reassign/ReassignPartitionsCommandOptions.java b/tools/src/main/java/org/apache/kafka/tools/reassign/ReassignPartitionsCommandOptions.java
index 2d31c5a902ab4..3954128871222 100644
--- a/tools/src/main/java/org/apache/kafka/tools/reassign/ReassignPartitionsCommandOptions.java
+++ b/tools/src/main/java/org/apache/kafka/tools/reassign/ReassignPartitionsCommandOptions.java
@@ -42,6 +42,7 @@ public class ReassignPartitionsCommandOptions extends CommandDefaultOptions {
     final OptionSpec<Long> timeoutOpt;
     final OptionSpec<?> additionalOpt;
     final OptionSpec<?> preserveThrottlesOpt;
+    final OptionSpec<?> disallowReplicationFactorChangeOpt;
 
     public ReassignPartitionsCommandOptions(String[] args) {
         super(args);
@@ -115,6 +116,7 @@ public ReassignPartitionsCommandOptions(String[] args) {
         additionalOpt = parser.accepts("additional", "Execute this reassignment in addition to any " +
             "other ongoing ones. This option can also be used to change the throttle of an ongoing reassignment.");
         preserveThrottlesOpt = parser.accepts("preserve-throttles", "Do not modify broker or topic throttles.");
+        disallowReplicationFactorChangeOpt = parser.accepts("disallow-replication-factor-change", "Denies the ability to change a partition's replication factor as part of this reassignment through adding validation against it.");
 
         options = parser.parse(args);
     }
diff --git a/tools/src/test/java/org/apache/kafka/tools/other/ReplicationQuotasTestRig.java b/tools/src/test/java/org/apache/kafka/tools/other/ReplicationQuotasTestRig.java
index 0dedf567c49d9..8c981e4eb4444 100644
--- a/tools/src/test/java/org/apache/kafka/tools/other/ReplicationQuotasTestRig.java
+++ b/tools/src/test/java/org/apache/kafka/tools/other/ReplicationQuotasTestRig.java
@@ -249,7 +249,7 @@ public void run(ExperimentDef config, Journal journal, boolean displayChartsOnSc
 
             ReassignPartitionsCommand.executeAssignment(adminClient, false,
                 ReassignPartitionsCommand.formatAsReassignmentJson(newAssignment, Collections.emptyMap()),
-                config.throttle, -1L, 10000L, Time.SYSTEM);
+                config.throttle, -1L, 10000L, Time.SYSTEM, false);
 
             //Await completion
             waitForReassignmentToComplete();
diff --git a/tools/src/test/java/org/apache/kafka/tools/reassign/ReassignPartitionsCommandTest.java b/tools/src/test/java/org/apache/kafka/tools/reassign/ReassignPartitionsCommandTest.java
index d7f6f937d97d5..a96bccd36eddd 100644
--- a/tools/src/test/java/org/apache/kafka/tools/reassign/ReassignPartitionsCommandTest.java
+++ b/tools/src/test/java/org/apache/kafka/tools/reassign/ReassignPartitionsCommandTest.java
@@ -93,6 +93,7 @@
 import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 
 @ClusterTestDefaults(brokers = 5, disksPerBroker = 3, serverProperties = {
@@ -432,6 +433,23 @@ topicPartition, new PartitionReassignmentState(asList(0, 1, 2), asList(0, 1, 2),
         }
     }
 
+    @ClusterTest
+    public void testDisallowReplicationFactorChange() {
+        createTopics();
+        String assignment = "{\"version\":1,\"partitions\":" +
+                "[{\"topic\":\"foo\",\"partition\":0,\"replicas\":[0,1],\"log_dirs\":[\"any\",\"any\"]}," +
+                "{\"topic\":\"foo\",\"partition\":1,\"replicas\":[0,1,2,3],\"log_dirs\":[\"any\",\"any\",\"any\",\"any\"]}," +
+                "{\"topic\":\"bar\",\"partition\":0,\"replicas\":[3],\"log_dirs\":[\"any\"]}" +
+                "]}";
+        try (Admin admin = clusterInstance.admin()) {
+            assertEquals("Error reassigning partition(s):\n" +
+                            "bar-0: The replication factor is changed from 3 to 1\n" +
+                            "foo-0: The replication factor is changed from 3 to 2\n" +
+                            "foo-1: The replication factor is changed from 3 to 4",
+                    assertThrows(TerseException.class, () -> executeAssignment(admin, false, assignment, -1L, -1L, 10000L, Time.SYSTEM, true)).getMessage());
+        }
+    }
+
     private void createTopics() {
         try (Admin admin = Admin.create(Collections.singletonMap(CommonClientConfigs.BOOTSTRAP_SERVERS_CONFIG, clusterInstance.bootstrapServers()))) {
             Map<Integer, List<Integer>> fooReplicasAssignments = new HashMap<>();
@@ -654,7 +672,7 @@ private void runExecuteAssignment(Boolean additional,
                                       Long replicaAlterLogDirsThrottle) throws RuntimeException {
         try (Admin admin = Admin.create(Collections.singletonMap(CommonClientConfigs.BOOTSTRAP_SERVERS_CONFIG, clusterInstance.bootstrapServers()))) {
             executeAssignment(admin, additional, reassignmentJson,
-                    interBrokerThrottle, replicaAlterLogDirsThrottle, 10000L, Time.SYSTEM);
+                    interBrokerThrottle, replicaAlterLogDirsThrottle, 10000L, Time.SYSTEM, false);
         } catch (ExecutionException | InterruptedException | JsonProcessingException | TerseException e) {
             throw new RuntimeException(e);
         }
diff --git a/tools/src/test/java/org/apache/kafka/tools/reassign/ReassignPartitionsUnitTest.java b/tools/src/test/java/org/apache/kafka/tools/reassign/ReassignPartitionsUnitTest.java
index d383b56e75585..793eba842d279 100644
--- a/tools/src/test/java/org/apache/kafka/tools/reassign/ReassignPartitionsUnitTest.java
+++ b/tools/src/test/java/org/apache/kafka/tools/reassign/ReassignPartitionsUnitTest.java
@@ -161,7 +161,7 @@ public void testFindPartitionReassignmentStates() throws Exception {
             reassignments.put(new TopicPartition("foo", 0), asList(0, 1, 3));
             reassignments.put(new TopicPartition("quux", 0), asList(1, 2, 3));
 
-            Map<TopicPartition, Throwable> reassignmentResult = alterPartitionReassignments(adminClient, reassignments);
+            Map<TopicPartition, Throwable> reassignmentResult = alterPartitionReassignments(adminClient, reassignments,  false);
 
             assertEquals(1, reassignmentResult.size());
             assertEquals(UnknownTopicOrPartitionException.class, reassignmentResult.get(new TopicPartition("quux", 0)).getClass());
@@ -606,7 +606,7 @@ public void testExecuteWithInvalidPartitionsFails() {
                     "{\"version\":1,\"partitions\":" +
                         "[{\"topic\":\"foo\",\"partition\":0,\"replicas\":[0,1],\"log_dirs\":[\"any\",\"any\"]}," +
                         "{\"topic\":\"quux\",\"partition\":0,\"replicas\":[2,3,4],\"log_dirs\":[\"any\",\"any\",\"any\"]}" +
-                        "]}", -1L, -1L, 10000L, Time.SYSTEM), "Expected reassignment with non-existent topic to fail").getCause().getMessage());
+                        "]}", -1L, -1L, 10000L, Time.SYSTEM, false), "Expected reassignment with non-existent topic to fail").getCause().getMessage());
         }
     }
 
@@ -619,7 +619,7 @@ public void testExecuteWithInvalidBrokerIdFails() {
                     "{\"version\":1,\"partitions\":" +
                         "[{\"topic\":\"foo\",\"partition\":0,\"replicas\":[0,1],\"log_dirs\":[\"any\",\"any\"]}," +
                         "{\"topic\":\"foo\",\"partition\":1,\"replicas\":[2,3,4],\"log_dirs\":[\"any\",\"any\",\"any\"]}" +
-                        "]}", -1L, -1L, 10000L, Time.SYSTEM), "Expected reassignment with non-existent broker id to fail").getMessage());
+                        "]}", -1L, -1L, 10000L, Time.SYSTEM, false), "Expected reassignment with non-existent broker id to fail").getMessage());
         }
     }
 
@@ -670,7 +670,7 @@ public void testCurReassignmentsToString() throws Exception {
             reassignments.put(new TopicPartition("foo", 0), asList(0, 1, 4, 2));
             reassignments.put(new TopicPartition("bar", 0), asList(2, 3));
 
-            Map<TopicPartition, Throwable> reassignmentResult = alterPartitionReassignments(adminClient, reassignments);
+            Map<TopicPartition, Throwable> reassignmentResult = alterPartitionReassignments(adminClient, reassignments, false);
 
             assertTrue(reassignmentResult.isEmpty());
             assertEquals(String.join(System.lineSeparator(),
@@ -762,7 +762,7 @@ public void testPropagateInvalidJsonError() {
         try (MockAdminClient adminClient = new MockAdminClient.Builder().numBrokers(4).build()) {
             addTopics(adminClient);
             assertStartsWith("Unexpected character",
-                assertThrows(AdminOperationException.class, () -> executeAssignment(adminClient, false, "{invalid_json", -1L, -1L, 10000L, Time.SYSTEM)).getMessage());
+                assertThrows(AdminOperationException.class, () -> executeAssignment(adminClient, false, "{invalid_json", -1L, -1L, 10000L, Time.SYSTEM, false)).getMessage());
         }
     }
 }


Reviews:
user: login: clolov
body: Thanks for the contributions! I have made an initial pass with some comment 😊 
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-02-21T11:44:01Z
user: login: chia7712
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-02-25T06:22:19Z
user: login: chia7712
body: @brandboat thanks for your patch!
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-02-25T06:37:12Z
user: login: frankvicky
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-02-25T15:00:04Z
user: login: brandboat
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-02T14:55:49Z
user: login: brandboat
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-02T15:37:12Z
user: login: brandboat
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-02T15:46:09Z
user: login: brandboat
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-02T16:19:10Z
user: login: brandboat
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-02T16:34:00Z
user: login: brandboat
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-02T16:39:41Z
user: login: frankvicky
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-03T15:15:40Z
user: login: brandboat
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-03T15:25:18Z
user: login: frankvicky
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-03T15:33:31Z
user: login: clolov
body: Thanks for the change 😊! Could you just reduce the length of the PR title because a linter is complaining? I will also leave this open for another day in case any of the other reviewers have blocking comments
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-04T13:30:58Z

Review comments:
diff_hunk: @@ -28,4 +28,25 @@
  */
 @InterfaceStability.Evolving
 public class AlterPartitionReassignmentsOptions extends AbstractOptions<AlterPartitionReassignmentsOptions> {
+
+    private Boolean allowReplicationFactorChange = true;
+
+    /**
+     * Set the option indicating if the alter partition reassignments call should be
+     * allowed to alter the replication factor of a partition.
+     * In cases where it is not allowed, any replication factor change will result in an exception thrown by the API.
+     */
+    public boolean allowReplicationFactorChange(boolean allow) {
+        this.allowReplicationFactorChange = allow;
+        return allowReplicationFactorChange;
path: clients/src/main/java/org/apache/kafka/clients/admin/AlterPartitionReassignmentsOptions.java
user: login: clolov
body: On a quick cross-check with other *Options it appears that for such methods we return the *Options class itself. In other words, something along these lines
```
public AlterPartitionReassignmentsOptions allowReplicationFactorChange(boolean allow) {
    this.allowReplicationFactorChange = allow;
    return this;
}
```
created_at: 2025-02-21T11:19:32Z
author_association: CONTRIBUTOR
diff_hunk: @@ -2406,6 +2414,31 @@ private void updatePartitionInfo(
             newPartInfo.elr);
     }
 
+    private void validatePartitionReplicationFactorUnchanged(
+            PartitionRegistration part,
path: metadata/src/main/java/org/apache/kafka/controller/ReplicationControlManager.java
user: login: clolov
body: Nit: Could you align the first argument with the opening bracket as other methods in this file?
created_at: 2025-02-21T11:27:09Z
author_association: CONTRIBUTOR
diff_hunk: @@ -432,6 +433,21 @@ topicPartition, new PartitionReassignmentState(asList(0, 1, 2), asList(0, 1, 2),
         }
     }
 
+    @ClusterTest
+    public void testDisallowReplicationFactorChange() {
path: tools/src/test/java/org/apache/kafka/tools/reassign/ReassignPartitionsCommandTest.java
user: login: clolov
body: Could you also add a test (or build on this one) which showcases that **increasing** the replication factor also fails?
created_at: 2025-02-21T11:31:08Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1922,6 +1922,135 @@ public void testReassignPartitions(short version) {
         assertEquals(NONE_REASSIGNING, replication.listPartitionReassignments(null, Long.MAX_VALUE));
     }
 
+    @ParameterizedTest
+    @ApiKeyVersionsSource(apiKey = ApiKeys.ALTER_PARTITION)
+    public void testAlterPartitionDisallowReplicationFactorChange(short version) {
+        MetadataVersion metadataVersion = MetadataVersion.latestTesting();
+        ReplicationControlTestContext ctx = new ReplicationControlTestContext.Builder()
+                .setMetadataVersion(metadataVersion)
+                .build();
+        ReplicationControlManager replication = ctx.replicationControl;
+        ctx.registerBrokers(0, 1, 2, 3);
+        ctx.unfenceBrokers(0, 1, 2, 3);
+        ctx.createTestTopic("foo", new int[][] {new int[] {1, 2, 3}, new int[] {1, 2, 3}, new int[] {1, 2, 3}});
+
+        ControllerResult<AlterPartitionReassignmentsResponseData> alterResult =
+                replication.alterPartitionReassignments(
+                        new AlterPartitionReassignmentsRequestData().setTopics(singletonList(
+                                new ReassignableTopic().setName("foo").setPartitions(asList(
+                                        new ReassignablePartition().setPartitionIndex(0).
+                                                setReplicas(asList(0, 1, 2)),
+                                        new ReassignablePartition().setPartitionIndex(1).
+                                                setReplicas(asList(0, 1)),
+                                        new ReassignablePartition().setPartitionIndex(2).
+                                                setReplicas(asList(0, 1, 2, 3)))))).
+                                setAllowReplicationFactorChange(false));
+        assertEquals(new AlterPartitionReassignmentsResponseData().
+                        setErrorMessage(null).setAllowReplicationFactorChange(false).setResponses(singletonList(
+                                new ReassignableTopicResponse().setName("foo").setPartitions(asList(
+                                        new ReassignablePartitionResponse().setPartitionIndex(0).
+                                                setErrorMessage(null),
+                                        new ReassignablePartitionResponse().setPartitionIndex(1).
+                                                setErrorCode(INVALID_REPLICATION_FACTOR.code()).
+                                                setErrorMessage("The replication factor is changed from 3 to 2"),
+                                        new ReassignablePartitionResponse().setPartitionIndex(2).
+                                                setErrorCode(INVALID_REPLICATION_FACTOR.code()).
+                                                setErrorMessage("The replication factor is changed from 3 to 4"))))),
+                alterResult.response());
+        ctx.replay(alterResult.records());
+        ListPartitionReassignmentsResponseData currentReassigning =
+                new ListPartitionReassignmentsResponseData().setErrorMessage(null).
+                        setTopics(singletonList(new OngoingTopicReassignment().
+                                setName("foo").setPartitions(singletonList(
+                                        new OngoingPartitionReassignment().setPartitionIndex(0).
+                                                setRemovingReplicas(singletonList(3)).
path: metadata/src/test/java/org/apache/kafka/controller/ReplicationControlManagerTest.java
user: login: clolov
body: Nit: Partition 0 started with replicas (0, 1, 2). For the sake of continuity, could you remove 0 and add 3 rather than the other way around?
created_at: 2025-02-21T11:36:43Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1922,6 +1922,135 @@ public void testReassignPartitions(short version) {
         assertEquals(NONE_REASSIGNING, replication.listPartitionReassignments(null, Long.MAX_VALUE));
     }
 
+    @ParameterizedTest
+    @ApiKeyVersionsSource(apiKey = ApiKeys.ALTER_PARTITION)
+    public void testAlterPartitionDisallowReplicationFactorChange(short version) {
+        MetadataVersion metadataVersion = MetadataVersion.latestTesting();
+        ReplicationControlTestContext ctx = new ReplicationControlTestContext.Builder()
+                .setMetadataVersion(metadataVersion)
+                .build();
+        ReplicationControlManager replication = ctx.replicationControl;
+        ctx.registerBrokers(0, 1, 2, 3);
+        ctx.unfenceBrokers(0, 1, 2, 3);
+        ctx.createTestTopic("foo", new int[][] {new int[] {1, 2, 3}, new int[] {1, 2, 3}, new int[] {1, 2, 3}});
+
+        ControllerResult<AlterPartitionReassignmentsResponseData> alterResult =
+                replication.alterPartitionReassignments(
+                        new AlterPartitionReassignmentsRequestData().setTopics(singletonList(
+                                new ReassignableTopic().setName("foo").setPartitions(asList(
+                                        new ReassignablePartition().setPartitionIndex(0).
+                                                setReplicas(asList(0, 1, 2)),
+                                        new ReassignablePartition().setPartitionIndex(1).
+                                                setReplicas(asList(0, 1)),
+                                        new ReassignablePartition().setPartitionIndex(2).
+                                                setReplicas(asList(0, 1, 2, 3)))))).
+                                setAllowReplicationFactorChange(false));
+        assertEquals(new AlterPartitionReassignmentsResponseData().
+                        setErrorMessage(null).setAllowReplicationFactorChange(false).setResponses(singletonList(
+                                new ReassignableTopicResponse().setName("foo").setPartitions(asList(
+                                        new ReassignablePartitionResponse().setPartitionIndex(0).
+                                                setErrorMessage(null),
+                                        new ReassignablePartitionResponse().setPartitionIndex(1).
+                                                setErrorCode(INVALID_REPLICATION_FACTOR.code()).
+                                                setErrorMessage("The replication factor is changed from 3 to 2"),
+                                        new ReassignablePartitionResponse().setPartitionIndex(2).
+                                                setErrorCode(INVALID_REPLICATION_FACTOR.code()).
+                                                setErrorMessage("The replication factor is changed from 3 to 4"))))),
+                alterResult.response());
+        ctx.replay(alterResult.records());
+        ListPartitionReassignmentsResponseData currentReassigning =
+                new ListPartitionReassignmentsResponseData().setErrorMessage(null).
+                        setTopics(singletonList(new OngoingTopicReassignment().
+                                setName("foo").setPartitions(singletonList(
+                                        new OngoingPartitionReassignment().setPartitionIndex(0).
+                                                setRemovingReplicas(singletonList(3)).
+                                                setAddingReplicas(singletonList(0)).
+                                                setReplicas(asList(0, 1, 2, 3))))));
+        assertEquals(currentReassigning, replication.listPartitionReassignments(singletonList(
+                new ListPartitionReassignmentsTopics().setName("foo").
+                        setPartitionIndexes(asList(0, 1, 2))), Long.MAX_VALUE));
+
+        // test alter replica factor not allow to change when partition reassignment is ongoing
+        ControllerResult<AlterPartitionReassignmentsResponseData> alterReassigningResult =
+                replication.alterPartitionReassignments(
+                        new AlterPartitionReassignmentsRequestData().setTopics(singletonList(
+                                new ReassignableTopic().setName("foo").setPartitions(singletonList(
+                                        new ReassignablePartition().setPartitionIndex(0).setReplicas(asList(0, 1)))))).
+                                setAllowReplicationFactorChange(false));
+        assertEquals(new AlterPartitionReassignmentsResponseData().
+                        setErrorMessage(null).setAllowReplicationFactorChange(false).setResponses(singletonList(
+                                new ReassignableTopicResponse().setName("foo").setPartitions(singletonList(
+                                        new ReassignablePartitionResponse().setPartitionIndex(0).
+                                                setErrorCode(INVALID_REPLICATION_FACTOR.code()).
+                                                setErrorMessage("The replication factor is changed from 3 to 2"))))),
+                alterReassigningResult.response());
+
+        ControllerResult<AlterPartitionReassignmentsResponseData> alterReassigningResult2 =
+                replication.alterPartitionReassignments(
+                        new AlterPartitionReassignmentsRequestData().setTopics(singletonList(
+                                        new ReassignableTopic().setName("foo").setPartitions(singletonList(
+                                                new ReassignablePartition().setPartitionIndex(0).setReplicas(asList(0, 2, 3)))))).
+                                setAllowReplicationFactorChange(false));
+        assertEquals(new AlterPartitionReassignmentsResponseData().
+                        setErrorMessage(null).setAllowReplicationFactorChange(false).setResponses(singletonList(
+                                new ReassignableTopicResponse().setName("foo").setPartitions(singletonList(
+                                        new ReassignablePartitionResponse().setPartitionIndex(0).
+                                                setErrorMessage(null))))),
+                alterReassigningResult2.response());
+    }
+
+    @ParameterizedTest
+    @ApiKeyVersionsSource(apiKey = ApiKeys.ALTER_PARTITION)
+    public void testDisallowReplicationFactorChangeNoEffectWhenCancelAlterPartition(short version) {
+        MetadataVersion metadataVersion = MetadataVersion.latestTesting();
+        ReplicationControlTestContext ctx = new ReplicationControlTestContext.Builder()
+                .setMetadataVersion(metadataVersion)
+                .build();
+        ReplicationControlManager replication = ctx.replicationControl;
+        ctx.registerBrokers(0, 1, 2, 3);
+        ctx.unfenceBrokers(0, 1, 2, 3);
+        ctx.createTestTopic("foo", new int[][] {new int[] {1, 2, 3}}).topicId();
+
+        ControllerResult<AlterPartitionReassignmentsResponseData> alterResult =
+                replication.alterPartitionReassignments(
+                        new AlterPartitionReassignmentsRequestData().setTopics(singletonList(
+                                        new ReassignableTopic().setName("foo").setPartitions(singletonList(
+                                                new ReassignablePartition().setPartitionIndex(0).
+                                                        setReplicas(asList(0, 1, 2)))))));
+        assertEquals(new AlterPartitionReassignmentsResponseData().
+                        setErrorMessage(null).setResponses(singletonList(
+                                new ReassignableTopicResponse().setName("foo").setPartitions(singletonList(
+                                        new ReassignablePartitionResponse().setPartitionIndex(0).setErrorMessage(null))))),
+                alterResult.response());
+        ctx.replay(alterResult.records());
+        ListPartitionReassignmentsResponseData currentReassigning =
+                new ListPartitionReassignmentsResponseData().setErrorMessage(null).
+                        setTopics(singletonList(new OngoingTopicReassignment().
+                                setName("foo").setPartitions(singletonList(
+                                        new OngoingPartitionReassignment().setPartitionIndex(0).
+                                                setRemovingReplicas(singletonList(3)).
path: metadata/src/test/java/org/apache/kafka/controller/ReplicationControlManagerTest.java
user: login: clolov
body: Nit: Same comment as in the previous test
created_at: 2025-02-21T11:38:10Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1922,6 +1922,135 @@ public void testReassignPartitions(short version) {
         assertEquals(NONE_REASSIGNING, replication.listPartitionReassignments(null, Long.MAX_VALUE));
     }
 
+    @ParameterizedTest
+    @ApiKeyVersionsSource(apiKey = ApiKeys.ALTER_PARTITION)
+    public void testAlterPartitionDisallowReplicationFactorChange(short version) {
path: metadata/src/test/java/org/apache/kafka/controller/ReplicationControlManagerTest.java
user: login: clolov
body: Maybe there already are such tests, in which case please point them out to me, but if not, could you add the same tests but with `setAllowReplicationFactorChange(true)`?
created_at: 2025-02-21T11:43:16Z
author_association: CONTRIBUTOR
diff_hunk: @@ -28,4 +28,25 @@
  */
 @InterfaceStability.Evolving
 public class AlterPartitionReassignmentsOptions extends AbstractOptions<AlterPartitionReassignmentsOptions> {
+
+    private Boolean allowReplicationFactorChange = true;
+
+    /**
+     * Set the option indicating if the alter partition reassignments call should be
+     * allowed to alter the replication factor of a partition.
+     * In cases where it is not allowed, any replication factor change will result in an exception thrown by the API.
+     */
+    public boolean allowReplicationFactorChange(boolean allow) {
+        this.allowReplicationFactorChange = allow;
+        return allowReplicationFactorChange;
path: clients/src/main/java/org/apache/kafka/clients/admin/AlterPartitionReassignmentsOptions.java
user: login: chia7712
body: +1 to fluent pattern
created_at: 2025-02-25T06:22:19Z
author_association: MEMBER
diff_hunk: @@ -28,4 +28,25 @@
  */
 @InterfaceStability.Evolving
 public class AlterPartitionReassignmentsOptions extends AbstractOptions<AlterPartitionReassignmentsOptions> {
+
+    private Boolean allowReplicationFactorChange = true;
path: clients/src/main/java/org/apache/kafka/clients/admin/AlterPartitionReassignmentsOptions.java
user: login: chia7712
body: `boolean`
created_at: 2025-02-25T06:22:35Z
author_association: MEMBER
diff_hunk: @@ -3452,11 +3452,26 @@ class PlaintextAdminIntegrationTest extends BaseAdminIntegrationTest {
     val tp3 = new TopicPartition(topic, 2)
     createTopic(topic, numPartitions = 4)
 
-
     val validAssignment = Optional.of(new NewPartitionReassignment(
       (0 until brokerCount).map(_.asInstanceOf[Integer]).asJava
     ))
 
+    val alterOptions = new AlterPartitionReassignmentsOptions
+    alterOptions.allowReplicationFactorChange(false)
+    val alterReplicaNumberTo1 = Optional.of(new NewPartitionReassignment(List(1.asInstanceOf[Integer]).asJava))
+    val alterReplicaNumberTo2 = Optional.of(new NewPartitionReassignment((0 until brokerCount - 1).map(_.asInstanceOf[Integer]).asJava))
+    val alterReplicaNumberTo3 = Optional.of(new NewPartitionReassignment((0 until brokerCount).map(_.asInstanceOf[Integer]).asJava))
+    val alterReplicaResults = client.alterPartitionReassignments(Map(
+      tp1 -> alterReplicaNumberTo1,
+      tp2 -> alterReplicaNumberTo2,
+      tp3 -> alterReplicaNumberTo3,
+    ).asJava, alterOptions).values()
+    assertDoesNotThrow(() => alterReplicaResults.get(tp1).get())
+    assertEquals("The replication factor is changed from 1 to 2",
+      assertFutureThrows(classOf[InvalidReplicationFactorException], alterReplicaResults.get(tp2)).getMessage)
+    assertEquals("The replication factor is changed from 1 to 3",
path: core/src/test/scala/integration/kafka/api/PlaintextAdminIntegrationTest.scala
user: login: chia7712
body: Could you please add a test of decreasing the RF?
created_at: 2025-02-25T06:33:46Z
author_association: MEMBER
diff_hunk: @@ -28,4 +28,25 @@
  */
 @InterfaceStability.Evolving
 public class AlterPartitionReassignmentsOptions extends AbstractOptions<AlterPartitionReassignmentsOptions> {
+
+    private Boolean allowReplicationFactorChange = true;
+
+    /**
+     * Set the option indicating if the alter partition reassignments call should be
+     * allowed to alter the replication factor of a partition.
+     * In cases where it is not allowed, any replication factor change will result in an exception thrown by the API.
+     */
path: clients/src/main/java/org/apache/kafka/clients/admin/AlterPartitionReassignmentsOptions.java
user: login: frankvicky
body: The javadoc is almost the same as one of the getter.
We could move it to the field and leverage `@see` for people who want to know about details.

For example:
```java
    /**
     * A boolean indicating if the alter partition reassignments should be
     * allowed to alter the replication factor of a partition.
     * In cases where it is not allowed, any replication factor change will result in an exception thrown by the API.
     */
    private boolean allowReplicationFactorChange = true;

    /**
     * @see AlterPartitionReassignmentsOptions#allowReplicationFactorChange
     */
    public boolean allowReplicationFactorChange(boolean allow) {
        this.allowReplicationFactorChange = allow;
        return allowReplicationFactorChange;
    }

    /**
     * @see AlterPartitionReassignmentsOptions#allowReplicationFactorChange
     */
    public boolean allowReplicationFactorChange() {
        return this.allowReplicationFactorChange;
    }
```


created_at: 2025-02-25T14:53:58Z
author_association: CONTRIBUTOR
diff_hunk: @@ -2406,6 +2414,31 @@ private void updatePartitionInfo(
             newPartInfo.elr);
     }
 
+    private void validatePartitionReplicationFactorUnchanged(
+            PartitionRegistration part,
path: metadata/src/main/java/org/apache/kafka/controller/ReplicationControlManager.java
user: login: brandboat
body: The code style in this class is somewhat inconsistent, and the formatting isn't fully aligned. That said, I can address this comment. Perhaps in the future, we could consider adding a checkstyle rule to standardize the argument alignment.
created_at: 2025-03-02T14:55:49Z
author_association: MEMBER
diff_hunk: @@ -28,4 +28,25 @@
  */
 @InterfaceStability.Evolving
 public class AlterPartitionReassignmentsOptions extends AbstractOptions<AlterPartitionReassignmentsOptions> {
+
+    private Boolean allowReplicationFactorChange = true;
path: clients/src/main/java/org/apache/kafka/clients/admin/AlterPartitionReassignmentsOptions.java
user: login: brandboat
body: This is defined in the KIP, but I'm happy to modify it.
created_at: 2025-03-02T15:37:11Z
author_association: MEMBER
diff_hunk: @@ -28,4 +28,25 @@
  */
 @InterfaceStability.Evolving
 public class AlterPartitionReassignmentsOptions extends AbstractOptions<AlterPartitionReassignmentsOptions> {
+
+    private Boolean allowReplicationFactorChange = true;
+
+    /**
+     * Set the option indicating if the alter partition reassignments call should be
+     * allowed to alter the replication factor of a partition.
+     * In cases where it is not allowed, any replication factor change will result in an exception thrown by the API.
+     */
path: clients/src/main/java/org/apache/kafka/clients/admin/AlterPartitionReassignmentsOptions.java
user: login: brandboat
body: It seems that this follows a pattern used in other xxxOptions, such as `CreateTopicsOptions.java`, `DeleteTopicsOptions.java`, and `DescribeClusterOptions.java`, etc. While modifying it could help reduce repetition in the description, I prefer to keep it as is to maintain consistency with other xxxOptions. Thanks!
created_at: 2025-03-02T15:46:09Z
author_association: MEMBER
diff_hunk: @@ -1922,6 +1922,135 @@ public void testReassignPartitions(short version) {
         assertEquals(NONE_REASSIGNING, replication.listPartitionReassignments(null, Long.MAX_VALUE));
     }
 
+    @ParameterizedTest
+    @ApiKeyVersionsSource(apiKey = ApiKeys.ALTER_PARTITION)
+    public void testAlterPartitionDisallowReplicationFactorChange(short version) {
path: metadata/src/test/java/org/apache/kafka/controller/ReplicationControlManagerTest.java
user: login: brandboat
body: Yes, we have `testReassignPartitions` and `testCancelReassignPartitions`. I believe these 2 tests already cover the scenario where altering the replication factor is possible when `allowReplicationFactorChange` is true (default value is true).
created_at: 2025-03-02T16:19:10Z
author_association: MEMBER
diff_hunk: @@ -432,6 +433,21 @@ topicPartition, new PartitionReassignmentState(asList(0, 1, 2), asList(0, 1, 2),
         }
     }
 
+    @ClusterTest
+    public void testDisallowReplicationFactorChange() {
path: tools/src/test/java/org/apache/kafka/tools/reassign/ReassignPartitionsCommandTest.java
user: login: brandboat
body: Sure!
created_at: 2025-03-02T16:33:59Z
author_association: MEMBER
diff_hunk: @@ -3452,11 +3452,26 @@ class PlaintextAdminIntegrationTest extends BaseAdminIntegrationTest {
     val tp3 = new TopicPartition(topic, 2)
     createTopic(topic, numPartitions = 4)
 
-
     val validAssignment = Optional.of(new NewPartitionReassignment(
       (0 until brokerCount).map(_.asInstanceOf[Integer]).asJava
     ))
 
+    val alterOptions = new AlterPartitionReassignmentsOptions
+    alterOptions.allowReplicationFactorChange(false)
+    val alterReplicaNumberTo1 = Optional.of(new NewPartitionReassignment(List(1.asInstanceOf[Integer]).asJava))
+    val alterReplicaNumberTo2 = Optional.of(new NewPartitionReassignment((0 until brokerCount - 1).map(_.asInstanceOf[Integer]).asJava))
+    val alterReplicaNumberTo3 = Optional.of(new NewPartitionReassignment((0 until brokerCount).map(_.asInstanceOf[Integer]).asJava))
+    val alterReplicaResults = client.alterPartitionReassignments(Map(
+      tp1 -> alterReplicaNumberTo1,
+      tp2 -> alterReplicaNumberTo2,
+      tp3 -> alterReplicaNumberTo3,
+    ).asJava, alterOptions).values()
+    assertDoesNotThrow(() => alterReplicaResults.get(tp1).get())
+    assertEquals("The replication factor is changed from 1 to 2",
+      assertFutureThrows(classOf[InvalidReplicationFactorException], alterReplicaResults.get(tp2)).getMessage)
+    assertEquals("The replication factor is changed from 1 to 3",
path: core/src/test/scala/integration/kafka/api/PlaintextAdminIntegrationTest.scala
user: login: brandboat
body: Sure! I modify the replication factor from 1 to 2 in this test case. 
created_at: 2025-03-02T16:39:41Z
author_association: MEMBER
diff_hunk: @@ -1922,6 +1922,135 @@ public void testReassignPartitions(short version) {
         assertEquals(NONE_REASSIGNING, replication.listPartitionReassignments(null, Long.MAX_VALUE));
     }
 
+    @ParameterizedTest
+    @ApiKeyVersionsSource(apiKey = ApiKeys.ALTER_PARTITION)
+    public void testAlterPartitionDisallowReplicationFactorChange(short version) {
+        MetadataVersion metadataVersion = MetadataVersion.latestTesting();
+        ReplicationControlTestContext ctx = new ReplicationControlTestContext.Builder()
+                .setMetadataVersion(metadataVersion)
+                .build();
+        ReplicationControlManager replication = ctx.replicationControl;
+        ctx.registerBrokers(0, 1, 2, 3);
+        ctx.unfenceBrokers(0, 1, 2, 3);
+        ctx.createTestTopic("foo", new int[][] {new int[] {0, 1, 2}, new int[] {0, 1, 2}, new int[] {0, 1, 2}});
+
+        ControllerResult<AlterPartitionReassignmentsResponseData> alterResult =
+                replication.alterPartitionReassignments(
+                        new AlterPartitionReassignmentsRequestData().setTopics(singletonList(
+                                new ReassignableTopic().setName("foo").setPartitions(asList(
+                                        new ReassignablePartition().setPartitionIndex(0).
+                                                setReplicas(asList(1, 2, 3)),
path: metadata/src/test/java/org/apache/kafka/controller/ReplicationControlManagerTest.java
user: login: frankvicky
body: nit: `List.of`
created_at: 2025-03-03T15:14:31Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1922,6 +1922,135 @@ public void testReassignPartitions(short version) {
         assertEquals(NONE_REASSIGNING, replication.listPartitionReassignments(null, Long.MAX_VALUE));
     }
 
+    @ParameterizedTest
+    @ApiKeyVersionsSource(apiKey = ApiKeys.ALTER_PARTITION)
+    public void testAlterPartitionDisallowReplicationFactorChange(short version) {
+        MetadataVersion metadataVersion = MetadataVersion.latestTesting();
+        ReplicationControlTestContext ctx = new ReplicationControlTestContext.Builder()
+                .setMetadataVersion(metadataVersion)
+                .build();
+        ReplicationControlManager replication = ctx.replicationControl;
+        ctx.registerBrokers(0, 1, 2, 3);
+        ctx.unfenceBrokers(0, 1, 2, 3);
+        ctx.createTestTopic("foo", new int[][] {new int[] {0, 1, 2}, new int[] {0, 1, 2}, new int[] {0, 1, 2}});
+
+        ControllerResult<AlterPartitionReassignmentsResponseData> alterResult =
+                replication.alterPartitionReassignments(
+                        new AlterPartitionReassignmentsRequestData().setTopics(singletonList(
+                                new ReassignableTopic().setName("foo").setPartitions(asList(
+                                        new ReassignablePartition().setPartitionIndex(0).
+                                                setReplicas(asList(1, 2, 3)),
path: metadata/src/test/java/org/apache/kafka/controller/ReplicationControlManagerTest.java
user: login: brandboat
body: I've given this some thought, and I agree with you that List.of is a better choice. However, since this test class consistently uses Arrays.asList, perhaps we can open a separate minor PR to address this improvement. As you suggested, we can replace asList with List.of in this test class.
created_at: 2025-03-03T15:25:18Z
author_association: MEMBER
diff_hunk: @@ -1922,6 +1922,135 @@ public void testReassignPartitions(short version) {
         assertEquals(NONE_REASSIGNING, replication.listPartitionReassignments(null, Long.MAX_VALUE));
     }
 
+    @ParameterizedTest
+    @ApiKeyVersionsSource(apiKey = ApiKeys.ALTER_PARTITION)
+    public void testAlterPartitionDisallowReplicationFactorChange(short version) {
+        MetadataVersion metadataVersion = MetadataVersion.latestTesting();
+        ReplicationControlTestContext ctx = new ReplicationControlTestContext.Builder()
+                .setMetadataVersion(metadataVersion)
+                .build();
+        ReplicationControlManager replication = ctx.replicationControl;
+        ctx.registerBrokers(0, 1, 2, 3);
+        ctx.unfenceBrokers(0, 1, 2, 3);
+        ctx.createTestTopic("foo", new int[][] {new int[] {0, 1, 2}, new int[] {0, 1, 2}, new int[] {0, 1, 2}});
+
+        ControllerResult<AlterPartitionReassignmentsResponseData> alterResult =
+                replication.alterPartitionReassignments(
+                        new AlterPartitionReassignmentsRequestData().setTopics(singletonList(
+                                new ReassignableTopic().setName("foo").setPartitions(asList(
+                                        new ReassignablePartition().setPartitionIndex(0).
+                                                setReplicas(asList(1, 2, 3)),
path: metadata/src/test/java/org/apache/kafka/controller/ReplicationControlManagerTest.java
user: login: frankvicky
body: Make sense 
created_at: 2025-03-03T15:33:31Z
author_association: CONTRIBUTOR

PR comments:
user: login: clolov
created_at: 2025-03-03T15:00:21Z
body: Heya, thanks for the updated code! I will have a look at it tomorrow!
author_association: CONTRIBUTOR
user: login: brandboat
created_at: 2025-03-03T15:03:53Z
body: > Heya, thanks for the updated code! I will have a look at it tomorrow!

Thank you!
author_association: MEMBER
user: login: brandboat
created_at: 2025-03-04T14:39:30Z
body: > Could you just reduce the length of the PR title because a linter is complaining? I will also leave this open for another day in case any of the other reviewers have blocking comments

I'm surprised that we have this linter. If I understand correctly, the PR title should match the JIRA title, right? Does that mean I need to shorten the JIRA title to maintain consistency?
author_association: MEMBER
user: login: clolov
created_at: 2025-03-05T11:21:50Z
body: That's a good question, I will merge as-is and reachout to people to hear their opinions.
author_association: CONTRIBUTOR
user: login: clolov
created_at: 2025-03-05T11:24:12Z
body: I will also let you resolve the JIRA appropriately and move the KIP to the correct section + update the Kafka version in which it will be going out!
author_association: CONTRIBUTOR
user: login: brandboat
created_at: 2025-03-05T16:41:59Z
body: > I will also let you resolve the JIRA appropriately and move the KIP to the correct section + update the Kafka version in which it will be going out!

Thank you for merging this PR @clolov! I’ve updated the KIP and marked the JIRA as resolved, with the target version set to 4.1.0.
author_association: MEMBER