Repository: elastic/elasticsearch
Pull request #126091: Allow balancing weights to be set per tier
Author: login: nicktindall
Labels: name: >enhancement
name: :Distributed/Allocation
description: All issues relating to the decision making around placing a shard (both master logic & on the nodes)
name: serverless-linked
description: Added by automation, don't add manually
name: Team:Distributed Coordination (obsolete)
description: Meta label for Distributed Coordination team. Obsolete. Please do not use.
name: v9.1.0
Created: 2025-04-02T07:07:46Z | Merged: 2025-04-28T07:56:49Z

@henningandersen [made the good point](https://github.com/elastic/elasticsearch/pull/125824#discussion_r2018172817) on my first attempt at this, that that approach did make some assumptions about the way the allocation deciders would govern shard movement. This iteration is an attempt to make those assumptions more explicit by introducing actual partitioning of the nodes.

## Approach

Instead of assuming the `BalancedShardsAllocator` applies to the entire cluster, I've added the concept of "partitions" into the balancing. The partitions must be mutually disjoint subsets of the shards and nodes - i.e. the set of shards in a partition are only ever allocated to the set of nodes in the same partition, as is the case in serverless. `WeightFunction`s and `NodeSorter`s are scoped to partitions.

The status quo behaviour is defined by the `GlobalPartitionedClusterFactory`, it produces a single global partition. The serverless version is called `TieredPartitionedClusterFactory` and will define a partition for each of the search and indexing tiers.

The serverless code is in here for now, but it will be moved to serverless if we think this is a sound approach.

## Alternatives
It would be nice if we could just do the partitioning on the `RoutingAllocation` and just run a `Balancer` for each of the partitions (search and indexing), but the contents of e.g. `Metadata` and `RoutingNodes` is so heavily cross-referenced and aggregated it might be tricky to pull apart.

A better approach might be to do the partitioning at the point that e.g. the `RoutingNodes` is being generated from the `GlobalRoutingTable`. But that seems like a more impactful refactor. 


Diff:
diff --git a/docs/changelog/126091.yaml b/docs/changelog/126091.yaml
new file mode 100644
index 0000000000000..beba808c96fc6
--- /dev/null
+++ b/docs/changelog/126091.yaml
@@ -0,0 +1,5 @@
+pr: 126091
+summary: Allow balancing weights to be set per tier
+area: Allocation
+type: enhancement
+issues: []
diff --git a/server/src/main/java/org/elasticsearch/cluster/ClusterModule.java b/server/src/main/java/org/elasticsearch/cluster/ClusterModule.java
index e0c78780c3b3f..33a7139213f93 100644
--- a/server/src/main/java/org/elasticsearch/cluster/ClusterModule.java
+++ b/server/src/main/java/org/elasticsearch/cluster/ClusterModule.java
@@ -38,8 +38,10 @@
 import org.elasticsearch.cluster.routing.allocation.WriteLoadForecaster;
 import org.elasticsearch.cluster.routing.allocation.allocator.BalancedShardsAllocator;
 import org.elasticsearch.cluster.routing.allocation.allocator.BalancerSettings;
+import org.elasticsearch.cluster.routing.allocation.allocator.BalancingWeightsFactory;
 import org.elasticsearch.cluster.routing.allocation.allocator.DesiredBalanceShardsAllocator;
 import org.elasticsearch.cluster.routing.allocation.allocator.DesiredBalanceShardsAllocator.DesiredBalanceReconcilerAction;
+import org.elasticsearch.cluster.routing.allocation.allocator.GlobalBalancingWeightsFactory;
 import org.elasticsearch.cluster.routing.allocation.allocator.ShardsAllocator;
 import org.elasticsearch.cluster.routing.allocation.decider.AllocationDecider;
 import org.elasticsearch.cluster.routing.allocation.decider.AllocationDeciders;
@@ -146,11 +148,20 @@ public ClusterModule(
         this.deciderList = createAllocationDeciders(settings, clusterService.getClusterSettings(), clusterPlugins);
         this.allocationDeciders = new AllocationDeciders(deciderList);
         final BalancerSettings balancerSettings = new BalancerSettings(clusterService.getClusterSettings());
-        var nodeAllocationStatsAndWeightsCalculator = new NodeAllocationStatsAndWeightsCalculator(writeLoadForecaster, balancerSettings);
+        final BalancingWeightsFactory balancingWeightsFactory = getBalancingWeightsFactory(
+            clusterPlugins,
+            balancerSettings,
+            clusterService.getClusterSettings()
+        );
+        var nodeAllocationStatsAndWeightsCalculator = new NodeAllocationStatsAndWeightsCalculator(
+            writeLoadForecaster,
+            balancingWeightsFactory
+        );
         this.shardsAllocator = createShardsAllocator(
             settings,
             clusterService.getClusterSettings(),
             balancerSettings,
+            balancingWeightsFactory,
             threadPool,
             clusterPlugins,
             clusterService,
@@ -203,6 +214,22 @@ public ShardRouting.Role newEmptyRole(int copyIndex) {
         };
     }
 
+    static BalancingWeightsFactory getBalancingWeightsFactory(
+        List<ClusterPlugin> clusterPlugins,
+        BalancerSettings balancerSettings,
+        ClusterSettings clusterSettings
+    ) {
+        final var strategies = clusterPlugins.stream()
+            .map(pl -> pl.getBalancingWeightsFactory(balancerSettings, clusterSettings))
+            .filter(Objects::nonNull)
+            .toList();
+        return switch (strategies.size()) {
+            case 0 -> new GlobalBalancingWeightsFactory(balancerSettings);
+            case 1 -> strategies.getFirst();
+            default -> throw new IllegalArgumentException("multiple plugins define balancing weights factories, which is not permitted");
+        };
+    }
+
     private ClusterState reconcile(ClusterState clusterState, RerouteStrategy rerouteStrategy) {
         return allocationService.executeWithRoutingAllocation(clusterState, "reconcile-desired-balance", rerouteStrategy);
     }
@@ -439,6 +466,7 @@ private static ShardsAllocator createShardsAllocator(
         Settings settings,
         ClusterSettings clusterSettings,
         BalancerSettings balancerSettings,
+        BalancingWeightsFactory balancingWeightsFactory,
         ThreadPool threadPool,
         List<ClusterPlugin> clusterPlugins,
         ClusterService clusterService,
@@ -448,12 +476,15 @@ private static ShardsAllocator createShardsAllocator(
         NodeAllocationStatsAndWeightsCalculator nodeAllocationStatsAndWeightsCalculator
     ) {
         Map<String, Supplier<ShardsAllocator>> allocators = new HashMap<>();
-        allocators.put(BALANCED_ALLOCATOR, () -> new BalancedShardsAllocator(balancerSettings, writeLoadForecaster));
+        allocators.put(
+            BALANCED_ALLOCATOR,
+            () -> new BalancedShardsAllocator(balancerSettings, writeLoadForecaster, balancingWeightsFactory)
+        );
         allocators.put(
             DESIRED_BALANCE_ALLOCATOR,
             () -> new DesiredBalanceShardsAllocator(
                 clusterSettings,
-                new BalancedShardsAllocator(balancerSettings, writeLoadForecaster),
+                new BalancedShardsAllocator(balancerSettings, writeLoadForecaster, balancingWeightsFactory),
                 threadPool,
                 clusterService,
                 reconciler,
diff --git a/server/src/main/java/org/elasticsearch/cluster/routing/allocation/NodeAllocationStatsAndWeightsCalculator.java b/server/src/main/java/org/elasticsearch/cluster/routing/allocation/NodeAllocationStatsAndWeightsCalculator.java
index f85b97125ceba..21e006f76b1d1 100644
--- a/server/src/main/java/org/elasticsearch/cluster/routing/allocation/NodeAllocationStatsAndWeightsCalculator.java
+++ b/server/src/main/java/org/elasticsearch/cluster/routing/allocation/NodeAllocationStatsAndWeightsCalculator.java
@@ -15,7 +15,8 @@
 import org.elasticsearch.cluster.routing.RoutingNode;
 import org.elasticsearch.cluster.routing.RoutingNodes;
 import org.elasticsearch.cluster.routing.ShardRouting;
-import org.elasticsearch.cluster.routing.allocation.allocator.BalancerSettings;
+import org.elasticsearch.cluster.routing.allocation.allocator.BalancingWeights;
+import org.elasticsearch.cluster.routing.allocation.allocator.BalancingWeightsFactory;
 import org.elasticsearch.cluster.routing.allocation.allocator.DesiredBalance;
 import org.elasticsearch.cluster.routing.allocation.allocator.WeightFunction;
 import org.elasticsearch.common.util.Maps;
@@ -28,7 +29,7 @@
  */
 public class NodeAllocationStatsAndWeightsCalculator {
     private final WriteLoadForecaster writeLoadForecaster;
-    private final BalancerSettings balancerSettings;
+    private final BalancingWeightsFactory balancingWeightsFactory;
 
     /**
      * Node shard allocation stats and the total node weight.
@@ -42,9 +43,12 @@ public record NodeAllocationStatsAndWeight(
         float currentNodeWeight
     ) {}
 
-    public NodeAllocationStatsAndWeightsCalculator(WriteLoadForecaster writeLoadForecaster, BalancerSettings balancerSettings) {
+    public NodeAllocationStatsAndWeightsCalculator(
+        WriteLoadForecaster writeLoadForecaster,
+        BalancingWeightsFactory balancingWeightsFactory
+    ) {
         this.writeLoadForecaster = writeLoadForecaster;
-        this.balancerSettings = balancerSettings;
+        this.balancingWeightsFactory = balancingWeightsFactory;
     }
 
     /**
@@ -60,18 +64,14 @@ public Map<String, NodeAllocationStatsAndWeight> nodesAllocationStatsAndWeights(
             // must not use licensed features when just starting up
             writeLoadForecaster.refreshLicense();
         }
-        var weightFunction = new WeightFunction(
-            balancerSettings.getShardBalanceFactor(),
-            balancerSettings.getIndexBalanceFactor(),
-            balancerSettings.getWriteLoadBalanceFactor(),
-            balancerSettings.getDiskUsageBalanceFactor()
-        );
+        final BalancingWeights balancingWeights = balancingWeightsFactory.create();
         var avgShardsPerNode = WeightFunction.avgShardPerNode(metadata, routingNodes);
         var avgWriteLoadPerNode = WeightFunction.avgWriteLoadPerNode(writeLoadForecaster, metadata, routingNodes);
         var avgDiskUsageInBytesPerNode = WeightFunction.avgDiskUsageInBytesPerNode(clusterInfo, metadata, routingNodes);
 
         var nodeAllocationStatsAndWeights = Maps.<String, NodeAllocationStatsAndWeight>newMapWithExpectedSize(routingNodes.size());
         for (RoutingNode node : routingNodes) {
+            WeightFunction weightFunction = balancingWeights.weightFunctionForNode(node);
             int shards = 0;
             int undesiredShards = 0;
             double forecastedWriteLoad = 0.0;
diff --git a/server/src/main/java/org/elasticsearch/cluster/routing/allocation/allocator/BalancedShardsAllocator.java b/server/src/main/java/org/elasticsearch/cluster/routing/allocation/allocator/BalancedShardsAllocator.java
index 4173ff930cfef..2681c588dcf3f 100644
--- a/server/src/main/java/org/elasticsearch/cluster/routing/allocation/allocator/BalancedShardsAllocator.java
+++ b/server/src/main/java/org/elasticsearch/cluster/routing/allocation/allocator/BalancedShardsAllocator.java
@@ -114,6 +114,7 @@ public class BalancedShardsAllocator implements ShardsAllocator {
 
     private final BalancerSettings balancerSettings;
     private final WriteLoadForecaster writeLoadForecaster;
+    private final BalancingWeightsFactory balancingWeightsFactory;
 
     public BalancedShardsAllocator() {
         this(Settings.EMPTY);
@@ -123,10 +124,19 @@ public BalancedShardsAllocator(Settings settings) {
         this(new BalancerSettings(settings), WriteLoadForecaster.DEFAULT);
     }
 
-    @Inject
     public BalancedShardsAllocator(BalancerSettings balancerSettings, WriteLoadForecaster writeLoadForecaster) {
+        this(balancerSettings, writeLoadForecaster, new GlobalBalancingWeightsFactory(balancerSettings));
+    }
+
+    @Inject
+    public BalancedShardsAllocator(
+        BalancerSettings balancerSettings,
+        WriteLoadForecaster writeLoadForecaster,
+        BalancingWeightsFactory balancingWeightsFactory
+    ) {
         this.balancerSettings = balancerSettings;
         this.writeLoadForecaster = writeLoadForecaster;
+        this.balancingWeightsFactory = balancingWeightsFactory;
     }
 
     @Override
@@ -142,25 +152,21 @@ public void allocate(RoutingAllocation allocation) {
             failAllocationOfNewPrimaries(allocation);
             return;
         }
-        final WeightFunction weightFunction = new WeightFunction(
-            balancerSettings.getShardBalanceFactor(),
-            balancerSettings.getIndexBalanceFactor(),
-            balancerSettings.getWriteLoadBalanceFactor(),
-            balancerSettings.getDiskUsageBalanceFactor()
-        );
-        final Balancer balancer = new Balancer(writeLoadForecaster, allocation, weightFunction, balancerSettings.getThreshold());
+        final BalancingWeights balancingWeights = balancingWeightsFactory.create();
+        final Balancer balancer = new Balancer(writeLoadForecaster, allocation, balancerSettings.getThreshold(), balancingWeights);
         balancer.allocateUnassigned();
         balancer.moveShards();
         balancer.balance();
 
         // Node weights are calculated after each internal balancing round and saved to the RoutingNodes copy.
-        collectAndRecordNodeWeightStats(balancer, weightFunction, allocation);
+        collectAndRecordNodeWeightStats(balancer, balancingWeights, allocation);
     }
 
-    private void collectAndRecordNodeWeightStats(Balancer balancer, WeightFunction weightFunction, RoutingAllocation allocation) {
+    private void collectAndRecordNodeWeightStats(Balancer balancer, BalancingWeights balancingWeights, RoutingAllocation allocation) {
         Map<DiscoveryNode, DesiredBalanceMetrics.NodeWeightStats> nodeLevelWeights = new HashMap<>();
         for (var entry : balancer.nodes.entrySet()) {
             var node = entry.getValue();
+            var weightFunction = balancingWeights.weightFunctionForNode(node.routingNode);
             var nodeWeight = weightFunction.calculateNodeWeight(
                 node.numShards(),
                 balancer.avgShardsPerNode(),
@@ -179,13 +185,12 @@ private void collectAndRecordNodeWeightStats(Balancer balancer, WeightFunction w
 
     @Override
     public ShardAllocationDecision decideShardAllocation(final ShardRouting shard, final RoutingAllocation allocation) {
-        WeightFunction weightFunction = new WeightFunction(
-            balancerSettings.getShardBalanceFactor(),
-            balancerSettings.getIndexBalanceFactor(),
-            balancerSettings.getWriteLoadBalanceFactor(),
-            balancerSettings.getDiskUsageBalanceFactor()
+        Balancer balancer = new Balancer(
+            writeLoadForecaster,
+            allocation,
+            balancerSettings.getThreshold(),
+            balancingWeightsFactory.create()
         );
-        Balancer balancer = new Balancer(writeLoadForecaster, allocation, weightFunction, balancerSettings.getThreshold());
         AllocateUnassignedDecision allocateUnassignedDecision = AllocateUnassignedDecision.NOT_TAKEN;
         MoveDecision moveDecision = MoveDecision.NOT_TAKEN;
         final ProjectIndex index = new ProjectIndex(allocation, shard);
@@ -236,32 +241,32 @@ public static class Balancer {
         private final RoutingAllocation allocation;
         private final RoutingNodes routingNodes;
         private final Metadata metadata;
-        private final WeightFunction weightFunction;
 
         private final float threshold;
         private final float avgShardsPerNode;
         private final double avgWriteLoadPerNode;
         private final double avgDiskUsageInBytesPerNode;
         private final Map<String, ModelNode> nodes;
-        private final NodeSorter sorter;
+        private final BalancingWeights balancingWeights;
+        private final NodeSorters nodeSorters;
 
         private Balancer(
             WriteLoadForecaster writeLoadForecaster,
             RoutingAllocation allocation,
-            WeightFunction weightFunction,
-            float threshold
+            float threshold,
+            BalancingWeights balancingWeights
         ) {
             this.writeLoadForecaster = writeLoadForecaster;
             this.allocation = allocation;
             this.routingNodes = allocation.routingNodes();
             this.metadata = allocation.metadata();
-            this.weightFunction = weightFunction;
             this.threshold = threshold;
             avgShardsPerNode = WeightFunction.avgShardPerNode(metadata, routingNodes);
             avgWriteLoadPerNode = WeightFunction.avgWriteLoadPerNode(writeLoadForecaster, metadata, routingNodes);
             avgDiskUsageInBytesPerNode = WeightFunction.avgDiskUsageInBytesPerNode(allocation.clusterInfo(), metadata, routingNodes);
             nodes = Collections.unmodifiableMap(buildModelFromAssigned());
-            sorter = newNodeSorter();
+            this.nodeSorters = balancingWeights.createNodeSorters(nodesArray(), this);
+            this.balancingWeights = balancingWeights;
         }
 
         private static long getShardDiskUsageInBytes(ShardRouting shardRouting, IndexMetadata indexMetadata, ClusterInfo clusterInfo) {
@@ -327,16 +332,6 @@ public double avgDiskUsageInBytesPerNode() {
             return avgDiskUsageInBytesPerNode;
         }
 
-        /**
-         * Returns a new {@link NodeSorter} that sorts the nodes based on their
-         * current weight with respect to the index passed to the sorter. The
-         * returned sorter is not sorted. Use {@link NodeSorter#reset(ProjectIndex)} )}
-         * to sort based on an index.
-         */
-        private NodeSorter newNodeSorter() {
-            return new NodeSorter(nodesArray(), weightFunction, this);
-        }
-
         /**
          * The absolute value difference between two weights.
          */
@@ -362,7 +357,7 @@ private IndexMetadata indexMetadata(ProjectIndex index) {
 
         /**
          * Balances the nodes on the cluster model according to the weight function.
-         * The actual balancing is delegated to {@link #balanceByWeights()}
+         * The actual balancing is delegated to {@link #balanceByWeights(NodeSorter)}
          */
         private void balance() {
             if (logger.isTraceEnabled()) {
@@ -387,7 +382,11 @@ private void balance() {
                 logger.trace("skipping rebalance as single node only");
                 return;
             }
-            balanceByWeights();
+
+            // Balance each partition
+            for (NodeSorter nodeSorter : nodeSorters) {
+                balanceByWeights(nodeSorter);
+            }
         }
 
         /**
@@ -396,6 +395,7 @@ private void balance() {
          * explain API only.
          */
         private MoveDecision decideRebalance(final ProjectIndex index, final ShardRouting shard, Decision canRemain) {
+            final NodeSorter sorter = nodeSorters.sorterForShard(shard);
             index.assertMatch(shard);
             if (shard.started() == false) {
                 // we can only rebalance started shards
@@ -418,7 +418,7 @@ private MoveDecision decideRebalance(final ProjectIndex index, final ShardRoutin
             assert currentNode != null : "currently assigned node could not be found";
 
             // balance the shard, if a better node can be found
-            final float currentWeight = weightFunction.calculateNodeWeightWithIndex(this, currentNode, index);
+            final float currentWeight = sorter.getWeightFunction().calculateNodeWeightWithIndex(this, currentNode, index);
             final AllocationDeciders deciders = allocation.deciders();
             Type rebalanceDecisionType = Type.NO;
             ModelNode targetNode = null;
@@ -434,7 +434,7 @@ private MoveDecision decideRebalance(final ProjectIndex index, final ShardRoutin
                 // this is a comparison of the number of shards on this node to the number of shards
                 // that should be on each node on average (both taking the cluster as a whole into account
                 // as well as shards per index)
-                final float nodeWeight = weightFunction.calculateNodeWeightWithIndex(this, node, index);
+                final float nodeWeight = sorter.getWeightFunction().calculateNodeWeightWithIndex(this, node, index);
                 // if the node we are examining has a worse (higher) weight than the node the shard is
                 // assigned to, then there is no way moving the shard to the node with the worse weight
                 // can make the balance of the cluster better, so we check for that here
@@ -532,11 +532,11 @@ private MoveDecision decideRebalance(final ProjectIndex index, final ShardRoutin
          * only, or in other words relocations that move the weight delta closer
          * to {@code 0.0}
          */
-        private void balanceByWeights() {
+        private void balanceByWeights(NodeSorter sorter) {
             final AllocationDeciders deciders = allocation.deciders();
             final ModelNode[] modelNodes = sorter.modelNodes;
             final float[] weights = sorter.weights;
-            for (var index : buildWeightOrderedIndices()) {
+            for (var index : buildWeightOrderedIndices(sorter)) {
                 IndexMetadata indexMetadata = indexMetadata(index);
 
                 // find nodes that have a shard of this index or where shards of this index are allowed to be allocated to,
@@ -667,7 +667,7 @@ private void balanceByWeights() {
          * average. To re-balance we need to move shards back eventually likely
          * to the nodes we relocated them from.
          */
-        private ProjectIndex[] buildWeightOrderedIndices() {
+        private ProjectIndex[] buildWeightOrderedIndices(NodeSorter sorter) {
             final ProjectIndex[] indices = allocation.globalRoutingTable()
                 .routingTables()
                 .entrySet()
@@ -765,6 +765,7 @@ public void moveShards() {
          *      {@link MoveDecision#getNodeDecisions} will have a non-null value.
          */
         public MoveDecision decideMove(final ProjectIndex index, final ShardRouting shardRouting) {
+            NodeSorter sorter = nodeSorters.sorterForShard(shardRouting);
             index.assertMatch(shardRouting);
 
             if (shardRouting.started() == false) {
@@ -787,17 +788,18 @@ public MoveDecision decideMove(final ProjectIndex index, final ShardRouting shar
              * This is not guaranteed to be balanced after this operation we still try best effort to
              * allocate on the minimal eligible node.
              */
-            MoveDecision moveDecision = decideMove(shardRouting, sourceNode, canRemain, this::decideCanAllocate);
+            MoveDecision moveDecision = decideMove(sorter, shardRouting, sourceNode, canRemain, this::decideCanAllocate);
             if (moveDecision.canRemain() == false && moveDecision.forceMove() == false) {
                 final boolean shardsOnReplacedNode = allocation.metadata().nodeShutdowns().contains(shardRouting.currentNodeId(), REPLACE);
                 if (shardsOnReplacedNode) {
-                    return decideMove(shardRouting, sourceNode, canRemain, this::decideCanForceAllocateForVacate);
+                    return decideMove(sorter, shardRouting, sourceNode, canRemain, this::decideCanForceAllocateForVacate);
                 }
             }
             return moveDecision;
         }
 
         private MoveDecision decideMove(
+            NodeSorter sorter,
             ShardRouting shardRouting,
             ModelNode sourceNode,
             Decision remainDecision,
@@ -998,6 +1000,7 @@ private ProjectIndex projectIndex(ShardRouting shardRouting) {
          * is of type {@link Type#NO}, then the assigned node will be null.
          */
         private AllocateUnassignedDecision decideAllocateUnassigned(final ProjectIndex index, final ShardRouting shard) {
+            WeightFunction weightFunction = balancingWeights.weightFunctionForShard(shard);
             index.assertMatch(shard);
             if (shard.assignedToNode()) {
                 // we only make decisions for unassigned shards here
@@ -1143,7 +1146,7 @@ private boolean tryRelocateShard(ModelNode minNode, ModelNode maxNode, ProjectIn
         }
     }
 
-    static class ModelNode implements Iterable<ModelIndex> {
+    public static class ModelNode implements Iterable<ModelIndex> {
         private int numShards = 0;
         private double writeLoad = 0.0;
         private double diskUsageInBytes = 0.0;
@@ -1153,7 +1156,7 @@ static class ModelNode implements Iterable<ModelIndex> {
         private final RoutingNode routingNode;
         private final Map<ProjectIndex, ModelIndex> indices;
 
-        ModelNode(WriteLoadForecaster writeLoadForecaster, Metadata metadata, ClusterInfo clusterInfo, RoutingNode routingNode) {
+        public ModelNode(WriteLoadForecaster writeLoadForecaster, Metadata metadata, ClusterInfo clusterInfo, RoutingNode routingNode) {
             this.writeLoadForecaster = writeLoadForecaster;
             this.metadata = metadata;
             this.clusterInfo = clusterInfo;
@@ -1286,7 +1289,15 @@ public boolean containsShard(ShardRouting shard) {
         }
     }
 
-    static final class NodeSorter extends IntroSorter {
+    /**
+     * A NodeSorter sorts the set of nodes for a single partition using the {@link WeightFunction}
+     * for that partition. In partitioned cluster topologies there will be one for each partition
+     * (e.g. search/indexing in stateless). By default, there is a single partition containing
+     * a single weight function that applies to all nodes and shards.
+     *
+     * @see BalancingWeightsFactory
+     */
+    public static final class NodeSorter extends IntroSorter {
 
         final ModelNode[] modelNodes;
         /* the nodes weights with respect to the current weight function / index */
@@ -1296,7 +1307,7 @@ static final class NodeSorter extends IntroSorter {
         private final Balancer balancer;
         private float pivotWeight;
 
-        NodeSorter(ModelNode[] modelNodes, WeightFunction function, Balancer balancer) {
+        public NodeSorter(ModelNode[] modelNodes, WeightFunction function, Balancer balancer) {
             this.function = function;
             this.balancer = balancer;
             this.modelNodes = modelNodes;
@@ -1353,7 +1364,11 @@ protected int comparePivot(int j) {
         }
 
         public float delta() {
-            return weights[weights.length - 1] - weights[0];
+            return weights.length == 0 ? 0.0f : weights[weights.length - 1] - weights[0];
+        }
+
+        public WeightFunction getWeightFunction() {
+            return function;
         }
     }
 
diff --git a/server/src/main/java/org/elasticsearch/cluster/routing/allocation/allocator/BalancingWeights.java b/server/src/main/java/org/elasticsearch/cluster/routing/allocation/allocator/BalancingWeights.java
new file mode 100644
index 0000000000000..b4ee53b996526
--- /dev/null
+++ b/server/src/main/java/org/elasticsearch/cluster/routing/allocation/allocator/BalancingWeights.java
@@ -0,0 +1,45 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the "Elastic License
+ * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
+ * Public License v 1"; you may not use this file except in compliance with, at
+ * your election, the "Elastic License 2.0", the "GNU Affero General Public
+ * License v3.0 only", or the "Server Side Public License, v 1".
+ */
+
+package org.elasticsearch.cluster.routing.allocation.allocator;
+
+import org.elasticsearch.cluster.routing.RoutingNode;
+import org.elasticsearch.cluster.routing.ShardRouting;
+
+/**
+ * The cluster nodes and shards are partitioned into mutually disjoint partitions. Each partition
+ * has its own {@link WeightFunction}.
+ */
+public interface BalancingWeights {
+
+    /**
+     * Get the weight function for the partition to which this shard belongs
+     *
+     * @param shard The shard
+     * @return The weight function that applies to the partition that shard belongs to
+     */
+    WeightFunction weightFunctionForShard(ShardRouting shard);
+
+    /**
+     * Get the weight function for the partition to which this node belongs
+     *
+     * @param node The node
+     * @return The weight function that applies to the partition that node belongs to
+     */
+    WeightFunction weightFunctionForNode(RoutingNode node);
+
+    /**
+     * Create the node sorters for the cluster
+     *
+     * @param modelNodes The full set of cluster nodes
+     * @param balancer The balancer
+     * @return a {@link NodeSorters} instance
+     */
+    NodeSorters createNodeSorters(BalancedShardsAllocator.ModelNode[] modelNodes, BalancedShardsAllocator.Balancer balancer);
+}
diff --git a/server/src/main/java/org/elasticsearch/cluster/routing/allocation/allocator/BalancingWeightsFactory.java b/server/src/main/java/org/elasticsearch/cluster/routing/allocation/allocator/BalancingWeightsFactory.java
new file mode 100644
index 0000000000000..23c556fe9d9d1
--- /dev/null
+++ b/server/src/main/java/org/elasticsearch/cluster/routing/allocation/allocator/BalancingWeightsFactory.java
@@ -0,0 +1,21 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the "Elastic License
+ * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
+ * Public License v 1"; you may not use this file except in compliance with, at
+ * your election, the "Elastic License 2.0", the "GNU Affero General Public
+ * License v3.0 only", or the "Server Side Public License, v 1".
+ */
+
+package org.elasticsearch.cluster.routing.allocation.allocator;
+
+/**
+ * A balancing weights factory must be able to divide all shards and nodes into mutually
+ * disjoint partitions. Allocation balancing will then be conducted sequentially for each partition.
+ * <p>
+ * If you can't partition your shards and nodes in this way, use {@link GlobalBalancingWeightsFactory}
+ */
+public interface BalancingWeightsFactory {
+
+    BalancingWeights create();
+}
diff --git a/server/src/main/java/org/elasticsearch/cluster/routing/allocation/allocator/GlobalBalancingWeightsFactory.java b/server/src/main/java/org/elasticsearch/cluster/routing/allocation/allocator/GlobalBalancingWeightsFactory.java
new file mode 100644
index 0000000000000..eecc549c1ece4
--- /dev/null
+++ b/server/src/main/java/org/elasticsearch/cluster/routing/allocation/allocator/GlobalBalancingWeightsFactory.java
@@ -0,0 +1,72 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the "Elastic License
+ * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
+ * Public License v 1"; you may not use this file except in compliance with, at
+ * your election, the "Elastic License 2.0", the "GNU Affero General Public
+ * License v3.0 only", or the "Server Side Public License, v 1".
+ */
+
+package org.elasticsearch.cluster.routing.allocation.allocator;
+
+import org.elasticsearch.cluster.routing.RoutingNode;
+import org.elasticsearch.cluster.routing.ShardRouting;
+import org.elasticsearch.common.collect.Iterators;
+
+import java.util.Iterator;
+
+public class GlobalBalancingWeightsFactory implements BalancingWeightsFactory {
+
+    private final BalancerSettings balancerSettings;
+
+    public GlobalBalancingWeightsFactory(BalancerSettings balancerSettings) {
+        this.balancerSettings = balancerSettings;
+    }
+
+    @Override
+    public BalancingWeights create() {
+        return new GlobalBalancingWeights();
+    }
+
+    private class GlobalBalancingWeights implements BalancingWeights {
+
+        private final WeightFunction weightFunction;
+
+        GlobalBalancingWeights() {
+            this.weightFunction = new WeightFunction(
+                balancerSettings.getShardBalanceFactor(),
+                balancerSettings.getIndexBalanceFactor(),
+                balancerSettings.getWriteLoadBalanceFactor(),
+                balancerSettings.getDiskUsageBalanceFactor()
+            );
+        }
+
+        @Override
+        public WeightFunction weightFunctionForShard(ShardRouting shard) {
+            return weightFunction;
+        }
+
+        @Override
+        public WeightFunction weightFunctionForNode(RoutingNode node) {
+            return weightFunction;
+        }
+
+        @Override
+        public NodeSorters createNodeSorters(BalancedShardsAllocator.ModelNode[] modelNodes, BalancedShardsAllocator.Balancer balancer) {
+            return new GlobalNodeSorters(new BalancedShardsAllocator.NodeSorter(modelNodes, weightFunction, balancer));
+        }
+
+        private record GlobalNodeSorters(BalancedShardsAllocator.NodeSorter nodeSorter) implements NodeSorters {
+
+            @Override
+            public BalancedShardsAllocator.NodeSorter sorterForShard(ShardRouting shard) {
+                return nodeSorter;
+            }
+
+            @Override
+            public Iterator<BalancedShardsAllocator.NodeSorter> iterator() {
+                return Iterators.single(nodeSorter);
+            }
+        }
+    }
+}
diff --git a/server/src/main/java/org/elasticsearch/cluster/routing/allocation/allocator/NodeSorters.java b/server/src/main/java/org/elasticsearch/cluster/routing/allocation/allocator/NodeSorters.java
new file mode 100644
index 0000000000000..041dcba3e047e
--- /dev/null
+++ b/server/src/main/java/org/elasticsearch/cluster/routing/allocation/allocator/NodeSorters.java
@@ -0,0 +1,27 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the "Elastic License
+ * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
+ * Public License v 1"; you may not use this file except in compliance with, at
+ * your election, the "Elastic License 2.0", the "GNU Affero General Public
+ * License v3.0 only", or the "Server Side Public License, v 1".
+ */
+
+package org.elasticsearch.cluster.routing.allocation.allocator;
+
+import org.elasticsearch.cluster.routing.ShardRouting;
+
+/**
+ * NodeSorters is just a cache of
+ * {@link org.elasticsearch.cluster.routing.allocation.allocator.BalancedShardsAllocator.NodeSorter}
+ * instances for each cluster partition
+ * <p>
+ * The returned iterator will return a node sorter for each partition in the cluster.
+ */
+public interface NodeSorters extends Iterable<BalancedShardsAllocator.NodeSorter> {
+
+    /**
+     * Get the {@link BalancedShardsAllocator.NodeSorter} for the specified shard
+     */
+    BalancedShardsAllocator.NodeSorter sorterForShard(ShardRouting shard);
+}
diff --git a/server/src/main/java/org/elasticsearch/plugins/ClusterPlugin.java b/server/src/main/java/org/elasticsearch/plugins/ClusterPlugin.java
index f8f508fddb25b..3f71b901ee32f 100644
--- a/server/src/main/java/org/elasticsearch/plugins/ClusterPlugin.java
+++ b/server/src/main/java/org/elasticsearch/plugins/ClusterPlugin.java
@@ -12,6 +12,8 @@
 import org.elasticsearch.cluster.routing.ShardRoutingRoleStrategy;
 import org.elasticsearch.cluster.routing.allocation.ExistingShardsAllocator;
 import org.elasticsearch.cluster.routing.allocation.WriteLoadForecaster;
+import org.elasticsearch.cluster.routing.allocation.allocator.BalancerSettings;
+import org.elasticsearch.cluster.routing.allocation.allocator.BalancingWeightsFactory;
 import org.elasticsearch.cluster.routing.allocation.allocator.ShardsAllocator;
 import org.elasticsearch.cluster.routing.allocation.decider.AllocationDecider;
 import org.elasticsearch.common.settings.ClusterSettings;
@@ -75,6 +77,10 @@ default ShardRoutingRoleStrategy getShardRoutingRoleStrategy() {
         return null;
     }
 
+    default BalancingWeightsFactory getBalancingWeightsFactory(BalancerSettings balancerSettings, ClusterSettings clusterSettings) {
+        return null;
+    }
+
     /**
      * Called when the node is started
      */
diff --git a/server/src/test/java/org/elasticsearch/cluster/routing/allocation/AllocationStatsServiceTests.java b/server/src/test/java/org/elasticsearch/cluster/routing/allocation/AllocationStatsServiceTests.java
index 756f29384ec5c..cf2653bc6c559 100644
--- a/server/src/test/java/org/elasticsearch/cluster/routing/allocation/AllocationStatsServiceTests.java
+++ b/server/src/test/java/org/elasticsearch/cluster/routing/allocation/AllocationStatsServiceTests.java
@@ -24,6 +24,7 @@
 import org.elasticsearch.cluster.routing.allocation.allocator.BalancerSettings;
 import org.elasticsearch.cluster.routing.allocation.allocator.DesiredBalance;
 import org.elasticsearch.cluster.routing.allocation.allocator.DesiredBalanceShardsAllocator;
+import org.elasticsearch.cluster.routing.allocation.allocator.GlobalBalancingWeightsFactory;
 import org.elasticsearch.cluster.routing.allocation.allocator.ShardAssignment;
 import org.elasticsearch.cluster.routing.allocation.allocator.ShardsAllocator;
 import org.elasticsearch.common.settings.ClusterSettings;
@@ -85,7 +86,10 @@ public void testShardStats() {
                 clusterService,
                 () -> clusterInfo,
                 createShardAllocator(),
-                new NodeAllocationStatsAndWeightsCalculator(TEST_WRITE_LOAD_FORECASTER, BalancerSettings.DEFAULT)
+                new NodeAllocationStatsAndWeightsCalculator(
+                    TEST_WRITE_LOAD_FORECASTER,
+                    new GlobalBalancingWeightsFactory(BalancerSettings.DEFAULT)
+                )
             );
             assertThat(
                 service.stats(),
@@ -126,7 +130,10 @@ public void testRelocatingShardIsOnlyCountedOnceOnTargetNode() {
                 clusterService,
                 EmptyClusterInfoService.INSTANCE,
                 createShardAllocator(),
-                new NodeAllocationStatsAndWeightsCalculator(TEST_WRITE_LOAD_FORECASTER, BalancerSettings.DEFAULT)
+                new NodeAllocationStatsAndWeightsCalculator(
+                    TEST_WRITE_LOAD_FORECASTER,
+                    new GlobalBalancingWeightsFactory(BalancerSettings.DEFAULT)
+                )
             );
             assertThat(
                 service.stats(),
@@ -183,7 +190,10 @@ public DesiredBalance getDesiredBalance() {
                         );
                     }
                 },
-                new NodeAllocationStatsAndWeightsCalculator(TEST_WRITE_LOAD_FORECASTER, BalancerSettings.DEFAULT)
+                new NodeAllocationStatsAndWeightsCalculator(
+                    TEST_WRITE_LOAD_FORECASTER,
+                    new GlobalBalancingWeightsFactory(BalancerSettings.DEFAULT)
+                )
             );
             assertThat(
                 service.stats(),
diff --git a/server/src/test/java/org/elasticsearch/cluster/routing/allocation/allocator/BalancedShardsAllocatorTests.java b/server/src/test/java/org/elasticsearch/cluster/routing/allocation/allocator/BalancedShardsAllocatorTests.java
index 92171b75b5181..7e05ae7c57f79 100644
--- a/server/src/test/java/org/elasticsearch/cluster/routing/allocation/allocator/BalancedShardsAllocatorTests.java
+++ b/server/src/test/java/org/elasticsearch/cluster/routing/allocation/allocator/BalancedShardsAllocatorTests.java
@@ -35,7 +35,9 @@
 import org.elasticsearch.cluster.routing.ShardRoutingState;
 import org.elasticsearch.cluster.routing.allocation.AllocateUnassignedDecision;
 import org.elasticsearch.cluster.routing.allocation.RoutingAllocation;
+import org.elasticsearch.cluster.routing.allocation.decider.AllocationDecider;
 import org.elasticsearch.cluster.routing.allocation.decider.AllocationDeciders;
+import org.elasticsearch.cluster.routing.allocation.decider.Decision;
 import org.elasticsearch.cluster.routing.allocation.decider.ThrottlingAllocationDecider;
 import org.elasticsearch.common.UUIDs;
 import org.elasticsearch.common.settings.Settings;
@@ -51,9 +53,11 @@
 import java.util.Arrays;
 import java.util.Collections;
 import java.util.HashMap;
+import java.util.Iterator;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
+import java.util.function.Function;
 import java.util.stream.Collector;
 import java.util.stream.Collectors;
 import java.util.stream.StreamSupport;
@@ -611,6 +615,71 @@ public void testShardSizeDiscrepancyWithinIndex() {
         assertSame(clusterState, reroute(allocationService, clusterState));
     }
 
+    public void testPartitionedClusterWithSeparateWeights() {
+        var allocationService = new MockAllocationService(
+            prefixAllocationDeciders(),
+            new TestGatewayAllocator(),
+            new BalancedShardsAllocator(
+                BalancerSettings.DEFAULT,
+                TEST_WRITE_LOAD_FORECASTER,
+                new PrefixBalancingWeightsFactory(
+                    Map.of("shardsOnly", new WeightFunction(1, 0, 0, 0), "weightsOnly", new WeightFunction(0, 0, 1, 0))
+                )
+            ),
+            EmptyClusterInfoService.INSTANCE,
+            SNAPSHOT_INFO_SERVICE_WITH_NO_SHARD_SIZES
+        );
+
+        var clusterState = applyStartedShardsUntilNoChange(
+            createStateWithIndices(
+                List.of("shardsOnly-1", "shardsOnly-2", "weightsOnly-1", "weightsOnly-2"),
+                shardId -> prefix(shardId.getIndexName()) + "-1",
+                anIndex("weightsOnly-heavy-index").indexWriteLoadForecast(8.0),
+                anIndex("weightsOnly-light-index-1").indexWriteLoadForecast(1.0),
+                anIndex("weightsOnly-light-index-2").indexWriteLoadForecast(2.0),
+                anIndex("weightsOnly-light-index-3").indexWriteLoadForecast(3.0),
+                anIndex("weightsOnly-zero-write-load-index").indexWriteLoadForecast(0.0),
+                anIndex("weightsOnly-no-write-load-index"),
+                anIndex("shardsOnly-heavy-index").indexWriteLoadForecast(8.0),
+                anIndex("shardsOnly-light-index-1").indexWriteLoadForecast(1.0),
+                anIndex("shardsOnly-light-index-2").indexWriteLoadForecast(2.0),
+                anIndex("shardsOnly-light-index-3").indexWriteLoadForecast(3.0),
+                anIndex("shardsOnly-zero-write-load-index").indexWriteLoadForecast(0.0),
+                anIndex("shardsOnly-no-write-load-index")
+            ),
+            allocationService
+        );
+
+        Map<String, Set<String>> shardsPerNode = getShardsPerNode(clusterState);
+        Map<String, Set<String>> shardBalancedPartition = shardsPerNode.entrySet()
+            .stream()
+            .filter(e -> e.getKey().startsWith("shardsOnly"))
+            .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));
+        Map<String, Set<String>> weightBalancedPartition = shardsPerNode.entrySet()
+            .stream()
+            .filter(e -> e.getKey().startsWith("weightsOnly"))
+            .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));
+
+        // The partition that balances on weights only is skewed
+        assertThat(
+            weightBalancedPartition.values(),
+            containsInAnyOrder(
+                Set.of("weightsOnly-heavy-index"),
+                Set.of(
+                    "weightsOnly-light-index-1",
+                    "weightsOnly-light-index-2",
+                    "weightsOnly-light-index-3",
+                    "weightsOnly-zero-write-load-index",
+                    "weightsOnly-no-write-load-index"
+                )
+            )
+        );
+
+        // The partition that balances on shard count only has an even distribution of shards
+        assertThat(shardBalancedPartition.get("shardsOnly-1"), hasSize(3));
+        assertThat(shardBalancedPartition.get("shardsOnly-2"), hasSize(3));
+    }
+
     private Map<String, Integer> getTargetShardPerNodeCount(IndexRoutingTable indexRoutingTable) {
         var counts = new HashMap<String, Integer>();
         for (int shardId = 0; shardId < indexRoutingTable.size(); shardId++) {
@@ -647,6 +716,14 @@ private static IndexMetadata.Builder anIndex(String name, Settings.Builder setti
     }
 
     private static ClusterState createStateWithIndices(IndexMetadata.Builder... indexMetadataBuilders) {
+        return createStateWithIndices(List.of("node-1", "node-2"), shardId -> "node-1", indexMetadataBuilders);
+    }
+
+    private static ClusterState createStateWithIndices(
+        List<String> nodeNames,
+        Function<ShardId, String> unbalancedAllocator,
+        IndexMetadata.Builder... indexMetadataBuilders
+    ) {
         var metadataBuilder = Metadata.builder();
         var routingTableBuilder = RoutingTable.builder(TestShardRoutingRoleStrategies.DEFAULT_ROLE_ONLY);
         if (randomBoolean()) {
@@ -663,10 +740,11 @@ private static ClusterState createStateWithIndices(IndexMetadata.Builder... inde
                 var inSyncId = UUIDs.randomBase64UUID();
                 var indexMetadata = index.putInSyncAllocationIds(0, Set.of(inSyncId)).build();
                 metadataBuilder.put(indexMetadata, false);
+                ShardId shardId = new ShardId(indexMetadata.getIndex(), 0);
                 routingTableBuilder.add(
                     IndexRoutingTable.builder(indexMetadata.getIndex())
                         .addShard(
-                            shardRoutingBuilder(new ShardId(indexMetadata.getIndex(), 0), "node-1", true, ShardRoutingState.STARTED)
+                            shardRoutingBuilder(shardId, unbalancedAllocator.apply(shardId), true, ShardRoutingState.STARTED)
                                 .withAllocationId(AllocationId.newInitializing(inSyncId))
                                 .build()
                         )
@@ -674,8 +752,13 @@ private static ClusterState createStateWithIndices(IndexMetadata.Builder... inde
             }
         }
 
+        DiscoveryNodes.Builder discoveryNodesBuilder = DiscoveryNodes.builder();
+        for (String nodeName : nodeNames) {
+            discoveryNodesBuilder.add(newNode(nodeName));
+        }
+
         return ClusterState.builder(ClusterName.DEFAULT)
-            .nodes(DiscoveryNodes.builder().add(newNode("node-1")).add(newNode("node-2")))
+            .nodes(discoveryNodesBuilder)
             .metadata(metadataBuilder)
             .routingTable(routingTableBuilder)
             .build();
@@ -712,4 +795,101 @@ private void addIndex(
         }
         routingTableBuilder.add(indexRoutingTableBuilder);
     }
+
+    /**
+     * A {@link BalancingWeightsFactory} that assumes the cluster is partitioned by the prefix
+     * of the node and shard names before the `-`.
+     */
+    class PrefixBalancingWeightsFactory implements BalancingWeightsFactory {
+
+        private final Map<String, WeightFunction> prefixWeights;
+
+        PrefixBalancingWeightsFactory(Map<String, WeightFunction> prefixWeights) {
+            this.prefixWeights = prefixWeights;
+        }
+
+        @Override
+        public BalancingWeights create() {
+            return new PrefixBalancingWeights();
+        }
+
+        class PrefixBalancingWeights implements BalancingWeights {
+
+            @Override
+            public WeightFunction weightFunctionForShard(ShardRouting shard) {
+                return prefixWeights.get(prefix(shard.getIndexName()));
+            }
+
+            @Override
+            public WeightFunction weightFunctionForNode(RoutingNode node) {
+                return prefixWeights.get(prefix(node.node().getId()));
+            }
+
+            @Override
+            public NodeSorters createNodeSorters(
+                BalancedShardsAllocator.ModelNode[] modelNodes,
+                BalancedShardsAllocator.Balancer balancer
+            ) {
+                final HashMap<String, BalancedShardsAllocator.NodeSorter> prefixNodeSorters = new HashMap<>();
+                for (var entry : prefixWeights.entrySet()) {
+                    prefixNodeSorters.put(
+                        entry.getKey(),
+                        new BalancedShardsAllocator.NodeSorter(
+                            Arrays.stream(modelNodes)
+                                .filter(node -> prefix(node.getRoutingNode().node().getId()).equals(entry.getKey()))
+                                .toArray(BalancedShardsAllocator.ModelNode[]::new),
+                            entry.getValue(),
+                            balancer
+                        )
+                    );
+                }
+                return new NodeSorters() {
+
+                    @Override
+                    public Iterator<BalancedShardsAllocator.NodeSorter> iterator() {
+                        return prefixNodeSorters.values().iterator();
+                    }
+
+                    @Override
+                    public BalancedShardsAllocator.NodeSorter sorterForShard(ShardRouting shard) {
+                        return prefixNodeSorters.get(prefix(shard.getIndexName()));
+                    }
+                };
+            }
+        }
+    }
+
+    /**
+     * Allocation deciders that only allow shards to be allocated to nodes whose names share the same prefix
+     * as the index they're from
+     */
+    private AllocationDeciders prefixAllocationDeciders() {
+        return new AllocationDeciders(List.of(new AllocationDecider() {
+            @Override
+            public Decision canAllocate(ShardRouting shardRouting, RoutingNode node, RoutingAllocation allocation) {
+                return nodePrefixMatchesIndexPrefix(shardRouting, node);
+            }
+
+            @Override
+            public Decision canRemain(
+                IndexMetadata indexMetadata,
+                ShardRouting shardRouting,
+                RoutingNode node,
+                RoutingAllocation allocation
+            ) {
+                return nodePrefixMatchesIndexPrefix(shardRouting, node);
+            }
+
+            private Decision nodePrefixMatchesIndexPrefix(ShardRouting shardRouting, RoutingNode node) {
+                var indexPrefix = prefix(shardRouting.index().getName());
+                var nodePrefix = prefix(node.node().getId());
+                return nodePrefix.equals(indexPrefix) ? Decision.YES : Decision.NO;
+            }
+        }));
+    }
+
+    private static String prefix(String value) {
+        assert value != null && value.contains("-") : "Invalid name passed: " + value;
+        return value.substring(0, value.indexOf("-"));
+    }
 }
diff --git a/test/framework/src/main/java/org/elasticsearch/cluster/ESAllocationTestCase.java b/test/framework/src/main/java/org/elasticsearch/cluster/ESAllocationTestCase.java
index d2f6c05a5d3f6..e6a3f7664bd28 100644
--- a/test/framework/src/main/java/org/elasticsearch/cluster/ESAllocationTestCase.java
+++ b/test/framework/src/main/java/org/elasticsearch/cluster/ESAllocationTestCase.java
@@ -33,6 +33,7 @@
 import org.elasticsearch.cluster.routing.allocation.allocator.BalancerSettings;
 import org.elasticsearch.cluster.routing.allocation.allocator.DesiredBalance;
 import org.elasticsearch.cluster.routing.allocation.allocator.DesiredBalanceShardsAllocator;
+import org.elasticsearch.cluster.routing.allocation.allocator.GlobalBalancingWeightsFactory;
 import org.elasticsearch.cluster.routing.allocation.allocator.ShardsAllocator;
 import org.elasticsearch.cluster.routing.allocation.decider.AllocationDecider;
 import org.elasticsearch.cluster.routing.allocation.decider.AllocationDeciders;
@@ -443,7 +444,10 @@ public void allocateUnassigned(
     }
 
     protected static final NodeAllocationStatsAndWeightsCalculator EMPTY_NODE_ALLOCATION_STATS =
-        new NodeAllocationStatsAndWeightsCalculator(WriteLoadForecaster.DEFAULT, BalancerSettings.DEFAULT) {
+        new NodeAllocationStatsAndWeightsCalculator(
+            WriteLoadForecaster.DEFAULT,
+            new GlobalBalancingWeightsFactory(BalancerSettings.DEFAULT)
+        ) {
             @Override
             public Map<String, NodeAllocationStatsAndWeight> nodesAllocationStatsAndWeights(
                 Metadata metadata,


Reviews:
user: login: nicktindall
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-03T06:19:22Z
user: login: nicktindall
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-03T06:20:09Z
user: login: nicktindall
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-08T06:12:31Z
user: login: nicktindall
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-09T03:13:30Z
user: login: henningandersen
body: This direction looks much better than the original.

If there is some simplification you can do that is fine (suggested one small thing myself). But I'd be ok with this change, I do not think it adds significant complexity.
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-11T11:36:02Z
user: login: nicktindall
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-11T11:48:25Z
user: login: DiannaHohensee
body: Posting some code comments. I didn't finish going through everything, but I've run out of time today and want to get the feedback published, so it doesn't become stale. I also have not yet looked at this from the high level, or in the context of your past proposals -- I mean to do that, too, but first I wanted to figure out how this code works.
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-11T18:35:22Z
user: login: nicktindall
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-14T01:18:43Z
user: login: nicktindall
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-14T01:21:25Z
user: login: nicktindall
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-14T01:28:46Z
user: login: nicktindall
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-14T01:45:14Z
user: login: nicktindall
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-14T01:45:19Z
user: login: nicktindall
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-14T02:06:31Z
user: login: nicktindall
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-14T04:03:59Z
user: login: nicktindall
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-14T05:06:12Z
user: login: nicktindall
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-14T05:09:43Z
user: login: nicktindall
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-14T05:30:18Z
user: login: nicktindall
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-14T05:41:56Z
user: login: nicktindall
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-14T05:45:09Z
user: login: nicktindall
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-15T03:46:55Z
user: login: nicktindall
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-17T02:24:15Z
user: login: pxsalehi
body: LGTM! Great work! 
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-17T14:49:12Z
user: login: nicktindall
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-22T03:55:18Z
user: login: henningandersen
body: LGTM.
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-24T13:11:40Z

Review comments:
diff_hunk: @@ -273,32 +268,32 @@ public static class Balancer {
         private final RoutingAllocation allocation;
         private final RoutingNodes routingNodes;
         private final Metadata metadata;
-        private final WeightFunction weightFunction;
 
         private final float threshold;
         private final float avgShardsPerNode;
         private final double avgWriteLoadPerNode;
         private final double avgDiskUsageInBytesPerNode;
         private final Map<String, ModelNode> nodes;
-        private final NodeSorter sorter;
+        private final PartitionedCluster partitionedCluster;
+        private final PartitionedNodeSorter partitionedNodeSorter;
 
         private Balancer(
             WriteLoadForecaster writeLoadForecaster,
             RoutingAllocation allocation,
-            WeightFunction weightFunction,
-            float threshold
+            float threshold,
+            PartitionedCluster partitionedCluster
         ) {
             this.writeLoadForecaster = writeLoadForecaster;
             this.allocation = allocation;
             this.routingNodes = allocation.routingNodes();
             this.metadata = allocation.metadata();
-            this.weightFunction = weightFunction;
             this.threshold = threshold;
             avgShardsPerNode = WeightFunction.avgShardPerNode(metadata, routingNodes);
             avgWriteLoadPerNode = WeightFunction.avgWriteLoadPerNode(writeLoadForecaster, metadata, routingNodes);
             avgDiskUsageInBytesPerNode = WeightFunction.avgDiskUsageInBytesPerNode(allocation.clusterInfo(), metadata, routingNodes);
path: server/src/main/java/org/elasticsearch/cluster/routing/allocation/allocator/BalancedShardsAllocator.java
user: login: nicktindall
body: I left the averages calculated globally, I did experiment with making them local to the partition, but I'm not sure of the benefit of this. I think it might be more costly to filter the nodes and shards by partition to calculate these numbers. But definitely something we can pursue if we think it's worthwhile.
created_at: 2025-04-03T06:19:22Z
author_association: CONTRIBUTOR
diff_hunk: @@ -424,7 +409,11 @@ private void balance() {
                 logger.trace("skipping rebalance as single node only");
                 return;
             }
-            balanceByWeights();
+
+            // Balance each partition
+            for (NodeSorter nodeSorter : partitionedNodeSorter.allNodeSorters()) {
+                balanceByWeights(nodeSorter);
+            }
path: server/src/main/java/org/elasticsearch/cluster/routing/allocation/allocator/BalancedShardsAllocator.java
user: login: nicktindall
body: We balance once for each `NodeSorter` (i.e. once for each partition)
created_at: 2025-04-03T06:20:09Z
author_association: CONTRIBUTOR
diff_hunk: @@ -273,32 +268,32 @@ public static class Balancer {
         private final RoutingAllocation allocation;
         private final RoutingNodes routingNodes;
         private final Metadata metadata;
-        private final WeightFunction weightFunction;
 
         private final float threshold;
         private final float avgShardsPerNode;
         private final double avgWriteLoadPerNode;
         private final double avgDiskUsageInBytesPerNode;
         private final Map<String, ModelNode> nodes;
-        private final NodeSorter sorter;
+        private final PartitionedCluster partitionedCluster;
+        private final PartitionedNodeSorter partitionedNodeSorter;
 
         private Balancer(
             WriteLoadForecaster writeLoadForecaster,
             RoutingAllocation allocation,
-            WeightFunction weightFunction,
-            float threshold
+            float threshold,
+            PartitionedCluster partitionedCluster
         ) {
             this.writeLoadForecaster = writeLoadForecaster;
             this.allocation = allocation;
             this.routingNodes = allocation.routingNodes();
             this.metadata = allocation.metadata();
-            this.weightFunction = weightFunction;
             this.threshold = threshold;
             avgShardsPerNode = WeightFunction.avgShardPerNode(metadata, routingNodes);
             avgWriteLoadPerNode = WeightFunction.avgWriteLoadPerNode(writeLoadForecaster, metadata, routingNodes);
             avgDiskUsageInBytesPerNode = WeightFunction.avgDiskUsageInBytesPerNode(allocation.clusterInfo(), metadata, routingNodes);
path: server/src/main/java/org/elasticsearch/cluster/routing/allocation/allocator/BalancedShardsAllocator.java
user: login: nicktindall
body: I wonder if the split would be significant if we had substantially differently-sized search and indexing tiers.
created_at: 2025-04-08T06:12:31Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1390,7 +1366,11 @@ protected int comparePivot(int j) {
         }
 
         public float delta() {
-            return weights[weights.length - 1] - weights[0];
+            return weights.length == 0 ? 0.0f : weights[weights.length - 1] - weights[0];
path: server/src/main/java/org/elasticsearch/cluster/routing/allocation/allocator/BalancedShardsAllocator.java
user: login: nicktindall
body: It used to be impossible to have zero nodes in a sorter, but now that can happen in the search partition
created_at: 2025-04-09T03:13:30Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,22 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the "Elastic License
+ * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
+ * Public License v 1"; you may not use this file except in compliance with, at
+ * your election, the "Elastic License 2.0", the "GNU Affero General Public
+ * License v3.0 only", or the "Server Side Public License, v 1".
+ */
+
+package org.elasticsearch.cluster.routing.allocation.allocator;
+
+/**
+ * A partitioned cluster factory must be able to divide all shards and nodes into mutually
+ * disjoint partitions. Allocation balancing will then be conducted sequentially for each partition.
+ * <p>
+ * If you can't partition your shards and nodes in this way, use
+ * {@link org.elasticsearch.cluster.routing.allocation.allocator.GlobalPartitionedClusterFactory}
+ */
+public interface PartitionedClusterFactory {
path: server/src/main/java/org/elasticsearch/cluster/routing/allocation/allocator/PartitionedClusterFactory.java
user: login: henningandersen
body: I am not sure I understand the role of the factory? Looks like the underlying `PartitionedCluster` might as well be used directly - it is sort of a factory for the sorter  and weight funtion(s).
created_at: 2025-04-11T11:32:23Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,22 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the "Elastic License
+ * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
+ * Public License v 1"; you may not use this file except in compliance with, at
+ * your election, the "Elastic License 2.0", the "GNU Affero General Public
+ * License v3.0 only", or the "Server Side Public License, v 1".
+ */
+
+package org.elasticsearch.cluster.routing.allocation.allocator;
+
+/**
+ * A partitioned cluster factory must be able to divide all shards and nodes into mutually
+ * disjoint partitions. Allocation balancing will then be conducted sequentially for each partition.
+ * <p>
+ * If you can't partition your shards and nodes in this way, use
+ * {@link org.elasticsearch.cluster.routing.allocation.allocator.GlobalPartitionedClusterFactory}
+ */
+public interface PartitionedClusterFactory {
path: server/src/main/java/org/elasticsearch/cluster/routing/allocation/allocator/PartitionedClusterFactory.java
user: login: nicktindall
body: Yeah the moment the PartitionedCluster is created is when we store the current set of weights. We could avoid the factory if we're happy for the weights to change under it.
created_at: 2025-04-11T11:48:25Z
author_association: CONTRIBUTOR
diff_hunk: @@ -114,14 +114,9 @@ public class BalancedShardsAllocator implements ShardsAllocator {
         Property.NodeScope
     );
 
-    // TODO: deduplicate these fields, use the fields in NodeAllocationStatsAndWeightsCalculator instead.
path: server/src/main/java/org/elasticsearch/cluster/routing/allocation/allocator/BalancedShardsAllocator.java
user: login: DiannaHohensee
body: If you want to minimize your diff for the weight changes, the new BalancerSettings is a prime candidate to be reviewed separately. It would also be nice to delete a few BalancedShardsAllocator constructors. I think

`BalancedShardsAllocator()` could be deleted

`BalancedShardsAllocator(Settings settings)` could become `BalancedShardsAllocator(BalancerSettings balancerSettings)`

`BalancedShardsAllocator(ClusterSettings clusterSettings)` could be deleted

`BalancedShardsAllocator(ClusterSettings clusterSettings, WriteLoadForecaster writeLoadForecaster)` could be deleted

The impediments are testing, but I think it _might_ be a straightforward change to use your new 

`public static final BalancerSettings DEFAULT = new BalancerSettings(ClusterSettings.createBuiltInClusterSettings());` to setup the `BalancedShardsAllocator` in tests?
created_at: 2025-04-11T12:25:36Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,175 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the "Elastic License
+ * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
+ * Public License v 1"; you may not use this file except in compliance with, at
+ * your election, the "Elastic License 2.0", the "GNU Affero General Public
+ * License v3.0 only", or the "Server Side Public License, v 1".
+ */
+
+package org.elasticsearch.cluster.routing.allocation.allocator;
+
+import org.elasticsearch.cluster.node.DiscoveryNodeRole;
+import org.elasticsearch.cluster.routing.RoutingNode;
+import org.elasticsearch.cluster.routing.ShardRouting;
+import org.elasticsearch.common.settings.ClusterSettings;
+import org.elasticsearch.common.settings.Setting;
+
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.List;
+import java.util.Set;
+import java.util.stream.Stream;
+
+import static org.elasticsearch.cluster.routing.allocation.allocator.BalancedShardsAllocator.SHARD_BALANCE_FACTOR_SETTING;
+import static org.elasticsearch.cluster.routing.allocation.allocator.BalancedShardsAllocator.WRITE_LOAD_BALANCE_FACTOR_SETTING;
+
+public class TieredPartitionedClusterFactory implements PartitionedClusterFactory {
+
+    public static final Setting<Float> INDEXING_TIER_SHARD_BALANCE_FACTOR_SETTING = Setting.floatSetting(
path: server/src/main/java/org/elasticsearch/cluster/routing/allocation/allocator/TieredPartitionedClusterFactory.java
user: login: DiannaHohensee
body: Would it be cleaner if we shoved all the balancer related settings into BalancerSettings? Or was your plan to turn this into a plugin?
created_at: 2025-04-11T13:13:28Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,175 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the "Elastic License
+ * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
+ * Public License v 1"; you may not use this file except in compliance with, at
+ * your election, the "Elastic License 2.0", the "GNU Affero General Public
+ * License v3.0 only", or the "Server Side Public License, v 1".
+ */
+
+package org.elasticsearch.cluster.routing.allocation.allocator;
+
+import org.elasticsearch.cluster.node.DiscoveryNodeRole;
+import org.elasticsearch.cluster.routing.RoutingNode;
+import org.elasticsearch.cluster.routing.ShardRouting;
+import org.elasticsearch.common.settings.ClusterSettings;
+import org.elasticsearch.common.settings.Setting;
+
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.List;
+import java.util.Set;
+import java.util.stream.Stream;
+
+import static org.elasticsearch.cluster.routing.allocation.allocator.BalancedShardsAllocator.SHARD_BALANCE_FACTOR_SETTING;
+import static org.elasticsearch.cluster.routing.allocation.allocator.BalancedShardsAllocator.WRITE_LOAD_BALANCE_FACTOR_SETTING;
+
+public class TieredPartitionedClusterFactory implements PartitionedClusterFactory {
path: server/src/main/java/org/elasticsearch/cluster/routing/allocation/allocator/TieredPartitionedClusterFactory.java
user: login: DiannaHohensee
body: I think we would want `Balancer` in the name someplace? Tiered and Partitioned don't give a hint to the purpose or content of the class, just a brief nod to the structure, which could be for anything. Perhaps something like

`TieredPartitionedClusterFactory` -> `ServerlessBalancingWeightsFactory`

`PartitionedClusterFactory` -> `BalancingWeightsFactory`?

`Global*` -> `GlobalBalancingWeightsFactory` (I think someday this would become the `TieredDataBalancingWeightsFactory` b/c stateful has multiple data tiers. For example, I expect the frozen tier has no use for write load balancing)
created_at: 2025-04-11T13:19:19Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,25 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the "Elastic License
+ * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
+ * Public License v 1"; you may not use this file except in compliance with, at
+ * your election, the "Elastic License 2.0", the "GNU Affero General Public
+ * License v3.0 only", or the "Server Side Public License, v 1".
+ */
+
+package org.elasticsearch.cluster.routing.allocation.allocator;
+
+import org.elasticsearch.cluster.routing.RoutingNode;
+import org.elasticsearch.cluster.routing.ShardRouting;
+
+public interface PartitionedCluster {
path: server/src/main/java/org/elasticsearch/cluster/routing/allocation/allocator/PartitionedCluster.java
user: login: DiannaHohensee
body: Similarly rename this to something like: BalancingWeights, GlobalBalancingWeights, ServerlessBalancingWeights?
created_at: 2025-04-11T13:26:59Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,175 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the "Elastic License
+ * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
+ * Public License v 1"; you may not use this file except in compliance with, at
+ * your election, the "Elastic License 2.0", the "GNU Affero General Public
+ * License v3.0 only", or the "Server Side Public License, v 1".
+ */
+
+package org.elasticsearch.cluster.routing.allocation.allocator;
+
+import org.elasticsearch.cluster.node.DiscoveryNodeRole;
+import org.elasticsearch.cluster.routing.RoutingNode;
+import org.elasticsearch.cluster.routing.ShardRouting;
+import org.elasticsearch.common.settings.ClusterSettings;
+import org.elasticsearch.common.settings.Setting;
+
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.List;
+import java.util.Set;
+import java.util.stream.Stream;
+
+import static org.elasticsearch.cluster.routing.allocation.allocator.BalancedShardsAllocator.SHARD_BALANCE_FACTOR_SETTING;
+import static org.elasticsearch.cluster.routing.allocation.allocator.BalancedShardsAllocator.WRITE_LOAD_BALANCE_FACTOR_SETTING;
+
+public class TieredPartitionedClusterFactory implements PartitionedClusterFactory {
+
+    public static final Setting<Float> INDEXING_TIER_SHARD_BALANCE_FACTOR_SETTING = Setting.floatSetting(
+        "cluster.routing.allocation.balance.shard.indexing",
+        SHARD_BALANCE_FACTOR_SETTING,
+        0.0f,
+        Setting.Property.Dynamic,
+        Setting.Property.NodeScope
+    );
+
+    public static final Setting<Float> SEARCH_TIER_SHARD_BALANCE_FACTOR_SETTING = Setting.floatSetting(
+        "cluster.routing.allocation.balance.shard.search",
+        SHARD_BALANCE_FACTOR_SETTING,
+        0.0f,
+        Setting.Property.Dynamic,
+        Setting.Property.NodeScope
+    );
+
+    public static final Setting<Float> INDEXING_TIER_WRITE_LOAD_BALANCE_FACTOR_SETTING = Setting.floatSetting(
+        "cluster.routing.allocation.balance.write_load.indexing",
+        WRITE_LOAD_BALANCE_FACTOR_SETTING,
+        0.0f,
+        Setting.Property.Dynamic,
+        Setting.Property.NodeScope
+    );
+
+    private final BalancerSettings balancerSettings;
+    private volatile float indexingTierShardBalanceFactor;
+    private volatile float searchTierShardBalanceFactor;
+    private volatile float indexingTierWriteLoadBalanceFactor;
+
+    public TieredPartitionedClusterFactory(BalancerSettings balancerSettings, ClusterSettings clusterSettings) {
+        this.balancerSettings = balancerSettings;
+        clusterSettings.initializeAndWatch(
+            INDEXING_TIER_SHARD_BALANCE_FACTOR_SETTING,
+            value -> this.indexingTierShardBalanceFactor = value
+        );
+        clusterSettings.initializeAndWatch(SEARCH_TIER_SHARD_BALANCE_FACTOR_SETTING, value -> this.searchTierShardBalanceFactor = value);
+        clusterSettings.initializeAndWatch(
+            INDEXING_TIER_WRITE_LOAD_BALANCE_FACTOR_SETTING,
+            value -> this.indexingTierWriteLoadBalanceFactor = value
+        );
+    }
+
+    @Override
+    public PartitionedCluster create() {
+        return new TieredPartitionedCluster();
+    }
+
+    private class TieredPartitionedCluster implements PartitionedCluster {
+
+        private final WeightFunction searchWeightFunction;
+        private final WeightFunction indexingWeightFunction;
+
+        private TieredPartitionedCluster() {
+            this.searchWeightFunction = new WeightFunction(
+                searchTierShardBalanceFactor,
+                balancerSettings.getIndexBalanceFactor(),
+                0.0f,
+                balancerSettings.getDiskUsageBalanceFactor()
+            );
+            this.indexingWeightFunction = new WeightFunction(
+                indexingTierShardBalanceFactor,
+                balancerSettings.getIndexBalanceFactor(),
+                indexingTierWriteLoadBalanceFactor,
+                balancerSettings.getDiskUsageBalanceFactor()
+            );
+        }
+
+        @Override
+        public WeightFunction weightFunctionForShard(ShardRouting shard) {
+            if (shard.role() == ShardRouting.Role.SEARCH_ONLY) {
+                return searchWeightFunction;
+            } else if (shard.role() == ShardRouting.Role.INDEX_ONLY) {
+                return indexingWeightFunction;
+            } else {
+                throw new IllegalArgumentException("Unsupported shard role [" + shard.role() + "]");
+            }
+        }
+
+        @Override
+        public WeightFunction weightFunctionForNode(RoutingNode node) {
+            Set<DiscoveryNodeRole> roles = node.node().getRoles();
+            if (roles.contains(DiscoveryNodeRole.SEARCH_ROLE)) {
+                return searchWeightFunction;
+            } else if (roles.contains(DiscoveryNodeRole.INDEX_ROLE)) {
+                return indexingWeightFunction;
+            } else {
+                throw new IllegalArgumentException("Node is neither indexing or search node, roles = " + roles);
path: server/src/main/java/org/elasticsearch/cluster/routing/allocation/allocator/TieredPartitionedClusterFactory.java
user: login: DiannaHohensee
body: Do you know how ML_ROLE works with balancing? Are those nodes also tagged with SEARCH_ROLE or INDEX_ROLE? I'm wondering if it's possible (in serverless) to get a node without either search or index roles.
created_at: 2025-04-11T15:02:46Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,175 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the "Elastic License
+ * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
+ * Public License v 1"; you may not use this file except in compliance with, at
+ * your election, the "Elastic License 2.0", the "GNU Affero General Public
+ * License v3.0 only", or the "Server Side Public License, v 1".
+ */
+
+package org.elasticsearch.cluster.routing.allocation.allocator;
+
+import org.elasticsearch.cluster.node.DiscoveryNodeRole;
+import org.elasticsearch.cluster.routing.RoutingNode;
+import org.elasticsearch.cluster.routing.ShardRouting;
+import org.elasticsearch.common.settings.ClusterSettings;
+import org.elasticsearch.common.settings.Setting;
+
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.List;
+import java.util.Set;
+import java.util.stream.Stream;
+
+import static org.elasticsearch.cluster.routing.allocation.allocator.BalancedShardsAllocator.SHARD_BALANCE_FACTOR_SETTING;
+import static org.elasticsearch.cluster.routing.allocation.allocator.BalancedShardsAllocator.WRITE_LOAD_BALANCE_FACTOR_SETTING;
+
+public class TieredPartitionedClusterFactory implements PartitionedClusterFactory {
+
+    public static final Setting<Float> INDEXING_TIER_SHARD_BALANCE_FACTOR_SETTING = Setting.floatSetting(
+        "cluster.routing.allocation.balance.shard.indexing",
+        SHARD_BALANCE_FACTOR_SETTING,
+        0.0f,
+        Setting.Property.Dynamic,
+        Setting.Property.NodeScope
+    );
+
+    public static final Setting<Float> SEARCH_TIER_SHARD_BALANCE_FACTOR_SETTING = Setting.floatSetting(
+        "cluster.routing.allocation.balance.shard.search",
+        SHARD_BALANCE_FACTOR_SETTING,
+        0.0f,
+        Setting.Property.Dynamic,
+        Setting.Property.NodeScope
+    );
+
+    public static final Setting<Float> INDEXING_TIER_WRITE_LOAD_BALANCE_FACTOR_SETTING = Setting.floatSetting(
+        "cluster.routing.allocation.balance.write_load.indexing",
+        WRITE_LOAD_BALANCE_FACTOR_SETTING,
+        0.0f,
+        Setting.Property.Dynamic,
+        Setting.Property.NodeScope
+    );
+
+    private final BalancerSettings balancerSettings;
+    private volatile float indexingTierShardBalanceFactor;
+    private volatile float searchTierShardBalanceFactor;
+    private volatile float indexingTierWriteLoadBalanceFactor;
+
+    public TieredPartitionedClusterFactory(BalancerSettings balancerSettings, ClusterSettings clusterSettings) {
+        this.balancerSettings = balancerSettings;
+        clusterSettings.initializeAndWatch(
+            INDEXING_TIER_SHARD_BALANCE_FACTOR_SETTING,
+            value -> this.indexingTierShardBalanceFactor = value
+        );
+        clusterSettings.initializeAndWatch(SEARCH_TIER_SHARD_BALANCE_FACTOR_SETTING, value -> this.searchTierShardBalanceFactor = value);
+        clusterSettings.initializeAndWatch(
+            INDEXING_TIER_WRITE_LOAD_BALANCE_FACTOR_SETTING,
+            value -> this.indexingTierWriteLoadBalanceFactor = value
+        );
+    }
+
+    @Override
+    public PartitionedCluster create() {
+        return new TieredPartitionedCluster();
+    }
+
+    private class TieredPartitionedCluster implements PartitionedCluster {
+
+        private final WeightFunction searchWeightFunction;
+        private final WeightFunction indexingWeightFunction;
+
+        private TieredPartitionedCluster() {
+            this.searchWeightFunction = new WeightFunction(
+                searchTierShardBalanceFactor,
+                balancerSettings.getIndexBalanceFactor(),
+                0.0f,
+                balancerSettings.getDiskUsageBalanceFactor()
+            );
+            this.indexingWeightFunction = new WeightFunction(
+                indexingTierShardBalanceFactor,
+                balancerSettings.getIndexBalanceFactor(),
+                indexingTierWriteLoadBalanceFactor,
+                balancerSettings.getDiskUsageBalanceFactor()
+            );
+        }
+
+        @Override
+        public WeightFunction weightFunctionForShard(ShardRouting shard) {
+            if (shard.role() == ShardRouting.Role.SEARCH_ONLY) {
+                return searchWeightFunction;
+            } else if (shard.role() == ShardRouting.Role.INDEX_ONLY) {
+                return indexingWeightFunction;
+            } else {
+                throw new IllegalArgumentException("Unsupported shard role [" + shard.role() + "]");
+            }
+        }
+
+        @Override
+        public WeightFunction weightFunctionForNode(RoutingNode node) {
+            Set<DiscoveryNodeRole> roles = node.node().getRoles();
+            if (roles.contains(DiscoveryNodeRole.SEARCH_ROLE)) {
+                return searchWeightFunction;
+            } else if (roles.contains(DiscoveryNodeRole.INDEX_ROLE)) {
+                return indexingWeightFunction;
+            } else {
+                throw new IllegalArgumentException("Node is neither indexing or search node, roles = " + roles);
+            }
+        }
+
+        @Override
+        public PartitionedNodeSorter createPartitionedNodeSorter(
+            BalancedShardsAllocator.ModelNode[] modelNodes,
+            BalancedShardsAllocator.Balancer balancer
+        ) {
+            return new TieredPartitionedNodeSorter(modelNodes, balancer);
+        }
+
+        private class TieredPartitionedNodeSorter implements PartitionedNodeSorter {
path: server/src/main/java/org/elasticsearch/cluster/routing/allocation/allocator/TieredPartitionedClusterFactory.java
user: login: DiannaHohensee
body: This is an awful lot like a wrapper class, with a twist around wrapping multiple instances of `NodeSorter`. It might be more transparent, of the class' purpose, to name it some sort of wrapper? 

`ServerlessNodeSorterWrapper` and `NodeSorterWrapper`? And `Global*`
created_at: 2025-04-11T15:10:13Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,175 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the "Elastic License
+ * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
+ * Public License v 1"; you may not use this file except in compliance with, at
+ * your election, the "Elastic License 2.0", the "GNU Affero General Public
+ * License v3.0 only", or the "Server Side Public License, v 1".
+ */
+
+package org.elasticsearch.cluster.routing.allocation.allocator;
+
+import org.elasticsearch.cluster.node.DiscoveryNodeRole;
+import org.elasticsearch.cluster.routing.RoutingNode;
+import org.elasticsearch.cluster.routing.ShardRouting;
+import org.elasticsearch.common.settings.ClusterSettings;
+import org.elasticsearch.common.settings.Setting;
+
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.List;
+import java.util.Set;
+import java.util.stream.Stream;
+
+import static org.elasticsearch.cluster.routing.allocation.allocator.BalancedShardsAllocator.SHARD_BALANCE_FACTOR_SETTING;
+import static org.elasticsearch.cluster.routing.allocation.allocator.BalancedShardsAllocator.WRITE_LOAD_BALANCE_FACTOR_SETTING;
+
+public class TieredPartitionedClusterFactory implements PartitionedClusterFactory {
+
+    public static final Setting<Float> INDEXING_TIER_SHARD_BALANCE_FACTOR_SETTING = Setting.floatSetting(
+        "cluster.routing.allocation.balance.shard.indexing",
+        SHARD_BALANCE_FACTOR_SETTING,
+        0.0f,
+        Setting.Property.Dynamic,
+        Setting.Property.NodeScope
+    );
+
+    public static final Setting<Float> SEARCH_TIER_SHARD_BALANCE_FACTOR_SETTING = Setting.floatSetting(
+        "cluster.routing.allocation.balance.shard.search",
+        SHARD_BALANCE_FACTOR_SETTING,
+        0.0f,
+        Setting.Property.Dynamic,
+        Setting.Property.NodeScope
+    );
+
+    public static final Setting<Float> INDEXING_TIER_WRITE_LOAD_BALANCE_FACTOR_SETTING = Setting.floatSetting(
+        "cluster.routing.allocation.balance.write_load.indexing",
+        WRITE_LOAD_BALANCE_FACTOR_SETTING,
+        0.0f,
+        Setting.Property.Dynamic,
+        Setting.Property.NodeScope
+    );
+
+    private final BalancerSettings balancerSettings;
+    private volatile float indexingTierShardBalanceFactor;
+    private volatile float searchTierShardBalanceFactor;
+    private volatile float indexingTierWriteLoadBalanceFactor;
+
+    public TieredPartitionedClusterFactory(BalancerSettings balancerSettings, ClusterSettings clusterSettings) {
+        this.balancerSettings = balancerSettings;
+        clusterSettings.initializeAndWatch(
+            INDEXING_TIER_SHARD_BALANCE_FACTOR_SETTING,
+            value -> this.indexingTierShardBalanceFactor = value
+        );
+        clusterSettings.initializeAndWatch(SEARCH_TIER_SHARD_BALANCE_FACTOR_SETTING, value -> this.searchTierShardBalanceFactor = value);
+        clusterSettings.initializeAndWatch(
+            INDEXING_TIER_WRITE_LOAD_BALANCE_FACTOR_SETTING,
+            value -> this.indexingTierWriteLoadBalanceFactor = value
+        );
+    }
+
+    @Override
+    public PartitionedCluster create() {
+        return new TieredPartitionedCluster();
+    }
+
+    private class TieredPartitionedCluster implements PartitionedCluster {
+
+        private final WeightFunction searchWeightFunction;
+        private final WeightFunction indexingWeightFunction;
+
+        private TieredPartitionedCluster() {
+            this.searchWeightFunction = new WeightFunction(
+                searchTierShardBalanceFactor,
+                balancerSettings.getIndexBalanceFactor(),
+                0.0f,
+                balancerSettings.getDiskUsageBalanceFactor()
+            );
+            this.indexingWeightFunction = new WeightFunction(
+                indexingTierShardBalanceFactor,
+                balancerSettings.getIndexBalanceFactor(),
+                indexingTierWriteLoadBalanceFactor,
+                balancerSettings.getDiskUsageBalanceFactor()
+            );
+        }
+
+        @Override
+        public WeightFunction weightFunctionForShard(ShardRouting shard) {
+            if (shard.role() == ShardRouting.Role.SEARCH_ONLY) {
+                return searchWeightFunction;
+            } else if (shard.role() == ShardRouting.Role.INDEX_ONLY) {
+                return indexingWeightFunction;
+            } else {
+                throw new IllegalArgumentException("Unsupported shard role [" + shard.role() + "]");
+            }
+        }
+
+        @Override
+        public WeightFunction weightFunctionForNode(RoutingNode node) {
+            Set<DiscoveryNodeRole> roles = node.node().getRoles();
+            if (roles.contains(DiscoveryNodeRole.SEARCH_ROLE)) {
+                return searchWeightFunction;
+            } else if (roles.contains(DiscoveryNodeRole.INDEX_ROLE)) {
+                return indexingWeightFunction;
+            } else {
+                throw new IllegalArgumentException("Node is neither indexing or search node, roles = " + roles);
+            }
+        }
+
+        @Override
+        public PartitionedNodeSorter createPartitionedNodeSorter(
+            BalancedShardsAllocator.ModelNode[] modelNodes,
+            BalancedShardsAllocator.Balancer balancer
+        ) {
+            return new TieredPartitionedNodeSorter(modelNodes, balancer);
+        }
+
+        private class TieredPartitionedNodeSorter implements PartitionedNodeSorter {
+
+            private final BalancedShardsAllocator.NodeSorter searchNodeSorter;
+            private final BalancedShardsAllocator.NodeSorter indexingNodeSorter;
+            private final List<BalancedShardsAllocator.NodeSorter> allNodeSorters;
+
+            TieredPartitionedNodeSorter(BalancedShardsAllocator.ModelNode[] allNodes, BalancedShardsAllocator.Balancer balancer) {
+                final BalancedShardsAllocator.ModelNode[] searchNodes = Arrays.stream(allNodes)
+                    .filter(n -> n.getRoutingNode().node().getRoles().contains(DiscoveryNodeRole.SEARCH_ROLE))
+                    .toArray(BalancedShardsAllocator.ModelNode[]::new);
+                final BalancedShardsAllocator.ModelNode[] indexingNodes = Arrays.stream(allNodes)
+                    .filter(n -> n.getRoutingNode().node().getRoles().contains(DiscoveryNodeRole.INDEX_ROLE))
+                    .toArray(BalancedShardsAllocator.ModelNode[]::new);
+                assert nodePartitionsAreDisjointAndUnionToAll(allNodes, searchNodes, indexingNodes);
+                searchNodeSorter = new BalancedShardsAllocator.NodeSorter(searchNodes, searchWeightFunction, balancer);
+                indexingNodeSorter = new BalancedShardsAllocator.NodeSorter(indexingNodes, indexingWeightFunction, balancer);
+                allNodeSorters = List.of(indexingNodeSorter, searchNodeSorter);
+            }
+
+            @Override
+            public Collection<BalancedShardsAllocator.NodeSorter> allNodeSorters() {
+                return allNodeSorters;
+            }
+
+            @Override
+            public BalancedShardsAllocator.NodeSorter sorterForShard(ShardRouting shard) {
+                if (shard.role() == ShardRouting.Role.SEARCH_ONLY) {
+                    return searchNodeSorter;
+                } else if (shard.role() == ShardRouting.Role.INDEX_ONLY) {
+                    return indexingNodeSorter;
+                } else {
+                    throw new IllegalArgumentException("Unsupported shard role [" + shard.role() + "]");
+                }
+            }
+        }
+
+        private static boolean nodePartitionsAreDisjointAndUnionToAll(
path: server/src/main/java/org/elasticsearch/cluster/routing/allocation/allocator/TieredPartitionedClusterFactory.java
user: login: DiannaHohensee
body: This should go inside TieredPartitionedNodeSorter, since it's only used in there?
created_at: 2025-04-11T15:16:18Z
author_association: CONTRIBUTOR
diff_hunk: @@ -74,13 +64,14 @@ public Map<String, NodeAllocationStatsAndWeight> nodesAllocationStatsAndWeights(
             // must not use licensed features when just starting up
             writeLoadForecaster.refreshLicense();
         }
-        var weightFunction = new WeightFunction(shardBalanceFactor, indexBalanceFactor, writeLoadBalanceFactor, diskUsageBalanceFactor);
+        final PartitionedCluster partitionedCluster = partitionedClusterFactory.create();
         var avgShardsPerNode = WeightFunction.avgShardPerNode(metadata, routingNodes);
         var avgWriteLoadPerNode = WeightFunction.avgWriteLoadPerNode(writeLoadForecaster, metadata, routingNodes);
         var avgDiskUsageInBytesPerNode = WeightFunction.avgDiskUsageInBytesPerNode(clusterInfo, metadata, routingNodes);
 
         var nodeAllocationStatsAndWeights = Maps.<String, NodeAllocationStatsAndWeight>newMapWithExpectedSize(routingNodes.size());
         for (RoutingNode node : routingNodes) {
+            WeightFunction weightFunction = partitionedCluster.weightFunctionForNode(node);
path: server/src/main/java/org/elasticsearch/cluster/routing/allocation/NodeAllocationStatsAndWeightsCalculator.java
user: login: DiannaHohensee
body: ```suggestion
            WeightFunction nodeWeightFunction = partitionedCluster.weightFunctionForNode(node);
```
Nit. Since it's per node now, might be nice to reflect that.
created_at: 2025-04-11T15:48:38Z
author_association: CONTRIBUTOR
diff_hunk: @@ -74,13 +64,14 @@ public Map<String, NodeAllocationStatsAndWeight> nodesAllocationStatsAndWeights(
             // must not use licensed features when just starting up
             writeLoadForecaster.refreshLicense();
         }
-        var weightFunction = new WeightFunction(shardBalanceFactor, indexBalanceFactor, writeLoadBalanceFactor, diskUsageBalanceFactor);
+        final PartitionedCluster partitionedCluster = partitionedClusterFactory.create();
path: server/src/main/java/org/elasticsearch/cluster/routing/allocation/NodeAllocationStatsAndWeightsCalculator.java
user: login: DiannaHohensee
body: This doesn't change from call to call -- maybe it did once upon a time? Or does it somehow?

Shouldn't the PartitionedCluster (a global or tiered instance) be created once and locally save in the constructor, and then repeatedly use the saved PartitionedCluster instance?
created_at: 2025-04-11T15:52:35Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,5 @@
+pr: 126091
+summary: Different approach at allowing separate weights per tier
path: docs/changelog/126091.yaml
user: login: DiannaHohensee
body: Your commit message seems better now than when this was generated?

This patch also currently has the side-effect of the search tier using 0 write load. Raises the question of whether you want to make that change in this patch, or set up the code to allow it and then a separate patch to start doing it.
created_at: 2025-04-11T15:58:56Z
author_association: CONTRIBUTOR
diff_hunk: @@ -158,25 +162,21 @@ public void allocate(RoutingAllocation allocation) {
             failAllocationOfNewPrimaries(allocation);
             return;
         }
-        final WeightFunction weightFunction = new WeightFunction(
-            shardBalanceFactor,
-            indexBalanceFactor,
-            writeLoadBalanceFactor,
-            diskUsageBalanceFactor
-        );
-        final Balancer balancer = new Balancer(writeLoadForecaster, allocation, weightFunction, threshold);
+        final PartitionedCluster partitionedCluster = partitionedClusterFactory.create();
path: server/src/main/java/org/elasticsearch/cluster/routing/allocation/allocator/BalancedShardsAllocator.java
user: login: DiannaHohensee
body: Similarly, can we create the `PartitionedCluster` instance once and save it, instead of the `*Factory`?

Maybe this ties back to Henning's [posted](https://github.com/elastic/elasticsearch/pull/126091#discussion_r2039362688) thought about decreasing interface intermediaries. Though my only concern about merging the interfaces is whether it makes the code harder to understand -- I haven't thought about it.
created_at: 2025-04-11T16:04:01Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,22 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the "Elastic License
+ * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
+ * Public License v 1"; you may not use this file except in compliance with, at
+ * your election, the "Elastic License 2.0", the "GNU Affero General Public
+ * License v3.0 only", or the "Server Side Public License, v 1".
+ */
+
+package org.elasticsearch.cluster.routing.allocation.allocator;
+
+/**
+ * A partitioned cluster factory must be able to divide all shards and nodes into mutually
+ * disjoint partitions. Allocation balancing will then be conducted sequentially for each partition.
+ * <p>
+ * If you can't partition your shards and nodes in this way, use
+ * {@link org.elasticsearch.cluster.routing.allocation.allocator.GlobalPartitionedClusterFactory}
+ */
+public interface PartitionedClusterFactory {
path: server/src/main/java/org/elasticsearch/cluster/routing/allocation/allocator/PartitionedClusterFactory.java
user: login: DiannaHohensee
body: Don't all instances of `PartitionaClusterFactory#create()` reference back to the original `BalancerSettings` instance that ties its local variables to the cluster state for updates? Or do I have this wrong 🤔 
created_at: 2025-04-11T16:09:11Z
author_association: CONTRIBUTOR
diff_hunk: @@ -273,32 +268,32 @@ public static class Balancer {
         private final RoutingAllocation allocation;
         private final RoutingNodes routingNodes;
         private final Metadata metadata;
-        private final WeightFunction weightFunction;
 
         private final float threshold;
         private final float avgShardsPerNode;
         private final double avgWriteLoadPerNode;
         private final double avgDiskUsageInBytesPerNode;
         private final Map<String, ModelNode> nodes;
-        private final NodeSorter sorter;
+        private final PartitionedCluster partitionedCluster;
+        private final PartitionedNodeSorter partitionedNodeSorter;
 
         private Balancer(
             WriteLoadForecaster writeLoadForecaster,
             RoutingAllocation allocation,
-            WeightFunction weightFunction,
-            float threshold
+            float threshold,
+            PartitionedCluster partitionedCluster
         ) {
             this.writeLoadForecaster = writeLoadForecaster;
             this.allocation = allocation;
             this.routingNodes = allocation.routingNodes();
             this.metadata = allocation.metadata();
-            this.weightFunction = weightFunction;
             this.threshold = threshold;
             avgShardsPerNode = WeightFunction.avgShardPerNode(metadata, routingNodes);
             avgWriteLoadPerNode = WeightFunction.avgWriteLoadPerNode(writeLoadForecaster, metadata, routingNodes);
             avgDiskUsageInBytesPerNode = WeightFunction.avgDiskUsageInBytesPerNode(allocation.clusterInfo(), metadata, routingNodes);
path: server/src/main/java/org/elasticsearch/cluster/routing/allocation/allocator/BalancedShardsAllocator.java
user: login: DiannaHohensee
body: Maybe this wouldn't be costly -- just more complex -- if we pre-calculated primary vs replica counts when setting up `ProjectMetadata#totalNumberOfShards`? Similarly with the other values. But.. that is quite a bit of fiddly code to maintain.

I wonder, though, whether this code could already be doing weird things to our balance calculations in serverless. For example, hypothetically, if the search tier has 3x the number of shards -- say 3 replica copies per shard -- then the index tier nodes are going to increase the shard weight component (balancing-factor-constant x difference-from-average).
created_at: 2025-04-11T16:20:42Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,22 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the "Elastic License
+ * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
+ * Public License v 1"; you may not use this file except in compliance with, at
+ * your election, the "Elastic License 2.0", the "GNU Affero General Public
+ * License v3.0 only", or the "Server Side Public License, v 1".
+ */
+
+package org.elasticsearch.cluster.routing.allocation.allocator;
+
+/**
+ * A partitioned cluster factory must be able to divide all shards and nodes into mutually
+ * disjoint partitions. Allocation balancing will then be conducted sequentially for each partition.
+ * <p>
+ * If you can't partition your shards and nodes in this way, use
+ * {@link org.elasticsearch.cluster.routing.allocation.allocator.GlobalPartitionedClusterFactory}
+ */
+public interface PartitionedClusterFactory {
path: server/src/main/java/org/elasticsearch/cluster/routing/allocation/allocator/PartitionedClusterFactory.java
user: login: nicktindall
body: Yes, but we take a copy of them at the moment the `PartitionedCluster` instance is created. i.e. if someone updates the settings in the middle of a balance, the running balance will continue to use the copy it took when the balancing began. This is analogous to how the old singular `WeightFunction` was constructed in the call to `allocate()`
created_at: 2025-04-14T01:18:43Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,175 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the "Elastic License
+ * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
+ * Public License v 1"; you may not use this file except in compliance with, at
+ * your election, the "Elastic License 2.0", the "GNU Affero General Public
+ * License v3.0 only", or the "Server Side Public License, v 1".
+ */
+
+package org.elasticsearch.cluster.routing.allocation.allocator;
+
+import org.elasticsearch.cluster.node.DiscoveryNodeRole;
+import org.elasticsearch.cluster.routing.RoutingNode;
+import org.elasticsearch.cluster.routing.ShardRouting;
+import org.elasticsearch.common.settings.ClusterSettings;
+import org.elasticsearch.common.settings.Setting;
+
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.List;
+import java.util.Set;
+import java.util.stream.Stream;
+
+import static org.elasticsearch.cluster.routing.allocation.allocator.BalancedShardsAllocator.SHARD_BALANCE_FACTOR_SETTING;
+import static org.elasticsearch.cluster.routing.allocation.allocator.BalancedShardsAllocator.WRITE_LOAD_BALANCE_FACTOR_SETTING;
+
+public class TieredPartitionedClusterFactory implements PartitionedClusterFactory {
+
+    public static final Setting<Float> INDEXING_TIER_SHARD_BALANCE_FACTOR_SETTING = Setting.floatSetting(
path: server/src/main/java/org/elasticsearch/cluster/routing/allocation/allocator/TieredPartitionedClusterFactory.java
user: login: nicktindall
body: Yeah I didn't move the existing ones partly to minimise the change, but also because the pattern seemed to be to define the settings in the class that used them (I might have read that wrong). The new settings will probably end up in stateless because they're not relevant to stateful ES.
created_at: 2025-04-14T01:21:25Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,175 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the "Elastic License
+ * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
+ * Public License v 1"; you may not use this file except in compliance with, at
+ * your election, the "Elastic License 2.0", the "GNU Affero General Public
+ * License v3.0 only", or the "Server Side Public License, v 1".
+ */
+
+package org.elasticsearch.cluster.routing.allocation.allocator;
+
+import org.elasticsearch.cluster.node.DiscoveryNodeRole;
+import org.elasticsearch.cluster.routing.RoutingNode;
+import org.elasticsearch.cluster.routing.ShardRouting;
+import org.elasticsearch.common.settings.ClusterSettings;
+import org.elasticsearch.common.settings.Setting;
+
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.List;
+import java.util.Set;
+import java.util.stream.Stream;
+
+import static org.elasticsearch.cluster.routing.allocation.allocator.BalancedShardsAllocator.SHARD_BALANCE_FACTOR_SETTING;
+import static org.elasticsearch.cluster.routing.allocation.allocator.BalancedShardsAllocator.WRITE_LOAD_BALANCE_FACTOR_SETTING;
+
+public class TieredPartitionedClusterFactory implements PartitionedClusterFactory {
path: server/src/main/java/org/elasticsearch/cluster/routing/allocation/allocator/TieredPartitionedClusterFactory.java
user: login: nicktindall
body: Yep, naming is hard :) I'll update.
created_at: 2025-04-14T01:28:45Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,175 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the "Elastic License
+ * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
+ * Public License v 1"; you may not use this file except in compliance with, at
+ * your election, the "Elastic License 2.0", the "GNU Affero General Public
+ * License v3.0 only", or the "Server Side Public License, v 1".
+ */
+
+package org.elasticsearch.cluster.routing.allocation.allocator;
+
+import org.elasticsearch.cluster.node.DiscoveryNodeRole;
+import org.elasticsearch.cluster.routing.RoutingNode;
+import org.elasticsearch.cluster.routing.ShardRouting;
+import org.elasticsearch.common.settings.ClusterSettings;
+import org.elasticsearch.common.settings.Setting;
+
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.List;
+import java.util.Set;
+import java.util.stream.Stream;
+
+import static org.elasticsearch.cluster.routing.allocation.allocator.BalancedShardsAllocator.SHARD_BALANCE_FACTOR_SETTING;
+import static org.elasticsearch.cluster.routing.allocation.allocator.BalancedShardsAllocator.WRITE_LOAD_BALANCE_FACTOR_SETTING;
+
+public class TieredPartitionedClusterFactory implements PartitionedClusterFactory {
path: server/src/main/java/org/elasticsearch/cluster/routing/allocation/allocator/TieredPartitionedClusterFactory.java
user: login: nicktindall
body: Addressed in 321ce06faf74bd64a27921769cc9fab1d39a8351
created_at: 2025-04-14T01:45:13Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,25 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the "Elastic License
+ * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
+ * Public License v 1"; you may not use this file except in compliance with, at
+ * your election, the "Elastic License 2.0", the "GNU Affero General Public
+ * License v3.0 only", or the "Server Side Public License, v 1".
+ */
+
+package org.elasticsearch.cluster.routing.allocation.allocator;
+
+import org.elasticsearch.cluster.routing.RoutingNode;
+import org.elasticsearch.cluster.routing.ShardRouting;
+
+public interface PartitionedCluster {
path: server/src/main/java/org/elasticsearch/cluster/routing/allocation/allocator/PartitionedCluster.java
user: login: nicktindall
body: Addressed in 321ce06faf74bd64a27921769cc9fab1d39a8351
created_at: 2025-04-14T01:45:19Z
author_association: CONTRIBUTOR
diff_hunk: @@ -74,13 +64,14 @@ public Map<String, NodeAllocationStatsAndWeight> nodesAllocationStatsAndWeights(
             // must not use licensed features when just starting up
             writeLoadForecaster.refreshLicense();
         }
-        var weightFunction = new WeightFunction(shardBalanceFactor, indexBalanceFactor, writeLoadBalanceFactor, diskUsageBalanceFactor);
+        final PartitionedCluster partitionedCluster = partitionedClusterFactory.create();
path: server/src/main/java/org/elasticsearch/cluster/routing/allocation/NodeAllocationStatsAndWeightsCalculator.java
user: login: nicktindall
body: As I mentioned above it's just about capturing the weights at this point so they remain static throughout the balancing. If we kept a single long-lived instance it would have to keep a reference to the dynamic weights and they may be updated mid-balance. It's probably not the end of the world but I was trying to keep the behaviour consistent with the existing implementation. 
created_at: 2025-04-14T02:06:31Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,5 @@
+pr: 126091
+summary: Different approach at allowing separate weights per tier
path: docs/changelog/126091.yaml
user: login: nicktindall
body: Changelog fixed in 69521699ff354cd3ec04bcda654f50785ebe34c6, also I moved the serverless changes to a separate [serverless PR](https://github.com/elastic/elasticsearch-serverless/pull/3766). In that I put the use of the `StatelessBalancingWeightsFactory` behind a feature flag.
created_at: 2025-04-14T04:03:59Z
author_association: CONTRIBUTOR
diff_hunk: @@ -114,14 +114,9 @@ public class BalancedShardsAllocator implements ShardsAllocator {
         Property.NodeScope
     );
 
-    // TODO: deduplicate these fields, use the fields in NodeAllocationStatsAndWeightsCalculator instead.
path: server/src/main/java/org/elasticsearch/cluster/routing/allocation/allocator/BalancedShardsAllocator.java
user: login: nicktindall
body: I broke out #126751 and #126752 to reduce the surface area of this change
created_at: 2025-04-14T05:06:12Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,175 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the "Elastic License
+ * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
+ * Public License v 1"; you may not use this file except in compliance with, at
+ * your election, the "Elastic License 2.0", the "GNU Affero General Public
+ * License v3.0 only", or the "Server Side Public License, v 1".
+ */
+
+package org.elasticsearch.cluster.routing.allocation.allocator;
+
+import org.elasticsearch.cluster.node.DiscoveryNodeRole;
+import org.elasticsearch.cluster.routing.RoutingNode;
+import org.elasticsearch.cluster.routing.ShardRouting;
+import org.elasticsearch.common.settings.ClusterSettings;
+import org.elasticsearch.common.settings.Setting;
+
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.List;
+import java.util.Set;
+import java.util.stream.Stream;
+
+import static org.elasticsearch.cluster.routing.allocation.allocator.BalancedShardsAllocator.SHARD_BALANCE_FACTOR_SETTING;
+import static org.elasticsearch.cluster.routing.allocation.allocator.BalancedShardsAllocator.WRITE_LOAD_BALANCE_FACTOR_SETTING;
+
+public class TieredPartitionedClusterFactory implements PartitionedClusterFactory {
+
+    public static final Setting<Float> INDEXING_TIER_SHARD_BALANCE_FACTOR_SETTING = Setting.floatSetting(
+        "cluster.routing.allocation.balance.shard.indexing",
+        SHARD_BALANCE_FACTOR_SETTING,
+        0.0f,
+        Setting.Property.Dynamic,
+        Setting.Property.NodeScope
+    );
+
+    public static final Setting<Float> SEARCH_TIER_SHARD_BALANCE_FACTOR_SETTING = Setting.floatSetting(
+        "cluster.routing.allocation.balance.shard.search",
+        SHARD_BALANCE_FACTOR_SETTING,
+        0.0f,
+        Setting.Property.Dynamic,
+        Setting.Property.NodeScope
+    );
+
+    public static final Setting<Float> INDEXING_TIER_WRITE_LOAD_BALANCE_FACTOR_SETTING = Setting.floatSetting(
+        "cluster.routing.allocation.balance.write_load.indexing",
+        WRITE_LOAD_BALANCE_FACTOR_SETTING,
+        0.0f,
+        Setting.Property.Dynamic,
+        Setting.Property.NodeScope
+    );
+
+    private final BalancerSettings balancerSettings;
+    private volatile float indexingTierShardBalanceFactor;
+    private volatile float searchTierShardBalanceFactor;
+    private volatile float indexingTierWriteLoadBalanceFactor;
+
+    public TieredPartitionedClusterFactory(BalancerSettings balancerSettings, ClusterSettings clusterSettings) {
+        this.balancerSettings = balancerSettings;
+        clusterSettings.initializeAndWatch(
+            INDEXING_TIER_SHARD_BALANCE_FACTOR_SETTING,
+            value -> this.indexingTierShardBalanceFactor = value
+        );
+        clusterSettings.initializeAndWatch(SEARCH_TIER_SHARD_BALANCE_FACTOR_SETTING, value -> this.searchTierShardBalanceFactor = value);
+        clusterSettings.initializeAndWatch(
+            INDEXING_TIER_WRITE_LOAD_BALANCE_FACTOR_SETTING,
+            value -> this.indexingTierWriteLoadBalanceFactor = value
+        );
+    }
+
+    @Override
+    public PartitionedCluster create() {
+        return new TieredPartitionedCluster();
+    }
+
+    private class TieredPartitionedCluster implements PartitionedCluster {
+
+        private final WeightFunction searchWeightFunction;
+        private final WeightFunction indexingWeightFunction;
+
+        private TieredPartitionedCluster() {
+            this.searchWeightFunction = new WeightFunction(
+                searchTierShardBalanceFactor,
+                balancerSettings.getIndexBalanceFactor(),
+                0.0f,
+                balancerSettings.getDiskUsageBalanceFactor()
+            );
+            this.indexingWeightFunction = new WeightFunction(
+                indexingTierShardBalanceFactor,
+                balancerSettings.getIndexBalanceFactor(),
+                indexingTierWriteLoadBalanceFactor,
+                balancerSettings.getDiskUsageBalanceFactor()
+            );
+        }
+
+        @Override
+        public WeightFunction weightFunctionForShard(ShardRouting shard) {
+            if (shard.role() == ShardRouting.Role.SEARCH_ONLY) {
+                return searchWeightFunction;
+            } else if (shard.role() == ShardRouting.Role.INDEX_ONLY) {
+                return indexingWeightFunction;
+            } else {
+                throw new IllegalArgumentException("Unsupported shard role [" + shard.role() + "]");
+            }
+        }
+
+        @Override
+        public WeightFunction weightFunctionForNode(RoutingNode node) {
+            Set<DiscoveryNodeRole> roles = node.node().getRoles();
+            if (roles.contains(DiscoveryNodeRole.SEARCH_ROLE)) {
+                return searchWeightFunction;
+            } else if (roles.contains(DiscoveryNodeRole.INDEX_ROLE)) {
+                return indexingWeightFunction;
+            } else {
+                throw new IllegalArgumentException("Node is neither indexing or search node, roles = " + roles);
path: server/src/main/java/org/elasticsearch/cluster/routing/allocation/allocator/TieredPartitionedClusterFactory.java
user: login: nicktindall
body: I had wondered this. Interestingly the serverless builds passed, I assume there must be tests with an ML node in them? I will dig into that though, I have made the assumption that all nodes have either `INDEX_ROLE` or `SEARCH_ROLE`. It would be especially problematic in `createPartitionedNodeSorter` because the serverless implementation asserts that the two tiers collectively include all the nodes.
created_at: 2025-04-14T05:09:43Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,175 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the "Elastic License
+ * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
+ * Public License v 1"; you may not use this file except in compliance with, at
+ * your election, the "Elastic License 2.0", the "GNU Affero General Public
+ * License v3.0 only", or the "Server Side Public License, v 1".
+ */
+
+package org.elasticsearch.cluster.routing.allocation.allocator;
+
+import org.elasticsearch.cluster.node.DiscoveryNodeRole;
+import org.elasticsearch.cluster.routing.RoutingNode;
+import org.elasticsearch.cluster.routing.ShardRouting;
+import org.elasticsearch.common.settings.ClusterSettings;
+import org.elasticsearch.common.settings.Setting;
+
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.List;
+import java.util.Set;
+import java.util.stream.Stream;
+
+import static org.elasticsearch.cluster.routing.allocation.allocator.BalancedShardsAllocator.SHARD_BALANCE_FACTOR_SETTING;
+import static org.elasticsearch.cluster.routing.allocation.allocator.BalancedShardsAllocator.WRITE_LOAD_BALANCE_FACTOR_SETTING;
+
+public class TieredPartitionedClusterFactory implements PartitionedClusterFactory {
+
+    public static final Setting<Float> INDEXING_TIER_SHARD_BALANCE_FACTOR_SETTING = Setting.floatSetting(
+        "cluster.routing.allocation.balance.shard.indexing",
+        SHARD_BALANCE_FACTOR_SETTING,
+        0.0f,
+        Setting.Property.Dynamic,
+        Setting.Property.NodeScope
+    );
+
+    public static final Setting<Float> SEARCH_TIER_SHARD_BALANCE_FACTOR_SETTING = Setting.floatSetting(
+        "cluster.routing.allocation.balance.shard.search",
+        SHARD_BALANCE_FACTOR_SETTING,
+        0.0f,
+        Setting.Property.Dynamic,
+        Setting.Property.NodeScope
+    );
+
+    public static final Setting<Float> INDEXING_TIER_WRITE_LOAD_BALANCE_FACTOR_SETTING = Setting.floatSetting(
+        "cluster.routing.allocation.balance.write_load.indexing",
+        WRITE_LOAD_BALANCE_FACTOR_SETTING,
+        0.0f,
+        Setting.Property.Dynamic,
+        Setting.Property.NodeScope
+    );
+
+    private final BalancerSettings balancerSettings;
+    private volatile float indexingTierShardBalanceFactor;
+    private volatile float searchTierShardBalanceFactor;
+    private volatile float indexingTierWriteLoadBalanceFactor;
+
+    public TieredPartitionedClusterFactory(BalancerSettings balancerSettings, ClusterSettings clusterSettings) {
+        this.balancerSettings = balancerSettings;
+        clusterSettings.initializeAndWatch(
+            INDEXING_TIER_SHARD_BALANCE_FACTOR_SETTING,
+            value -> this.indexingTierShardBalanceFactor = value
+        );
+        clusterSettings.initializeAndWatch(SEARCH_TIER_SHARD_BALANCE_FACTOR_SETTING, value -> this.searchTierShardBalanceFactor = value);
+        clusterSettings.initializeAndWatch(
+            INDEXING_TIER_WRITE_LOAD_BALANCE_FACTOR_SETTING,
+            value -> this.indexingTierWriteLoadBalanceFactor = value
+        );
+    }
+
+    @Override
+    public PartitionedCluster create() {
+        return new TieredPartitionedCluster();
+    }
+
+    private class TieredPartitionedCluster implements PartitionedCluster {
+
+        private final WeightFunction searchWeightFunction;
+        private final WeightFunction indexingWeightFunction;
+
+        private TieredPartitionedCluster() {
+            this.searchWeightFunction = new WeightFunction(
+                searchTierShardBalanceFactor,
+                balancerSettings.getIndexBalanceFactor(),
+                0.0f,
+                balancerSettings.getDiskUsageBalanceFactor()
+            );
+            this.indexingWeightFunction = new WeightFunction(
+                indexingTierShardBalanceFactor,
+                balancerSettings.getIndexBalanceFactor(),
+                indexingTierWriteLoadBalanceFactor,
+                balancerSettings.getDiskUsageBalanceFactor()
+            );
+        }
+
+        @Override
+        public WeightFunction weightFunctionForShard(ShardRouting shard) {
+            if (shard.role() == ShardRouting.Role.SEARCH_ONLY) {
+                return searchWeightFunction;
+            } else if (shard.role() == ShardRouting.Role.INDEX_ONLY) {
+                return indexingWeightFunction;
+            } else {
+                throw new IllegalArgumentException("Unsupported shard role [" + shard.role() + "]");
+            }
+        }
+
+        @Override
+        public WeightFunction weightFunctionForNode(RoutingNode node) {
+            Set<DiscoveryNodeRole> roles = node.node().getRoles();
+            if (roles.contains(DiscoveryNodeRole.SEARCH_ROLE)) {
+                return searchWeightFunction;
+            } else if (roles.contains(DiscoveryNodeRole.INDEX_ROLE)) {
+                return indexingWeightFunction;
+            } else {
+                throw new IllegalArgumentException("Node is neither indexing or search node, roles = " + roles);
+            }
+        }
+
+        @Override
+        public PartitionedNodeSorter createPartitionedNodeSorter(
+            BalancedShardsAllocator.ModelNode[] modelNodes,
+            BalancedShardsAllocator.Balancer balancer
+        ) {
+            return new TieredPartitionedNodeSorter(modelNodes, balancer);
+        }
+
+        private class TieredPartitionedNodeSorter implements PartitionedNodeSorter {
+
+            private final BalancedShardsAllocator.NodeSorter searchNodeSorter;
+            private final BalancedShardsAllocator.NodeSorter indexingNodeSorter;
+            private final List<BalancedShardsAllocator.NodeSorter> allNodeSorters;
+
+            TieredPartitionedNodeSorter(BalancedShardsAllocator.ModelNode[] allNodes, BalancedShardsAllocator.Balancer balancer) {
+                final BalancedShardsAllocator.ModelNode[] searchNodes = Arrays.stream(allNodes)
+                    .filter(n -> n.getRoutingNode().node().getRoles().contains(DiscoveryNodeRole.SEARCH_ROLE))
+                    .toArray(BalancedShardsAllocator.ModelNode[]::new);
+                final BalancedShardsAllocator.ModelNode[] indexingNodes = Arrays.stream(allNodes)
+                    .filter(n -> n.getRoutingNode().node().getRoles().contains(DiscoveryNodeRole.INDEX_ROLE))
+                    .toArray(BalancedShardsAllocator.ModelNode[]::new);
+                assert nodePartitionsAreDisjointAndUnionToAll(allNodes, searchNodes, indexingNodes);
+                searchNodeSorter = new BalancedShardsAllocator.NodeSorter(searchNodes, searchWeightFunction, balancer);
+                indexingNodeSorter = new BalancedShardsAllocator.NodeSorter(indexingNodes, indexingWeightFunction, balancer);
+                allNodeSorters = List.of(indexingNodeSorter, searchNodeSorter);
+            }
+
+            @Override
+            public Collection<BalancedShardsAllocator.NodeSorter> allNodeSorters() {
+                return allNodeSorters;
+            }
+
+            @Override
+            public BalancedShardsAllocator.NodeSorter sorterForShard(ShardRouting shard) {
+                if (shard.role() == ShardRouting.Role.SEARCH_ONLY) {
+                    return searchNodeSorter;
+                } else if (shard.role() == ShardRouting.Role.INDEX_ONLY) {
+                    return indexingNodeSorter;
+                } else {
+                    throw new IllegalArgumentException("Unsupported shard role [" + shard.role() + "]");
+                }
+            }
+        }
+
+        private static boolean nodePartitionsAreDisjointAndUnionToAll(
path: server/src/main/java/org/elasticsearch/cluster/routing/allocation/allocator/TieredPartitionedClusterFactory.java
user: login: nicktindall
body: Fixed in https://github.com/elastic/elasticsearch-serverless/pull/3766/commits/09ace85d9ad77270e9e40a700dc77f584ccbf59a
created_at: 2025-04-14T05:30:18Z
author_association: CONTRIBUTOR
diff_hunk: @@ -273,32 +268,32 @@ public static class Balancer {
         private final RoutingAllocation allocation;
         private final RoutingNodes routingNodes;
         private final Metadata metadata;
-        private final WeightFunction weightFunction;
 
         private final float threshold;
         private final float avgShardsPerNode;
         private final double avgWriteLoadPerNode;
         private final double avgDiskUsageInBytesPerNode;
         private final Map<String, ModelNode> nodes;
-        private final NodeSorter sorter;
+        private final PartitionedCluster partitionedCluster;
+        private final PartitionedNodeSorter partitionedNodeSorter;
 
         private Balancer(
             WriteLoadForecaster writeLoadForecaster,
             RoutingAllocation allocation,
-            WeightFunction weightFunction,
-            float threshold
+            float threshold,
+            PartitionedCluster partitionedCluster
         ) {
             this.writeLoadForecaster = writeLoadForecaster;
             this.allocation = allocation;
             this.routingNodes = allocation.routingNodes();
             this.metadata = allocation.metadata();
-            this.weightFunction = weightFunction;
             this.threshold = threshold;
             avgShardsPerNode = WeightFunction.avgShardPerNode(metadata, routingNodes);
             avgWriteLoadPerNode = WeightFunction.avgWriteLoadPerNode(writeLoadForecaster, metadata, routingNodes);
             avgDiskUsageInBytesPerNode = WeightFunction.avgDiskUsageInBytesPerNode(allocation.clusterInfo(), metadata, routingNodes);
path: server/src/main/java/org/elasticsearch/cluster/routing/allocation/allocator/BalancedShardsAllocator.java
user: login: nicktindall
body: I think we would probably want to base the counting on `ShardRole`, because perhaps there might be a future where primaries live in the search tier for read-only indices? (wild speculation there, but you never know). That would keep the logic for deciding "which shards are search/indexing shards" in one place, in the `StatelessShardRoutingRoleStrategy`.

> I wonder, though, whether this code could already be doing weird things to our balance calculations in serverless.

Yes, it's a good point. Even just if there are different sized indexing and search tiers, some weights could never be balanced. e.g. if there were 5 indexing nodes and 1 search node, with an index with 5 shards, 1 replica. The average shard count would be 10/6 = 1.66~ so the indexing nodes would all be under-weight and the search node massively overweight in terms of shard count (I think). 

created_at: 2025-04-14T05:41:56Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1323,7 +1299,7 @@ public boolean containsShard(ShardRouting shard) {
         }
     }
 
-    static final class NodeSorter extends IntroSorter {
+    public static final class NodeSorter extends IntroSorter {
path: server/src/main/java/org/elasticsearch/cluster/routing/allocation/allocator/BalancedShardsAllocator.java
user: login: nicktindall
body: annoyingly these need to become public so the `StatelessBalancingWeightsFactory` can see/instantiate them
created_at: 2025-04-14T05:45:09Z
author_association: CONTRIBUTOR
diff_hunk: @@ -158,25 +162,21 @@ public void allocate(RoutingAllocation allocation) {
             failAllocationOfNewPrimaries(allocation);
             return;
         }
-        final WeightFunction weightFunction = new WeightFunction(
-            shardBalanceFactor,
-            indexBalanceFactor,
-            writeLoadBalanceFactor,
-            diskUsageBalanceFactor
-        );
-        final Balancer balancer = new Balancer(writeLoadForecaster, allocation, weightFunction, threshold);
+        final PartitionedCluster partitionedCluster = partitionedClusterFactory.create();
path: server/src/main/java/org/elasticsearch/cluster/routing/allocation/allocator/BalancedShardsAllocator.java
user: login: nicktindall
body: The issue with that is about taking a copy of the volatile settings values, so we run a balance with the weights at the point in time that the balance started. If we're happy for them to change mid-balance we can remove that lifecycle, but it would be inconsistent with the existing behaviour.
created_at: 2025-04-15T03:46:55Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,175 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the "Elastic License
+ * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
+ * Public License v 1"; you may not use this file except in compliance with, at
+ * your election, the "Elastic License 2.0", the "GNU Affero General Public
+ * License v3.0 only", or the "Server Side Public License, v 1".
+ */
+
+package org.elasticsearch.cluster.routing.allocation.allocator;
+
+import org.elasticsearch.cluster.node.DiscoveryNodeRole;
+import org.elasticsearch.cluster.routing.RoutingNode;
+import org.elasticsearch.cluster.routing.ShardRouting;
+import org.elasticsearch.common.settings.ClusterSettings;
+import org.elasticsearch.common.settings.Setting;
+
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.List;
+import java.util.Set;
+import java.util.stream.Stream;
+
+import static org.elasticsearch.cluster.routing.allocation.allocator.BalancedShardsAllocator.SHARD_BALANCE_FACTOR_SETTING;
+import static org.elasticsearch.cluster.routing.allocation.allocator.BalancedShardsAllocator.WRITE_LOAD_BALANCE_FACTOR_SETTING;
+
+public class TieredPartitionedClusterFactory implements PartitionedClusterFactory {
+
+    public static final Setting<Float> INDEXING_TIER_SHARD_BALANCE_FACTOR_SETTING = Setting.floatSetting(
+        "cluster.routing.allocation.balance.shard.indexing",
+        SHARD_BALANCE_FACTOR_SETTING,
+        0.0f,
+        Setting.Property.Dynamic,
+        Setting.Property.NodeScope
+    );
+
+    public static final Setting<Float> SEARCH_TIER_SHARD_BALANCE_FACTOR_SETTING = Setting.floatSetting(
+        "cluster.routing.allocation.balance.shard.search",
+        SHARD_BALANCE_FACTOR_SETTING,
+        0.0f,
+        Setting.Property.Dynamic,
+        Setting.Property.NodeScope
+    );
+
+    public static final Setting<Float> INDEXING_TIER_WRITE_LOAD_BALANCE_FACTOR_SETTING = Setting.floatSetting(
+        "cluster.routing.allocation.balance.write_load.indexing",
+        WRITE_LOAD_BALANCE_FACTOR_SETTING,
+        0.0f,
+        Setting.Property.Dynamic,
+        Setting.Property.NodeScope
+    );
+
+    private final BalancerSettings balancerSettings;
+    private volatile float indexingTierShardBalanceFactor;
+    private volatile float searchTierShardBalanceFactor;
+    private volatile float indexingTierWriteLoadBalanceFactor;
+
+    public TieredPartitionedClusterFactory(BalancerSettings balancerSettings, ClusterSettings clusterSettings) {
+        this.balancerSettings = balancerSettings;
+        clusterSettings.initializeAndWatch(
+            INDEXING_TIER_SHARD_BALANCE_FACTOR_SETTING,
+            value -> this.indexingTierShardBalanceFactor = value
+        );
+        clusterSettings.initializeAndWatch(SEARCH_TIER_SHARD_BALANCE_FACTOR_SETTING, value -> this.searchTierShardBalanceFactor = value);
+        clusterSettings.initializeAndWatch(
+            INDEXING_TIER_WRITE_LOAD_BALANCE_FACTOR_SETTING,
+            value -> this.indexingTierWriteLoadBalanceFactor = value
+        );
+    }
+
+    @Override
+    public PartitionedCluster create() {
+        return new TieredPartitionedCluster();
+    }
+
+    private class TieredPartitionedCluster implements PartitionedCluster {
+
+        private final WeightFunction searchWeightFunction;
+        private final WeightFunction indexingWeightFunction;
+
+        private TieredPartitionedCluster() {
+            this.searchWeightFunction = new WeightFunction(
+                searchTierShardBalanceFactor,
+                balancerSettings.getIndexBalanceFactor(),
+                0.0f,
+                balancerSettings.getDiskUsageBalanceFactor()
+            );
+            this.indexingWeightFunction = new WeightFunction(
+                indexingTierShardBalanceFactor,
+                balancerSettings.getIndexBalanceFactor(),
+                indexingTierWriteLoadBalanceFactor,
+                balancerSettings.getDiskUsageBalanceFactor()
+            );
+        }
+
+        @Override
+        public WeightFunction weightFunctionForShard(ShardRouting shard) {
+            if (shard.role() == ShardRouting.Role.SEARCH_ONLY) {
+                return searchWeightFunction;
+            } else if (shard.role() == ShardRouting.Role.INDEX_ONLY) {
+                return indexingWeightFunction;
+            } else {
+                throw new IllegalArgumentException("Unsupported shard role [" + shard.role() + "]");
+            }
+        }
+
+        @Override
+        public WeightFunction weightFunctionForNode(RoutingNode node) {
+            Set<DiscoveryNodeRole> roles = node.node().getRoles();
+            if (roles.contains(DiscoveryNodeRole.SEARCH_ROLE)) {
+                return searchWeightFunction;
+            } else if (roles.contains(DiscoveryNodeRole.INDEX_ROLE)) {
+                return indexingWeightFunction;
+            } else {
+                throw new IllegalArgumentException("Node is neither indexing or search node, roles = " + roles);
+            }
+        }
+
+        @Override
+        public PartitionedNodeSorter createPartitionedNodeSorter(
+            BalancedShardsAllocator.ModelNode[] modelNodes,
+            BalancedShardsAllocator.Balancer balancer
+        ) {
+            return new TieredPartitionedNodeSorter(modelNodes, balancer);
+        }
+
+        private class TieredPartitionedNodeSorter implements PartitionedNodeSorter {
path: server/src/main/java/org/elasticsearch/cluster/routing/allocation/allocator/TieredPartitionedClusterFactory.java
user: login: nicktindall
body: I changed the interface to just be `NodeSorters`. It's function is distinct from the `NodeSorter` itself, it needs to provide all the node sorters and identify the one relevant to a shard. See b9d02416b4945ab85ea6ce719d7e944d761a3295
created_at: 2025-04-17T02:24:15Z
author_association: CONTRIBUTOR
diff_hunk: @@ -424,7 +409,11 @@ private void balance() {
                 logger.trace("skipping rebalance as single node only");
                 return;
             }
-            balanceByWeights();
+
+            // Balance each partition
+            for (NodeSorter nodeSorter : partitionedNodeSorter.allNodeSorters()) {
+                balanceByWeights(nodeSorter);
+            }
path: server/src/main/java/org/elasticsearch/cluster/routing/allocation/allocator/BalancedShardsAllocator.java
user: login: pxsalehi
body: I wonder if it would be more clear to use the node sorter in a "partition" class/holder so we're going through the partitions, rather than the sorter. It might also be a naming thing.
created_at: 2025-04-17T14:48:13Z
author_association: MEMBER
diff_hunk: @@ -424,7 +409,11 @@ private void balance() {
                 logger.trace("skipping rebalance as single node only");
                 return;
             }
-            balanceByWeights();
+
+            // Balance each partition
+            for (NodeSorter nodeSorter : partitionedNodeSorter.allNodeSorters()) {
+                balanceByWeights(nodeSorter);
+            }
path: server/src/main/java/org/elasticsearch/cluster/routing/allocation/allocator/BalancedShardsAllocator.java
user: login: nicktindall
body: Yeah I know what you mean. I've made it now so `NodeSorters` is an `Iterable<NodeSorter>` the "partition" terminology has faded from the implementation somewhat, but I think it's an important concept.

I added some javadoc to `NodeSorter` in b9403bb447bcaea3db0b294f579c1bc2f2c49c78 to make it clear it's scoped to a partition. I did have the partition terminology more front-and-centre in earlier versions of the PR but it also didn't look quite right.

Also, we don't always need `NodeSorter`s. See in `NodeAllocationStatsAndWeightsCalculator` we use the `BalancingWeightsFactory` to create the `BalancingWeights` but we don't ever call `createNodeSorters`, so refactoring to just return e.g. a `PartitionedCluster` that contained `Partition`s which each hold a `NodeSorter` and a `WeightFunction`, though conceptually cleanest, won't fit the way we use the API. 
created_at: 2025-04-22T03:55:18Z
author_association: CONTRIBUTOR

PR comments:
user: login: elasticsearchmachine
created_at: 2025-04-08T06:10:58Z
body: Hi @nicktindall, I've created a changelog YAML for you.
author_association: COLLABORATOR
user: login: elasticsearchmachine
created_at: 2025-04-08T06:12:58Z
body: Pinging @elastic/es-distributed-coordination (Team:Distributed Coordination)
author_association: COLLABORATOR
user: login: ywangd
created_at: 2025-04-10T05:47:06Z
body: Seeing the amount of changes makes me wonder whether it is worthwhile to reduce the scope to just zeroing out `write_load` for the search tier similar to what the initial PoC (https://github.com/elastic/elasticsearch/pull/125355) does. Do we have use cases for separation of other weights? We had some experiments on shard count weight. But it didn't prove to be very effective. And once `write_load` is dedicated for the indexing tier, increasing it should give us similar effect as decreasing shard count weight. 

Apologies If I am behind the current discussion and please ignore my comments if the current direction has been agreed upon. Thanks!
author_association: MEMBER
user: login: pxsalehi
created_at: 2025-04-10T15:06:04Z
body: I've had a look. While I agree that maybe we don't want to implement something too hacky, I also think there is rather some generality and abstraction here that we don't really need. I think your suggestion for a partitioned cluster abstraction is nice but maybe that's a lot of change for what we need for now, and maybe if we were to do that it would be a rather larger change to be consistent and incorporate how we currency rely on `StatelessAllocationDecider` to make this tiered behaviour happen (AFAIK), i.e. in that partitioned cluster/balancer world, we shouldn't need a plugin-specific decider maybe. 

More concretely, do we need the different node sorters? Can we just focus on the different weight functions? It might be also worth considering a smaller change first that would allow us to move forward with actually using different weights and then decide how to generalize that distinction further within the allocation code base.

author_association: MEMBER
user: login: nicktindall
created_at: 2025-04-11T06:18:26Z
body: > More concretely, do we need the different node sorters? Can we just focus on the different weight functions? It might be also worth considering a smaller change first that would allow us to move forward with actually using different weights and then decide how to generalize that distinction further within the allocation code base.

The hard part to implement is in the `balanceByWeights` method.

In this method, we iterate over the indices, and for each index
- We identify the subset of nodes `N` that can host this index (this does the filtering at the index level, so `StatelessAllocationDecider` is no help here, it doesn't have an opinion at the index level)
- We then calculate the `minWeightDelta`, `W` for that index.
- For `N`, which in serverless will include search and indexing nodes, we iterate over pairs of nodes and check whether the difference in weights exceeds `W * threshold`, if so, we go into `tryRelocateShard` which is the expensive part and looks at moving shard(s) between the nodes to determine if we can improve things.

Because the weight function would be different for search and indexing tiers, calculating a suitable global `minWeightDelta` is problematic. `minWeightDelta` will be lower for search because it has no `writeLoad` component. If we use the search tier threshold we end up considering lots of pairs of nodes in the indexing tier that will not yield any worthwhile movements. If we  instead use the indexing tier threshold we'd skip over pairs of search nodes that have shards that are worthwhile moving.

Sorry if that reads like gibberish it's hard to explain. I'm happy to jump on a call if it helps.

I will continue to investigate if there's an OK solution that doesn't involve full segregation of the nodes, but it's not obvious to me as it stands. 

author_association: CONTRIBUTOR
user: login: nicktindall
created_at: 2025-04-11T08:03:51Z
body: Actually I think I know how to do this. Watch this space.
author_association: CONTRIBUTOR
user: login: pxsalehi
created_at: 2025-04-11T12:41:03Z
body: I understand if it is not trivial to limit the change to just weight function. TBC, I'm also ok with the current approach and don't want to postpone this. Maybe we can just reduce the factory/interface part?
author_association: MEMBER
user: login: nicktindall
created_at: 2025-04-14T01:10:32Z
body: > Actually I think I know how to do this. Watch this space.

I was hoping we might be able to do something more in line with [your original suggestion](https://github.com/elastic/elasticsearch/pull/125824#discussion_r2018290506) @henningandersen 

I thought we might be able to subclass `BalancedShardsAllocator`, overriding `allocate()` to...

1. call `super.allocate()` with `AllocationDeciders` amended with a decider that prevents any movement of indexing shards or allocation to indexing nodes, supplying the search tier weight function
2. call `super.allocate()` with `AllocationDeciders` amended with a decider that prevents any movement of search shards or allocation to search nodes, supplying the indexing tier weight function

But it looks like it might require substantial change to `BalancedShardsAllocator` to accomodate that, because the assumption that we do all allocation in a single pass is littered throughout that class. That change seems riskier to me than adding the explicit partitioning, and also subverts the purpose of `AllocationDecider` I think.

author_association: CONTRIBUTOR