This is an automated email from the ASF dual-hosted git repository.

pjfanning pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/pekko.git


The following commit(s) were added to refs/heads/main by this push:
     new 17fb00e51e consistent-hashing shard allocation strategy (#3270)
17fb00e51e is described below

commit 17fb00e51e847997a31cf6f130545ba3893285b8
Author: PJ Fanning <[email protected]>
AuthorDate: Mon Jul 6 18:42:18 2026 +0100

    consistent-hashing shard allocation strategy (#3270)
    
    * Apply remaining changes
    
    * manual changes
    
    * more
    
    * more
    
    * more
    
    * compile issues
    
    * build issues
    
    * unused imports
    
    * docs: add Colocate Shards and EventsBySliceFirehoseQuery documentation 
(#57)
    
    Motivation:
    The consistent-hashing shard allocation and EventsBySliceFirehoseQuery code
    were ported in this PR, but the corresponding user-facing documentation from
    the upstream commits was not yet included.
    
    Modification:
    - Add the "Colocate Shards" section to typed/cluster-sharding.md (from akka
      522f566128), wired to the existing 
ConsistentHashingShardAllocationCompileOnly
      examples.
    - Add the EventsBySliceFirehoseQuery scalability paragraph to
      persistence-query.md and a `See also [[EventsBySliceFirehoseQuery]]`
      cross-reference to both EventsBySliceQuery DSL traits (from akka 
362ab9301e).
    
    Result:
    The Cluster Sharding and Persistence Query docs describe the new
    consistent-hashing colocation and firehose query features.
    
    Tests: Not run - docs only (sbt "docs/paradox" builds successfully)
    
    References:
    Ports docs from akka/akka-core@522f566128 (akka#31967) and
    akka/akka-core@362ab9301e (akka#31957), which are now Apache licensed.
    Completes documentation for apache/pekko#3270.
    
    * fix: update ConsistentHashingShardAllocationStrategySpec for pekko 
protocol hash (#58)
    
    * fix: update ConsistentHashingShardAllocationStrategySpec for pekko 
protocol
    
    Motivation:
    ConsistentHashingShardAllocationStrategySpec was ported from Akka 2.8.3
    with test expectations based on the akka:// protocol. Since Pekko uses
    pekko:// as the protocol name, Address.toString produces different
    strings, causing ConsistentHash to compute different hash values and
    map shards to different nodes.
    
    Modification:
    Updated all test expectations to match the consistent hash results
    computed with pekko:// addresses. Key changes:
    - 3-node: shard "2" maps to regionA (was regionB), shard "3" maps to 
regionA (was regionC)
    - 4-node: shard "0" maps to regionD (was regionC), shard "2" to regionA 
(was regionD)
    - 2-node: shard "2" maps to regionA (was regionB), shard "3" to regionA 
(was regionB)
    - Rebalance assertions updated to reflect the new 4-node shard distribution
    
    Result:
    All 10 ConsistentHashingShardAllocationStrategySpec tests pass.
    No changes to production code.
    
    Tests:
    sbt "cluster-sharding / Test / testOnly 
org.apache.pekko.cluster.sharding.ConsistentHashingShardAllocationStrategySpec"
    Tests: succeeded 10, failed 0
    
    References:
    Fixes apache/pekko#3270
    
    * fix: update stale comment in ConsistentHashingShardAllocationStrategySpec
    
    In Pekko's consistent hash, shards 2, 3, 10 and 14 are all already at
    the right place (not just 10 and 14 as in Akka).
    
    * scalafmt
    
    * Update consistent-hashing-allocation.excludes
    
    * Update ConsistentHashingShardAllocationCompileOnlyTest.java
    
    ---------
    
    Co-authored-by: copilot-swe-agent[bot] 
<[email protected]>
    Co-authored-by: He-Pin(kerr) <[email protected]>
---
 ...stentHashingShardAllocationCompileOnlyTest.java | 141 +++++++++++
 ...tentHashingShardAllocationCompileOnlySpec.scala |  94 ++++++++
 .../consistent-hashing-allocation.excludes         |  22 ++
 .../ConsistentHashingShardAllocationStrategy.scala | 163 +++++++++++++
 .../pekko/cluster/sharding/ShardCoordinator.scala  |   5 +-
 .../AbstractLeastShardAllocationStrategy.scala     |  87 ++-----
 ...egy.scala => ClusterShardAllocationMixin.scala} |  71 ++----
 .../internal/LeastShardAllocationStrategy.scala    |   4 +-
 ...sistentHashingShardAllocationStrategySpec.scala | 257 +++++++++++++++++++++
 ...eprecatedLeastShardAllocationStrategySpec.scala |  24 +-
 .../LeastShardAllocationStrategySpec.scala         |  23 +-
 docs/src/main/paradox/persistence-query.md         |   5 +
 .../typed/cluster-sharded-daemon-process.md        |  38 ++-
 docs/src/main/paradox/typed/cluster-sharding.md    |  38 +++
 14 files changed, 813 insertions(+), 159 deletions(-)

diff --git 
a/cluster-sharding-typed/src/test/java/jdocs/org/apache/pekko/cluster/sharding/typed/ConsistentHashingShardAllocationCompileOnlyTest.java
 
b/cluster-sharding-typed/src/test/java/jdocs/org/apache/pekko/cluster/sharding/typed/ConsistentHashingShardAllocationCompileOnlyTest.java
new file mode 100644
index 0000000000..3a002220af
--- /dev/null
+++ 
b/cluster-sharding-typed/src/test/java/jdocs/org/apache/pekko/cluster/sharding/typed/ConsistentHashingShardAllocationCompileOnlyTest.java
@@ -0,0 +1,141 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * license agreements; and to You under the Apache License, version 2.0:
+ *
+ *   https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * This file is part of the Apache Pekko project, which was derived from Akka.
+ */
+
+/*
+ * Copyright (C) 2023 Lightbend Inc. <https://www.lightbend.com>
+ */
+
+package jdocs.org.apache.pekko.cluster.sharding.typed;
+
+import org.apache.pekko.actor.typed.ActorSystem;
+import org.apache.pekko.actor.typed.Behavior;
+import org.apache.pekko.actor.typed.javadsl.AbstractBehavior;
+import org.apache.pekko.actor.typed.javadsl.ActorContext;
+import org.apache.pekko.actor.typed.javadsl.Behaviors;
+import org.apache.pekko.actor.typed.javadsl.Receive;
+import 
org.apache.pekko.cluster.sharding.ConsistentHashingShardAllocationStrategy;
+import org.apache.pekko.cluster.sharding.typed.ShardingEnvelope;
+import org.apache.pekko.cluster.sharding.typed.ShardingMessageExtractor;
+import org.apache.pekko.cluster.sharding.typed.javadsl.ClusterSharding;
+import org.apache.pekko.cluster.sharding.typed.javadsl.Entity;
+import org.apache.pekko.cluster.sharding.typed.javadsl.EntityTypeKey;
+
+public class ConsistentHashingShardAllocationCompileOnlyTest {
+
+  // #building
+  public static class Building extends AbstractBehavior<Building.Command> {
+
+    static int NUMBER_OF_SHARDS = 100;
+
+    static final class MessageExtractor
+        extends ShardingMessageExtractor<ShardingEnvelope<Building.Command>, 
Building.Command> {
+      @Override
+      public String entityId(ShardingEnvelope<Command> envelope) {
+        return envelope.entityId();
+      }
+
+      @Override
+      public String shardId(String entityId) {
+        return String.valueOf(Math.abs(entityId.hashCode() % 
NUMBER_OF_SHARDS));
+      }
+
+      @Override
+      public Command unwrapMessage(ShardingEnvelope<Command> envelope) {
+        return envelope.message();
+      }
+    }
+
+    static final EntityTypeKey<Building.Command> typeKey =
+        EntityTypeKey.create(Building.Command.class, "Building");
+
+    public interface Command {}
+
+    public static Behavior<Building.Command> create(String entityId) {
+      return Behaviors.setup(context -> new Building(context, entityId));
+    }
+
+    private Building(ActorContext<Building.Command> context, String entityId) {
+      super(context);
+    }
+
+    @Override
+    public Receive<Building.Command> createReceive() {
+      return newReceiveBuilder().build();
+    }
+  }
+
+  // #building
+
+  // #device
+  public static class Device extends AbstractBehavior<Device.Command> {
+
+    static final class MessageExtractor
+        extends ShardingMessageExtractor<ShardingEnvelope<Device.Command>, 
Device.Command> {
+      @Override
+      public String entityId(ShardingEnvelope<Command> envelope) {
+        return envelope.entityId();
+      }
+
+      @Override
+      public String shardId(String entityId) {
+        // Use same shardId as the Building to colocate Building and Device
+        // we have the buildingId as prefix in the entityId
+        String buildingId = entityId.split(":")[0];
+        return String.valueOf(Math.abs(buildingId.hashCode() % 
Building.NUMBER_OF_SHARDS));
+      }
+
+      @Override
+      public Command unwrapMessage(ShardingEnvelope<Command> envelope) {
+        return envelope.message();
+      }
+    }
+
+    static final EntityTypeKey<Device.Command> typeKey =
+        EntityTypeKey.create(Device.Command.class, "Device");
+
+    public interface Command {}
+
+    public static Behavior<Device.Command> create(String entityId) {
+      return Behaviors.setup(context -> new Device(context, entityId));
+    }
+
+    private Device(ActorContext<Device.Command> context, String entityId) {
+      super(context);
+    }
+
+    @Override
+    public Receive<Device.Command> createReceive() {
+      return newReceiveBuilder().build();
+    }
+  }
+
+  // #device
+
+  void example() {
+    ActorSystem<?> system = null;
+
+    // #init
+    int rebalanceLimit = 10;
+
+    ClusterSharding.get(system)
+        .init(
+            Entity.of(Building.typeKey, ctx -> 
Building.create(ctx.getEntityId()))
+                .withMessageExtractor(new Building.MessageExtractor())
+                .withAllocationStrategy(
+                    new 
ConsistentHashingShardAllocationStrategy(rebalanceLimit)));
+
+    ClusterSharding.get(system)
+        .init(
+            Entity.of(Device.typeKey, ctx -> Device.create(ctx.getEntityId()))
+                .withMessageExtractor(new Device.MessageExtractor())
+                .withAllocationStrategy(
+                    new 
ConsistentHashingShardAllocationStrategy(rebalanceLimit)));
+    // #init
+  }
+}
diff --git 
a/cluster-sharding-typed/src/test/scala/docs/org/apache/pekko/cluster/sharding/typed/ConsistentHashingShardAllocationCompileOnlySpec.scala
 
b/cluster-sharding-typed/src/test/scala/docs/org/apache/pekko/cluster/sharding/typed/ConsistentHashingShardAllocationCompileOnlySpec.scala
new file mode 100644
index 0000000000..ea92ce517b
--- /dev/null
+++ 
b/cluster-sharding-typed/src/test/scala/docs/org/apache/pekko/cluster/sharding/typed/ConsistentHashingShardAllocationCompileOnlySpec.scala
@@ -0,0 +1,94 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * license agreements; and to You under the Apache License, version 2.0:
+ *
+ *   https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * This file is part of the Apache Pekko project, which was derived from Akka.
+ */
+
+/*
+ * Copyright (C) 2023 Lightbend Inc. <https://www.lightbend.com>
+ */
+
+package docs.org.apache.pekko.cluster.sharding.typed
+
+import org.apache.pekko
+import pekko.actor.typed.ActorSystem
+import pekko.actor.typed.Behavior
+import pekko.cluster.sharding.ConsistentHashingShardAllocationStrategy
+import pekko.cluster.sharding.typed.ShardingEnvelope
+import pekko.cluster.sharding.typed.ShardingMessageExtractor
+import pekko.cluster.sharding.typed.scaladsl.ClusterSharding
+import pekko.cluster.sharding.typed.scaladsl.Entity
+import pekko.cluster.sharding.typed.scaladsl.EntityTypeKey
+
+class ConsistentHashingShardAllocationCompileOnlySpec {
+
+  // #building
+  object Building {
+    val TypeKey = EntityTypeKey[Command]("Building")
+
+    val NumberOfShards = 100
+
+    final class MessageExtractor extends 
ShardingMessageExtractor[ShardingEnvelope[Command], Command] {
+
+      override def entityId(envelope: ShardingEnvelope[Command]): String =
+        envelope.entityId
+
+      override def shardId(entityId: String): String =
+        math.abs(entityId.hashCode % NumberOfShards).toString
+
+      override def unwrapMessage(envelope: ShardingEnvelope[Command]): Command 
=
+        envelope.message
+    }
+
+    sealed trait Command
+
+    def apply(entityId: String): Behavior[Command] = ???
+  }
+
+  // #building
+
+  // #device
+  object Device {
+    val TypeKey = EntityTypeKey[Command]("Device")
+
+    final class MessageExtractor extends 
ShardingMessageExtractor[ShardingEnvelope[Command], Command] {
+
+      override def entityId(envelope: ShardingEnvelope[Command]): String =
+        envelope.entityId
+
+      override def shardId(entityId: String): String = {
+        // Use same shardId as the Building to colocate Building and Device
+        // we have the buildingId as prefix in the entityId
+        val buildingId = entityId.split(':').head
+        math.abs(buildingId.hashCode % Building.NumberOfShards).toString
+      }
+
+      override def unwrapMessage(envelope: ShardingEnvelope[Command]): Command 
=
+        envelope.message
+    }
+
+    sealed trait Command
+
+    def apply(entityId: String): Behavior[Command] = ???
+  }
+
+  // #device
+
+  val system: ActorSystem[?] = ???
+
+  // #init
+  ClusterSharding(system).init(
+    Entity(Building.TypeKey)(createBehavior = entityContext => 
Building(entityContext.entityId))
+      .withMessageExtractor(new Building.MessageExtractor)
+      .withAllocationStrategy(new 
ConsistentHashingShardAllocationStrategy(rebalanceLimit = 10)))
+
+  ClusterSharding(system).init(
+    Entity(Device.TypeKey)(createBehavior = entityContext => 
Device(entityContext.entityId))
+      .withMessageExtractor(new Device.MessageExtractor)
+      .withAllocationStrategy(new 
ConsistentHashingShardAllocationStrategy(rebalanceLimit = 10)))
+  // #init
+
+}
diff --git 
a/cluster-sharding/src/main/mima-filters/2.0.x.backwards.excludes/consistent-hashing-allocation.excludes
 
b/cluster-sharding/src/main/mima-filters/2.0.x.backwards.excludes/consistent-hashing-allocation.excludes
new file mode 100644
index 0000000000..f5326a3be4
--- /dev/null
+++ 
b/cluster-sharding/src/main/mima-filters/2.0.x.backwards.excludes/consistent-hashing-allocation.excludes
@@ -0,0 +1,22 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+# Internal classes affected by refactor related to Colocating shards with 
consistent hashing
+ProblemFilters.exclude[MissingClassProblem]("org.apache.pekko.cluster.sharding.internal.AbstractLeastShardAllocationStrategy$")
+ProblemFilters.exclude[MissingClassProblem]("org.apache.pekko.cluster.sharding.internal.AbstractLeastShardAllocationStrategy$RegionEntry")
+ProblemFilters.exclude[MissingClassProblem]("org.apache.pekko.cluster.sharding.internal.AbstractLeastShardAllocationStrategy$RegionEntry$")
+ProblemFilters.exclude[MissingClassProblem]("org.apache.pekko.cluster.sharding.internal.AbstractLeastShardAllocationStrategy$ShardSuitabilityOrdering$")
diff --git 
a/cluster-sharding/src/main/scala/org/apache/pekko/cluster/sharding/ConsistentHashingShardAllocationStrategy.scala
 
b/cluster-sharding/src/main/scala/org/apache/pekko/cluster/sharding/ConsistentHashingShardAllocationStrategy.scala
new file mode 100644
index 0000000000..0778c519db
--- /dev/null
+++ 
b/cluster-sharding/src/main/scala/org/apache/pekko/cluster/sharding/ConsistentHashingShardAllocationStrategy.scala
@@ -0,0 +1,163 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * license agreements; and to You under the Apache License, version 2.0:
+ *
+ *   https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * This file is part of the Apache Pekko project, which was derived from Akka.
+ */
+
+/*
+ * Copyright (C) 2023 Lightbend Inc. <https://www.lightbend.com>
+ */
+
+package org.apache.pekko.cluster.sharding
+
+import scala.annotation.nowarn
+import scala.collection.immutable
+import scala.concurrent.Future
+
+import org.apache.pekko
+import pekko.actor.ActorRef
+import pekko.actor.ActorSystem
+import pekko.actor.Address
+import pekko.annotation.DoNotInherit
+import pekko.cluster.Cluster
+import pekko.cluster.ClusterEvent.CurrentClusterState
+import pekko.cluster.Member
+import 
pekko.cluster.sharding.ShardCoordinator.ActorSystemDependentAllocationStrategy
+import pekko.cluster.sharding.ShardRegion.ShardId
+import pekko.cluster.sharding.internal.ClusterShardAllocationMixin
+import 
pekko.cluster.sharding.internal.ClusterShardAllocationMixin.ShardSuitabilityOrdering
+import pekko.event.Logging
+import pekko.event.LoggingAdapter
+import pekko.routing.ConsistentHash
+
+object ConsistentHashingShardAllocationStrategy {
+  private val emptyRebalanceResult = Future.successful(Set.empty[ShardId])
+}
+
+/**
+ * [[pekko.cluster.sharding.ShardCoordinator.ShardAllocationStrategy]] that is 
using consistent
+ * hashing. This can be useful when shards with the same shard id for 
different entity types
+ * should be best effort colocated to the same nodes.
+ *
+ * When adding or removing nodes it will rebalance according to the new 
consistent hashing,
+ * but that means that only a few shards will be rebalanced and others remain 
on the same
+ * location.
+ *
+ * A good explanation of Consistent Hashing:
+ * https://tom-e-white.com/2007/11/consistent-hashing.html
+ *
+ * Create a new instance of this for each entity types, i.e. a 
`ConsistentHashingShardAllocationStrategy`
+ * instance must not be shared between different entity types.
+ *
+ * Not intended for public inheritance/implementation
+ */
+@DoNotInherit
+class ConsistentHashingShardAllocationStrategy(rebalanceLimit: Int)
+    extends ActorSystemDependentAllocationStrategy
+    with ClusterShardAllocationMixin {
+  import ConsistentHashingShardAllocationStrategy.emptyRebalanceResult
+
+  private var cluster: Cluster = _
+  private var _log: LoggingAdapter = _
+
+  private val virtualNodesFactor = 10
+  private var hashedByNodes: Vector[Address] = Vector.empty
+  private var consistentHashing: ConsistentHash[Address] = ConsistentHash(Nil, 
virtualNodesFactor)
+
+  override def start(system: ActorSystem): Unit = {
+    cluster = Cluster(system)
+    _log = Logging(system, classOf[ConsistentHashingShardAllocationStrategy])
+  }
+
+  // can be overridden for tests without real Cluster, i.e. without 
`start(system)`
+  protected def log: LoggingAdapter =
+    _log
+
+  override protected def clusterState: CurrentClusterState = cluster.state
+  override protected def selfMember: Member = cluster.selfMember
+
+  @nowarn("msg=never used")
+  override def allocateShard(
+      requester: ActorRef,
+      shardId: ShardId,
+      currentShardAllocations: Map[ActorRef, immutable.IndexedSeq[ShardId]]): 
Future[ActorRef] = {
+    val nodes = nodesForRegions(currentShardAllocations)
+    updateHashing(nodes)
+    val node = consistentHashing.nodeFor(shardId)
+    currentShardAllocations.keysIterator.find(region => nodeForRegion(region) 
== node) match {
+      case Some(region) => Future.successful(region)
+      case None         =>
+        throw new IllegalStateException(s"currentShardAllocations should 
include region for node [$node]")
+    }
+  }
+
+  override def rebalance(
+      currentShardAllocations: Map[ActorRef, immutable.IndexedSeq[ShardId]],
+      rebalanceInProgress: Set[ShardId]): Future[Set[ShardId]] = {
+
+    val sortedRegionEntries = 
regionEntriesFor(currentShardAllocations).toVector.sorted(ShardSuitabilityOrdering)
+    if (!isAGoodTimeToRebalance(sortedRegionEntries)) {
+      emptyRebalanceResult
+    } else {
+
+      val nodes = nodesForRegions(currentShardAllocations)
+      updateHashing(nodes)
+
+      val regionByNode = currentShardAllocations.keysIterator.map(region => 
nodeForRegion(region) -> region).toMap
+
+      var result = Set.empty[String]
+
+      def lessThanLimit: Boolean =
+        rebalanceLimit <= 0 || result.size < rebalanceLimit
+
+      currentShardAllocations
+        // deterministic order, at least easier to test
+        .toVector.sortBy { case (region, _) => nodeForRegion(region) 
}(Address.addressOrdering).foreach {
+          case (currentRegion, shardIds) =>
+            shardIds.foreach { shardId =>
+              if (lessThanLimit && !rebalanceInProgress.contains(shardId)) {
+                val node = consistentHashing.nodeFor(shardId)
+                regionByNode.get(node) match {
+                  case Some(region) =>
+                    if (region != currentRegion) {
+                      log.debug(
+                        "Rebalance needed for shard [{}], from [{}] to [{}]",
+                        shardId,
+                        nodeForRegion(currentRegion),
+                        node)
+                      result += shardId
+                    }
+                  case None =>
+                    throw new IllegalStateException(s"currentShardAllocations 
should include region for node [$node]")
+                }
+              }
+            }
+        }
+
+      Future.successful(result)
+    }
+  }
+
+  private def nodesForRegions(
+      currentShardAllocations: Map[ActorRef, immutable.IndexedSeq[ShardId]]): 
Vector[Address] = {
+    currentShardAllocations.keysIterator.map(nodeForRegion).toVector
+  }
+
+  private def nodeForRegion(region: ActorRef): Address =
+    if (region.path.address.hasLocalScope) selfMember.address
+    else region.path.address
+
+  private def updateHashing(nodes: Vector[Address]): Unit = {
+    val sortedNodes = nodes.sorted
+    if (sortedNodes != hashedByNodes) {
+      if (log.isDebugEnabled)
+        log.debug("Update consistent hashing nodes [{}]", 
sortedNodes.mkString(", "))
+      hashedByNodes = sortedNodes
+      consistentHashing = ConsistentHash(sortedNodes, virtualNodesFactor)
+    }
+  }
+
+}
diff --git 
a/cluster-sharding/src/main/scala/org/apache/pekko/cluster/sharding/ShardCoordinator.scala
 
b/cluster-sharding/src/main/scala/org/apache/pekko/cluster/sharding/ShardCoordinator.scala
index b353bd7ede..1d9baa46dc 100644
--- 
a/cluster-sharding/src/main/scala/org/apache/pekko/cluster/sharding/ShardCoordinator.scala
+++ 
b/cluster-sharding/src/main/scala/org/apache/pekko/cluster/sharding/ShardCoordinator.scala
@@ -39,7 +39,8 @@ import pekko.cluster.sharding.internal.{
   RememberEntitiesProvider
 }
 import pekko.cluster.sharding.internal.AbstractLeastShardAllocationStrategy
-import 
pekko.cluster.sharding.internal.AbstractLeastShardAllocationStrategy.RegionEntry
+import pekko.cluster.sharding.internal.ClusterShardAllocationMixin.RegionEntry
+import 
pekko.cluster.sharding.internal.ClusterShardAllocationMixin.ShardSuitabilityOrdering
 import 
pekko.cluster.sharding.internal.EventSourcedRememberEntitiesCoordinatorStore.MigrationMarker
 import pekko.event.{ BusLogging, Logging }
 import pekko.pattern.{ pipe, AskTimeoutException }
@@ -296,8 +297,6 @@ object ShardCoordinator {
       extends AbstractLeastShardAllocationStrategy
       with Serializable {
 
-    import AbstractLeastShardAllocationStrategy.ShardSuitabilityOrdering
-
     override def rebalance(
         currentShardAllocations: Map[ActorRef, immutable.IndexedSeq[ShardId]],
         rebalanceInProgress: Set[ShardId]): Future[Set[ShardId]] = {
diff --git 
a/cluster-sharding/src/main/scala/org/apache/pekko/cluster/sharding/internal/AbstractLeastShardAllocationStrategy.scala
 
b/cluster-sharding/src/main/scala/org/apache/pekko/cluster/sharding/internal/AbstractLeastShardAllocationStrategy.scala
index 433bbbd49a..1826e83f5b 100644
--- 
a/cluster-sharding/src/main/scala/org/apache/pekko/cluster/sharding/internal/AbstractLeastShardAllocationStrategy.scala
+++ 
b/cluster-sharding/src/main/scala/org/apache/pekko/cluster/sharding/internal/AbstractLeastShardAllocationStrategy.scala
@@ -13,8 +13,6 @@
 
 package org.apache.pekko.cluster.sharding.internal
 
-import java.lang.{ Boolean => JBoolean, Integer => JInteger }
-
 import scala.collection.immutable
 import scala.concurrent.Future
 import scala.concurrent.duration.DurationInt
@@ -22,57 +20,35 @@ import scala.concurrent.duration.DurationInt
 import org.apache.pekko
 import pekko.actor.ActorRef
 import pekko.actor.ActorSystem
-import pekko.actor.Address
 import pekko.annotation.InternalApi
 import pekko.cluster.Cluster
 import pekko.cluster.ClusterEvent.CurrentClusterState
 import pekko.cluster.Member
-import pekko.cluster.MemberStatus
 import 
pekko.cluster.sharding.ShardCoordinator.ActorSystemDependentAllocationStrategy
 import pekko.cluster.sharding.ShardRegion.ShardId
+import pekko.cluster.sharding.internal.ClusterShardAllocationMixin.RegionEntry
+import 
pekko.cluster.sharding.internal.ClusterShardAllocationMixin.ShardSuitabilityOrdering
 import pekko.pattern.after
 
 /**
- * Common logic for the least shard allocation strategy implementations
- *
  * INTERNAL API
  */
 @InternalApi
 private[pekko] object AbstractLeastShardAllocationStrategy {
-  import MemberStatus._
-  private val JoiningCluster: Set[MemberStatus] = Set(Joining, WeaklyUp)
-  private val LeavingClusterStatuses: Set[MemberStatus] = Set(Leaving, 
Exiting, Down)
-
-  type AllocationMap = Map[ActorRef, immutable.IndexedSeq[ShardId]]
-
-  final case class RegionEntry(region: ActorRef, member: Member, shardIds: 
immutable.IndexedSeq[ShardId])
-
-  implicit object ShardSuitabilityOrdering extends Ordering[RegionEntry] {
-    override def compare(x: RegionEntry, y: RegionEntry): Int = {
-      val RegionEntry(_, memberX, allocatedShardsX) = x
-      val RegionEntry(_, memberY, allocatedShardsY) = y
-      if (memberX.status != memberY.status) {
-        // prefer allocating to nodes that are not on their way out of the 
cluster
-        val xIsLeaving = LeavingClusterStatuses(memberX.status)
-        val yIsLeaving = LeavingClusterStatuses(memberY.status)
-        JBoolean.compare(xIsLeaving, yIsLeaving)
-      } else if (memberX.appVersion != memberY.appVersion) {
-        // prefer nodes with the highest rolling update app version
-        memberY.appVersion.compareTo(memberX.appVersion)
-      } else {
-        // prefer the node with the least allocated shards
-        JInteger.compare(allocatedShardsX.size, allocatedShardsY.size)
-      }
-    }
-  }
+  // kept for binary compatibility - delegate to ClusterShardAllocationMixin
+  type AllocationMap = ClusterShardAllocationMixin.AllocationMap
+  val ShardSuitabilityOrdering: Ordering[RegionEntry] = 
ClusterShardAllocationMixin.ShardSuitabilityOrdering
+  type RegionEntry = ClusterShardAllocationMixin.RegionEntry
+  val RegionEntry: ClusterShardAllocationMixin.RegionEntry.type = 
ClusterShardAllocationMixin.RegionEntry
 }
 
 /**
- * INTERNAL API
+ * INTERNAL API: Common logic for the least shard allocation strategy 
implementations
  */
 @InternalApi
-private[pekko] abstract class AbstractLeastShardAllocationStrategy extends 
ActorSystemDependentAllocationStrategy {
-  import AbstractLeastShardAllocationStrategy._
+private[pekko] abstract class AbstractLeastShardAllocationStrategy
+    extends ActorSystemDependentAllocationStrategy
+    with ClusterShardAllocationMixin {
 
   @volatile private var system: ActorSystem = _
   @volatile private var cluster: Cluster = _
@@ -83,8 +59,8 @@ private[pekko] abstract class 
AbstractLeastShardAllocationStrategy extends Actor
   }
 
   // protected for testability
-  protected def clusterState: CurrentClusterState = cluster.state
-  protected def selfMember: Member = cluster.selfMember
+  override protected def clusterState: CurrentClusterState = cluster.state
+  override protected def selfMember: Member = cluster.selfMember
 
   override def allocateShard(
       requester: ActorRef,
@@ -101,47 +77,10 @@ private[pekko] abstract class 
AbstractLeastShardAllocationStrategy extends Actor
     }
   }
 
-  final protected def isAGoodTimeToRebalance(regionEntries: 
Iterable[RegionEntry]): Boolean = {
-    // Avoid rebalance when rolling update is in progress
-    // (This will ignore versions on members with no shard regions, because of 
sharding role or not yet completed joining)
-    regionEntries.headOption match {
-      case None              => false // empty list of regions, probably not a 
good time to rebalance...
-      case Some(firstRegion) =>
-        def allNodesSameVersion = {
-          regionEntries.forall(_.member.appVersion == 
firstRegion.member.appVersion)
-        }
-        // Rebalance requires ack from regions and proxies - no need to 
rebalance if it cannot be completed
-        // FIXME #29589, we currently only look at same dc but proxies in 
other dcs may delay complete as well right now
-        def neededMembersReachable =
-          !clusterState.members.exists(m => m.dataCenter == 
selfMember.dataCenter && clusterState.unreachable(m))
-        // No members in same dc joining, we want that to complete before 
rebalance, such nodes should reach Up soon
-        def membersInProgressOfJoining =
-          clusterState.members.exists(m => m.dataCenter == 
selfMember.dataCenter && JoiningCluster(m.status))
-
-        allNodesSameVersion && neededMembersReachable && 
!membersInProgressOfJoining
-    }
-  }
-
   final protected def mostSuitableRegion(
       regionEntries: Iterable[RegionEntry]): (ActorRef, 
immutable.IndexedSeq[ShardId]) = {
     val mostSuitableEntry = regionEntries.min(ShardSuitabilityOrdering)
     mostSuitableEntry.region -> mostSuitableEntry.shardIds
   }
 
-  final protected def regionEntriesFor(currentShardAllocations: 
AllocationMap): Iterable[RegionEntry] = {
-    val addressToMember: Map[Address, Member] = 
clusterState.members.iterator.map(m => m.address -> m).toMap
-    currentShardAllocations.flatMap {
-      case (region, shardIds) =>
-        val regionAddress = {
-          if (region.path.address.hasLocalScope) selfMember.address
-          else region.path.address
-        }
-
-        val memberForRegion = addressToMember.get(regionAddress)
-        // if the member is unknown (very unlikely but not impossible) because 
of view not updated yet
-        // that node is ignored for this invocation
-        memberForRegion.map(member => RegionEntry(region, member, shardIds))
-    }
-  }
-
 }
diff --git 
a/cluster-sharding/src/main/scala/org/apache/pekko/cluster/sharding/internal/AbstractLeastShardAllocationStrategy.scala
 
b/cluster-sharding/src/main/scala/org/apache/pekko/cluster/sharding/internal/ClusterShardAllocationMixin.scala
similarity index 63%
copy from 
cluster-sharding/src/main/scala/org/apache/pekko/cluster/sharding/internal/AbstractLeastShardAllocationStrategy.scala
copy to 
cluster-sharding/src/main/scala/org/apache/pekko/cluster/sharding/internal/ClusterShardAllocationMixin.scala
index 433bbbd49a..ec23b34ce9 100644
--- 
a/cluster-sharding/src/main/scala/org/apache/pekko/cluster/sharding/internal/AbstractLeastShardAllocationStrategy.scala
+++ 
b/cluster-sharding/src/main/scala/org/apache/pekko/cluster/sharding/internal/ClusterShardAllocationMixin.scala
@@ -8,43 +8,39 @@
  */
 
 /*
- * Copyright (C) 2020-2022 Lightbend Inc. <https://www.lightbend.com>
+ * Copyright (C) 2020-2023 Lightbend Inc. <https://www.lightbend.com>
  */
 
 package org.apache.pekko.cluster.sharding.internal
 
-import java.lang.{ Boolean => JBoolean, Integer => JInteger }
+import java.lang.{ Boolean => JBoolean }
+import java.lang.{ Integer => JInteger }
 
 import scala.collection.immutable
-import scala.concurrent.Future
-import scala.concurrent.duration.DurationInt
 
 import org.apache.pekko
 import pekko.actor.ActorRef
-import pekko.actor.ActorSystem
 import pekko.actor.Address
 import pekko.annotation.InternalApi
-import pekko.cluster.Cluster
 import pekko.cluster.ClusterEvent.CurrentClusterState
 import pekko.cluster.Member
 import pekko.cluster.MemberStatus
-import 
pekko.cluster.sharding.ShardCoordinator.ActorSystemDependentAllocationStrategy
 import pekko.cluster.sharding.ShardRegion.ShardId
-import pekko.pattern.after
+import 
pekko.cluster.sharding.internal.ClusterShardAllocationMixin.JoiningCluster
+import pekko.cluster.sharding.internal.ClusterShardAllocationMixin.RegionEntry
 
 /**
- * Common logic for the least shard allocation strategy implementations
- *
  * INTERNAL API
  */
-@InternalApi
-private[pekko] object AbstractLeastShardAllocationStrategy {
-  import MemberStatus._
-  private val JoiningCluster: Set[MemberStatus] = Set(Joining, WeaklyUp)
-  private val LeavingClusterStatuses: Set[MemberStatus] = Set(Leaving, 
Exiting, Down)
+@InternalApi private[pekko] object ClusterShardAllocationMixin {
 
   type AllocationMap = Map[ActorRef, immutable.IndexedSeq[ShardId]]
 
+  import MemberStatus._
+
+  val JoiningCluster: Set[MemberStatus] = Set(Joining, WeaklyUp)
+  val LeavingClusterStatuses: Set[MemberStatus] = Set(Leaving, Exiting, Down)
+
   final case class RegionEntry(region: ActorRef, member: Member, shardIds: 
immutable.IndexedSeq[ShardId])
 
   implicit object ShardSuitabilityOrdering extends Ordering[RegionEntry] {
@@ -65,41 +61,18 @@ private[pekko] object AbstractLeastShardAllocationStrategy {
       }
     }
   }
+
 }
 
 /**
  * INTERNAL API
  */
-@InternalApi
-private[pekko] abstract class AbstractLeastShardAllocationStrategy extends 
ActorSystemDependentAllocationStrategy {
-  import AbstractLeastShardAllocationStrategy._
+@InternalApi private[pekko] trait ClusterShardAllocationMixin {
+  import ClusterShardAllocationMixin.AllocationMap
 
-  @volatile private var system: ActorSystem = _
-  @volatile private var cluster: Cluster = _
-
-  override def start(system: ActorSystem): Unit = {
-    this.system = system
-    cluster = Cluster(system)
-  }
+  protected def clusterState: CurrentClusterState
 
-  // protected for testability
-  protected def clusterState: CurrentClusterState = cluster.state
-  protected def selfMember: Member = cluster.selfMember
-
-  override def allocateShard(
-      requester: ActorRef,
-      shardId: ShardId,
-      currentShardAllocations: Map[ActorRef, immutable.IndexedSeq[ShardId]]): 
Future[ActorRef] = {
-    val regionEntries = regionEntriesFor(currentShardAllocations)
-    if (regionEntries.isEmpty) {
-      // very unlikely to ever happen but possible because of cluster state 
view not yet updated when collecting
-      // region entries, view should be updated after a very short time
-      after(50.millis)(allocateShard(requester, shardId, 
currentShardAllocations))(system)
-    } else {
-      val (region, _) = mostSuitableRegion(regionEntries)
-      Future.successful(region)
-    }
-  }
+  protected def selfMember: Member
 
   final protected def isAGoodTimeToRebalance(regionEntries: 
Iterable[RegionEntry]): Boolean = {
     // Avoid rebalance when rolling update is in progress
@@ -107,13 +80,14 @@ private[pekko] abstract class 
AbstractLeastShardAllocationStrategy extends Actor
     regionEntries.headOption match {
       case None              => false // empty list of regions, probably not a 
good time to rebalance...
       case Some(firstRegion) =>
-        def allNodesSameVersion = {
+        def allNodesSameVersion =
           regionEntries.forall(_.member.appVersion == 
firstRegion.member.appVersion)
-        }
+
         // Rebalance requires ack from regions and proxies - no need to 
rebalance if it cannot be completed
         // FIXME #29589, we currently only look at same dc but proxies in 
other dcs may delay complete as well right now
         def neededMembersReachable =
           !clusterState.members.exists(m => m.dataCenter == 
selfMember.dataCenter && clusterState.unreachable(m))
+
         // No members in same dc joining, we want that to complete before 
rebalance, such nodes should reach Up soon
         def membersInProgressOfJoining =
           clusterState.members.exists(m => m.dataCenter == 
selfMember.dataCenter && JoiningCluster(m.status))
@@ -122,12 +96,6 @@ private[pekko] abstract class 
AbstractLeastShardAllocationStrategy extends Actor
     }
   }
 
-  final protected def mostSuitableRegion(
-      regionEntries: Iterable[RegionEntry]): (ActorRef, 
immutable.IndexedSeq[ShardId]) = {
-    val mostSuitableEntry = regionEntries.min(ShardSuitabilityOrdering)
-    mostSuitableEntry.region -> mostSuitableEntry.shardIds
-  }
-
   final protected def regionEntriesFor(currentShardAllocations: 
AllocationMap): Iterable[RegionEntry] = {
     val addressToMember: Map[Address, Member] = 
clusterState.members.iterator.map(m => m.address -> m).toMap
     currentShardAllocations.flatMap {
@@ -143,5 +111,4 @@ private[pekko] abstract class 
AbstractLeastShardAllocationStrategy extends Actor
         memberForRegion.map(member => RegionEntry(region, member, shardIds))
     }
   }
-
 }
diff --git 
a/cluster-sharding/src/main/scala/org/apache/pekko/cluster/sharding/internal/LeastShardAllocationStrategy.scala
 
b/cluster-sharding/src/main/scala/org/apache/pekko/cluster/sharding/internal/LeastShardAllocationStrategy.scala
index 86be1f4481..61d7ba5dd0 100644
--- 
a/cluster-sharding/src/main/scala/org/apache/pekko/cluster/sharding/internal/LeastShardAllocationStrategy.scala
+++ 
b/cluster-sharding/src/main/scala/org/apache/pekko/cluster/sharding/internal/LeastShardAllocationStrategy.scala
@@ -20,8 +20,8 @@ import org.apache.pekko
 import pekko.actor.ActorRef
 import pekko.annotation.InternalApi
 import pekko.cluster.sharding.ShardRegion.ShardId
-import 
pekko.cluster.sharding.internal.AbstractLeastShardAllocationStrategy.RegionEntry
-import 
pekko.cluster.sharding.internal.AbstractLeastShardAllocationStrategy.ShardSuitabilityOrdering
+import pekko.cluster.sharding.internal.ClusterShardAllocationMixin.RegionEntry
+import 
pekko.cluster.sharding.internal.ClusterShardAllocationMixin.ShardSuitabilityOrdering
 
 /**
  * INTERNAL API
diff --git 
a/cluster-sharding/src/test/scala/org/apache/pekko/cluster/sharding/ConsistentHashingShardAllocationStrategySpec.scala
 
b/cluster-sharding/src/test/scala/org/apache/pekko/cluster/sharding/ConsistentHashingShardAllocationStrategySpec.scala
new file mode 100644
index 0000000000..d65a35dda2
--- /dev/null
+++ 
b/cluster-sharding/src/test/scala/org/apache/pekko/cluster/sharding/ConsistentHashingShardAllocationStrategySpec.scala
@@ -0,0 +1,257 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * license agreements; and to You under the Apache License, version 2.0:
+ *
+ *   https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * This file is part of the Apache Pekko project, which was derived from Akka.
+ */
+
+/*
+ * Copyright (C) 2023 Lightbend Inc. <https://www.lightbend.com>
+ */
+
+package org.apache.pekko.cluster.sharding
+
+import scala.collection.immutable.SortedSet
+
+import org.apache.pekko
+import pekko.actor.ActorPath
+import pekko.actor.ActorRef
+import pekko.actor.ActorRefProvider
+import pekko.actor.Address
+import pekko.actor.MinimalActorRef
+import pekko.actor.RootActorPath
+import pekko.cluster.ClusterEvent
+import pekko.cluster.ClusterEvent.CurrentClusterState
+import pekko.cluster.ClusterSettings
+import pekko.cluster.Member
+import pekko.cluster.MemberStatus
+import pekko.cluster.UniqueAddress
+import pekko.event.Logging
+import pekko.event.LoggingAdapter
+import pekko.testkit.PekkoSpec
+import pekko.util.Version
+
+object ConsistentHashingShardAllocationStrategySpec {
+
+  final class DummyActorRef(val path: ActorPath) extends MinimalActorRef {
+    override def provider: ActorRefProvider = ???
+  }
+
+  def newUpMember(host: String, port: Int = 252525, version: Version = 
Version("1.0.0")) =
+    Member(
+      UniqueAddress(Address("pekko", "myapp", host, port), 1L),
+      Set(ClusterSettings.DcRolePrefix + ClusterSettings.DefaultDataCenter),
+      version).copy(MemberStatus.Up)
+
+  def newFakeRegion(idForDebug: String, member: Member): ActorRef =
+    new DummyActorRef(RootActorPath(member.address) / "system" / "fake" / 
idForDebug)
+}
+
+class ConsistentHashingShardAllocationStrategySpec extends PekkoSpec {
+  import ConsistentHashingShardAllocationStrategySpec._
+
+  val memberA = newUpMember("127.0.0.1")
+  val memberB = newUpMember("127.0.0.2")
+  val memberC = newUpMember("127.0.0.3")
+  val memberD = newUpMember("127.0.0.4")
+
+  val regionA = newFakeRegion("regionA", memberA)
+  val regionB = newFakeRegion("regionB", memberB)
+  val regionC = newFakeRegion("regionC", memberC)
+  val regionD = newFakeRegion("regionD", memberD)
+
+  private val emptyAllocationsABC: Map[ActorRef, Vector[String]] =
+    Map(regionA -> Vector.empty, regionB -> Vector.empty, regionC -> 
Vector.empty)
+
+  private def strategy(rebalanceLimit: Int = 0) =
+    // we don't really "start" it as we fake the cluster access
+    new ConsistentHashingShardAllocationStrategy(rebalanceLimit) {
+      override protected def clusterState: ClusterEvent.CurrentClusterState =
+        CurrentClusterState(SortedSet(memberA, memberB, memberC))
+      override protected def selfMember: Member = memberA
+      override protected val log: LoggingAdapter =
+        Logging(system, classOf[ConsistentHashingShardAllocationStrategy])
+    }
+
+  "ConsistentHashingShardAllocationStrategy" must {
+    "allocate to regions" in {
+      val allocationStrategy = strategy()
+      val allocations = emptyAllocationsABC
+      allocationStrategy.allocateShard(regionA, "0", allocations).futureValue 
should ===(regionC)
+      allocationStrategy.allocateShard(regionA, "1", allocations).futureValue 
should ===(regionB)
+      allocationStrategy.allocateShard(regionA, "2", allocations).futureValue 
should ===(regionA)
+      allocationStrategy.allocateShard(regionA, "10", allocations).futureValue 
should ===(regionA)
+    }
+
+    "allocate to mostly same regions when node is removed" in {
+      val allocationStrategy = strategy()
+      val allocations = emptyAllocationsABC
+      allocationStrategy.allocateShard(regionA, "0", allocations).futureValue 
should ===(regionC)
+      allocationStrategy.allocateShard(regionA, "1", allocations).futureValue 
should ===(regionB)
+      allocationStrategy.allocateShard(regionA, "2", allocations).futureValue 
should ===(regionA)
+      allocationStrategy.allocateShard(regionA, "3", allocations).futureValue 
should ===(regionA)
+      allocationStrategy.allocateShard(regionA, "10", allocations).futureValue 
should ===(regionA)
+      allocationStrategy.allocateShard(regionA, "14", allocations).futureValue 
should ===(regionA)
+
+      val allocations2 = allocations - regionC
+      allocationStrategy.allocateShard(regionA, "0", allocations2).futureValue 
should ===(regionA)
+      allocationStrategy.allocateShard(regionA, "1", allocations2).futureValue 
should ===(regionB)
+      allocationStrategy.allocateShard(regionA, "2", allocations2).futureValue 
should ===(regionA)
+      allocationStrategy.allocateShard(regionA, "3", allocations2).futureValue 
should ===(regionA)
+      allocationStrategy.allocateShard(regionA, "10", 
allocations2).futureValue should ===(regionA)
+      allocationStrategy.allocateShard(regionA, "14", 
allocations2).futureValue should ===(regionA)
+    }
+
+    "allocate to mostly same regions when node is added" in {
+      val allocationStrategy = strategy()
+      val allocations = emptyAllocationsABC
+      allocationStrategy.allocateShard(regionA, "0", allocations).futureValue 
should ===(regionC)
+      allocationStrategy.allocateShard(regionA, "1", allocations).futureValue 
should ===(regionB)
+      allocationStrategy.allocateShard(regionA, "2", allocations).futureValue 
should ===(regionA)
+      allocationStrategy.allocateShard(regionA, "3", allocations).futureValue 
should ===(regionA)
+      allocationStrategy.allocateShard(regionA, "10", allocations).futureValue 
should ===(regionA)
+      allocationStrategy.allocateShard(regionA, "14", allocations).futureValue 
should ===(regionA)
+
+      val allocations2 = allocations.updated(regionD, Vector.empty)
+      allocationStrategy.allocateShard(regionA, "0", allocations2).futureValue 
should ===(regionD)
+      allocationStrategy.allocateShard(regionA, "1", allocations2).futureValue 
should ===(regionB)
+      allocationStrategy.allocateShard(regionA, "2", allocations2).futureValue 
should ===(regionA)
+      allocationStrategy.allocateShard(regionA, "3", allocations2).futureValue 
should ===(regionA)
+      allocationStrategy.allocateShard(regionA, "10", 
allocations2).futureValue should ===(regionA)
+      allocationStrategy.allocateShard(regionA, "14", 
allocations2).futureValue should ===(regionA)
+    }
+
+    "not rebalance when nodes not changed" in {
+      val allocationStrategy = strategy()
+      val allocations = emptyAllocationsABC
+      allocationStrategy.allocateShard(regionA, "0", allocations).futureValue 
should ===(regionC)
+      allocationStrategy.allocateShard(regionA, "1", allocations).futureValue 
should ===(regionB)
+      allocationStrategy.allocateShard(regionA, "2", allocations).futureValue 
should ===(regionA)
+      allocationStrategy.allocateShard(regionA, "10", allocations).futureValue 
should ===(regionA)
+
+      val allocations2 = Map(regionA -> Vector("2", "10"), regionB -> 
Vector("1"), regionC -> Vector("0"))
+      allocationStrategy.rebalance(allocations2, Set.empty).futureValue should 
===(Set.empty[String])
+    }
+
+    "rebalance when node is added" in {
+      val allocationStrategy = strategy()
+      val allocations = emptyAllocationsABC
+      allocationStrategy.allocateShard(regionA, "0", allocations).futureValue 
should ===(regionC)
+      allocationStrategy.allocateShard(regionA, "1", allocations).futureValue 
should ===(regionB)
+      allocationStrategy.allocateShard(regionA, "2", allocations).futureValue 
should ===(regionA)
+      allocationStrategy.allocateShard(regionA, "3", allocations).futureValue 
should ===(regionA)
+      allocationStrategy.allocateShard(regionA, "10", allocations).futureValue 
should ===(regionA)
+      allocationStrategy.allocateShard(regionA, "14", allocations).futureValue 
should ===(regionA)
+
+      val allocations2 = Map(
+        regionA -> Vector("2", "3", "10", "14"),
+        regionB -> Vector("1"),
+        regionC -> Vector("0"),
+        regionD -> Vector.empty)
+      allocationStrategy.rebalance(allocations2, Set.empty).futureValue should 
===(Set("0"))
+
+      val allocations3 = Map(
+        regionB -> Vector("1"),
+        regionA -> Vector("2", "3", "10", "14"),
+        regionD -> Vector.empty,
+        regionC -> Vector("0"))
+      allocationStrategy.rebalance(allocations3, Set.empty).futureValue should 
===(Set("0"))
+    }
+
+    "not rebalance more than limit" in {
+      val allocationStrategy = strategy(rebalanceLimit = 2)
+      val allocations = Map(
+        regionA -> Vector("0", "1", "2", "3", "10", "14"),
+        regionB -> Vector.empty,
+        regionC -> Vector.empty,
+        regionD -> Vector.empty)
+      allocationStrategy.rebalance(allocations, Set.empty).futureValue should 
===(Set("0", "1"))
+
+      val allocations2 = Map(
+        regionA -> Vector("2", "3", "10", "14"),
+        regionB -> Vector("1"),
+        regionC -> Vector("0"),
+        regionD -> Vector.empty)
+      allocationStrategy.rebalance(allocations2, Set.empty).futureValue should 
===(Set("0"))
+
+      val allocations3 =
+        Map(regionA -> Vector("2", "3", "10", "14"), regionB -> Vector("1"), 
regionC -> Vector.empty,
+          regionD -> Vector("0"))
+      allocationStrategy.rebalance(allocations3, Set.empty).futureValue should 
===(Set.empty[String])
+    }
+
+    "not rebalance those that are in progress" in {
+      val allocationStrategy = strategy(rebalanceLimit = 2)
+      val allocations = Map(
+        regionA -> Vector("0", "1", "2", "3", "10", "14"),
+        regionB -> Vector.empty,
+        regionC -> Vector.empty,
+        regionD -> Vector.empty)
+      allocationStrategy.rebalance(allocations, Set.empty).futureValue should 
===(Set("0", "1"))
+      allocationStrategy.rebalance(allocations, Set("0", "1")).futureValue 
should ===(Set.empty[String])
+      // 2, 3, 10 and 14 are already at right place
+      allocationStrategy.rebalance(allocations, Set("0", "1", "2", 
"3")).futureValue should ===(Set.empty[String])
+    }
+
+    "not rebalance when rolling update in progress" in {
+      val allocationStrategy =
+        new ConsistentHashingShardAllocationStrategy(rebalanceLimit = 0) {
+
+          val member1 = newUpMember("127.0.0.1", version = Version("1.0.0"))
+          val member2 = newUpMember("127.0.0.2", version = Version("1.0.1"))
+          val member3 = newUpMember("127.0.0.3", version = Version("1.0.0"))
+
+          // multiple versions to simulate rolling update in progress
+          override protected def clusterState: CurrentClusterState =
+            CurrentClusterState(SortedSet(member1, member2, member3))
+
+          override protected def selfMember: Member = member1
+
+          override protected val log: LoggingAdapter =
+            Logging(system, classOf[ConsistentHashingShardAllocationStrategy])
+        }
+      val allocations = Map(regionA -> Vector("0", "1", "2", "3", "10", "14"), 
regionB -> Vector.empty)
+      allocationStrategy.rebalance(allocations, Set.empty).futureValue should 
===(Set.empty[String])
+    }
+
+    "not rebalance when regions are unreachable" in {
+      val allocationStrategy =
+        new ConsistentHashingShardAllocationStrategy(rebalanceLimit = 0) {
+
+          override protected def clusterState: CurrentClusterState =
+            CurrentClusterState(SortedSet(memberA, memberB, memberC), 
unreachable = Set(memberB))
+          override protected def selfMember: Member = memberB
+          override protected val log: LoggingAdapter =
+            Logging(system, classOf[ConsistentHashingShardAllocationStrategy])
+        }
+      val allocations =
+        Map(regionA -> Vector("0", "1", "2", "3", "10", "14"), regionB -> 
Vector.empty, regionC -> Vector.empty)
+      allocationStrategy.rebalance(allocations, Set.empty).futureValue should 
===(Set.empty[String])
+    }
+
+    "not rebalance when members are joining dc" in {
+      val allocationStrategy =
+        new ConsistentHashingShardAllocationStrategy(rebalanceLimit = 0) {
+
+          val member1 = newUpMember("127.0.0.1")
+          val member2 =
+            Member(
+              UniqueAddress(Address("pekko", "myapp", "127.0.0.2", 252525), 
1L),
+              Set(ClusterSettings.DcRolePrefix + 
ClusterSettings.DefaultDataCenter),
+              member1.appVersion)
+          val member3 = newUpMember("127.0.0.3")
+
+          override protected def clusterState: CurrentClusterState =
+            CurrentClusterState(SortedSet(member1, member2, member3), 
unreachable = Set.empty)
+          override protected def selfMember: Member = member2
+          override protected val log: LoggingAdapter =
+            Logging(system, classOf[ConsistentHashingShardAllocationStrategy])
+        }
+      val allocations =
+        Map(regionA -> Vector("0", "1", "2", "3", "10", "14"), regionB -> 
Vector.empty, regionC -> Vector.empty)
+      allocationStrategy.rebalance(allocations, Set.empty).futureValue should 
===(Set.empty[String])
+    }
+  }
+}
diff --git 
a/cluster-sharding/src/test/scala/org/apache/pekko/cluster/sharding/DeprecatedLeastShardAllocationStrategySpec.scala
 
b/cluster-sharding/src/test/scala/org/apache/pekko/cluster/sharding/DeprecatedLeastShardAllocationStrategySpec.scala
index 7b1a09c9d6..88e6c3d524 100644
--- 
a/cluster-sharding/src/test/scala/org/apache/pekko/cluster/sharding/DeprecatedLeastShardAllocationStrategySpec.scala
+++ 
b/cluster-sharding/src/test/scala/org/apache/pekko/cluster/sharding/DeprecatedLeastShardAllocationStrategySpec.scala
@@ -23,8 +23,8 @@ import pekko.cluster.ClusterSettings
 import pekko.cluster.Member
 import pekko.cluster.MemberStatus
 import pekko.cluster.UniqueAddress
-import pekko.cluster.sharding.internal.AbstractLeastShardAllocationStrategy
-import 
pekko.cluster.sharding.internal.AbstractLeastShardAllocationStrategy.RegionEntry
+import pekko.cluster.sharding.internal.ClusterShardAllocationMixin.RegionEntry
+import 
pekko.cluster.sharding.internal.ClusterShardAllocationMixin.ShardSuitabilityOrdering
 import pekko.testkit.PekkoSpec
 import pekko.util.Version
 
@@ -202,7 +202,7 @@ class DeprecatedLeastShardAllocationStrategySpec extends 
PekkoSpec {
           RegionEntry(fakeRegionC, newVersionMember2, Vector("ShardId1")))
 
       val sortedRegions =
-        
shardsAndMembers.sorted(AbstractLeastShardAllocationStrategy.ShardSuitabilityOrdering).map(_.region)
+        shardsAndMembers.sorted(ShardSuitabilityOrdering).map(_.region)
 
       // only node b has the new version
       sortedRegions should ===(
@@ -219,11 +219,13 @@ class DeprecatedLeastShardAllocationStrategySpec extends 
PekkoSpec {
         new ShardCoordinator.LeastShardAllocationStrategy(rebalanceThreshold = 
2, maxSimultaneousRebalance = 100) {
 
           val member1 = newUpMember("127.0.0.1", version = Version("1.0.0"))
-          val member2 = newUpMember("127.0.0.1", version = Version("1.0.1"))
+          val member2 = newUpMember("127.0.0.2", version = Version("1.0.1"))
+          val member3 = newUpMember("127.0.0.3", version = Version("1.0.0"))
 
           // multiple versions to simulate rolling update in progress
           override protected def clusterState: CurrentClusterState =
-            CurrentClusterState(SortedSet(member1, member2))
+            CurrentClusterState(SortedSet(member1, member2, member3))
+
           override protected def selfMember: Member = member1
         }
       val allocations = createAllocations(aCount = 5, bCount = 5)
@@ -237,13 +239,9 @@ class DeprecatedLeastShardAllocationStrategySpec extends 
PekkoSpec {
       val allocationStrategy =
         new ShardCoordinator.LeastShardAllocationStrategy(rebalanceThreshold = 
2, maxSimultaneousRebalance = 100) {
 
-          val member1 = newUpMember("127.0.0.1")
-          val member2 = newUpMember("127.0.0.2")
-
-          // multiple versions to simulate rolling update in progress
           override protected def clusterState: CurrentClusterState =
-            CurrentClusterState(SortedSet(member1, member2), unreachable = 
Set(member2))
-          override protected def selfMember: Member = member1
+            CurrentClusterState(SortedSet(memberA, memberB, memberC), 
unreachable = Set(memberB))
+          override protected def selfMember: Member = memberB
         }
       val allocations = createAllocations(aCount = 5, bCount = 5)
       allocationStrategy.rebalance(allocations, Set.empty).futureValue should 
===(Set.empty[String])
@@ -262,10 +260,10 @@ class DeprecatedLeastShardAllocationStrategySpec extends 
PekkoSpec {
               UniqueAddress(Address("pekko", "myapp", "127.0.0.2", 252525), 
1L),
               Set(ClusterSettings.DcRolePrefix + 
ClusterSettings.DefaultDataCenter),
               member1.appVersion)
+          val member3 = newUpMember("127.0.0.3")
 
-          // multiple versions to simulate rolling update in progress
           override protected def clusterState: CurrentClusterState =
-            CurrentClusterState(SortedSet(member1, member2), unreachable = 
Set(member2))
+            CurrentClusterState(SortedSet(member1, member2, member3), 
unreachable = Set.empty)
           override protected def selfMember: Member = member2
         }
       val allocations = createAllocations(aCount = 5, bCount = 5)
diff --git 
a/cluster-sharding/src/test/scala/org/apache/pekko/cluster/sharding/LeastShardAllocationStrategySpec.scala
 
b/cluster-sharding/src/test/scala/org/apache/pekko/cluster/sharding/LeastShardAllocationStrategySpec.scala
index 44da6b3fa4..50a5535ab4 100644
--- 
a/cluster-sharding/src/test/scala/org/apache/pekko/cluster/sharding/LeastShardAllocationStrategySpec.scala
+++ 
b/cluster-sharding/src/test/scala/org/apache/pekko/cluster/sharding/LeastShardAllocationStrategySpec.scala
@@ -31,8 +31,8 @@ import pekko.cluster.MemberStatus
 import pekko.cluster.UniqueAddress
 import pekko.cluster.sharding.ShardCoordinator.ShardAllocationStrategy
 import pekko.cluster.sharding.ShardRegion.ShardId
-import pekko.cluster.sharding.internal.AbstractLeastShardAllocationStrategy
-import 
pekko.cluster.sharding.internal.AbstractLeastShardAllocationStrategy.RegionEntry
+import pekko.cluster.sharding.internal.ClusterShardAllocationMixin.RegionEntry
+import 
pekko.cluster.sharding.internal.ClusterShardAllocationMixin.ShardSuitabilityOrdering
 import pekko.cluster.sharding.internal.LeastShardAllocationStrategy
 import pekko.testkit.PekkoSpec
 import pekko.util.Version
@@ -257,7 +257,7 @@ class LeastShardAllocationStrategySpec extends PekkoSpec {
           RegionEntry(fakeRegionC, newVersionMember2, Vector("ShardId1")))
 
       val sortedRegions =
-        
shardsAndMembers.sorted(AbstractLeastShardAllocationStrategy.ShardSuitabilityOrdering).map(_.region)
+        shardsAndMembers.sorted(ShardSuitabilityOrdering).map(_.region)
 
       // only node b has the new version
       sortedRegions should ===(
@@ -274,11 +274,12 @@ class LeastShardAllocationStrategySpec extends PekkoSpec {
         new LeastShardAllocationStrategy(absoluteLimit = 1000, relativeLimit = 
1.0) {
 
           val member1 = newUpMember("127.0.0.1", version = Version("1.0.0"))
-          val member2 = newUpMember("127.0.0.1", version = Version("1.0.1"))
+          val member2 = newUpMember("127.0.0.2", version = Version("1.0.1"))
+          val member3 = newUpMember("127.0.0.3", version = Version("1.0.0"))
 
           // multiple versions to simulate rolling update in progress
           override protected def clusterState: CurrentClusterState =
-            CurrentClusterState(SortedSet(member1, member2))
+            CurrentClusterState(SortedSet(member1, member2, member3))
 
           override protected def selfMember: Member = member1
         }
@@ -293,13 +294,9 @@ class LeastShardAllocationStrategySpec extends PekkoSpec {
       val allocationStrategy =
         new LeastShardAllocationStrategy(absoluteLimit = 1000, relativeLimit = 
1.0) {
 
-          val member1 = newUpMember("127.0.0.1")
-          val member2 = newUpMember("127.0.0.2")
-
-          // multiple versions to simulate rolling update in progress
           override protected def clusterState: CurrentClusterState =
-            CurrentClusterState(SortedSet(member1, member2), unreachable = 
Set(member2))
-          override protected def selfMember: Member = member2
+            CurrentClusterState(SortedSet(memberA, memberB, memberC), 
unreachable = Set(memberB))
+          override protected def selfMember: Member = memberB
         }
       val allocations = createAllocations(aCount = 5, bCount = 5)
       allocationStrategy.rebalance(allocations, Set.empty).futureValue should 
===(Set.empty[String])
@@ -317,10 +314,10 @@ class LeastShardAllocationStrategySpec extends PekkoSpec {
               UniqueAddress(Address("pekko", "myapp", "127.0.0.2", 252525), 
1L),
               Set(ClusterSettings.DcRolePrefix + 
ClusterSettings.DefaultDataCenter),
               member1.appVersion)
+          val member3 = newUpMember("127.0.0.3")
 
-          // multiple versions to simulate rolling update in progress
           override protected def clusterState: CurrentClusterState =
-            CurrentClusterState(SortedSet(member1, member2), unreachable = 
Set(member2))
+            CurrentClusterState(SortedSet(member1, member2, member3), 
unreachable = Set.empty)
           override protected def selfMember: Member = member2
         }
       val allocations = createAllocations(aCount = 5, bCount = 5)
diff --git a/docs/src/main/paradox/persistence-query.md 
b/docs/src/main/paradox/persistence-query.md
index 44f2de0bc1..a85199a8fd 100644
--- a/docs/src/main/paradox/persistence-query.md
+++ b/docs/src/main/paradox/persistence-query.md
@@ -172,6 +172,11 @@ See 
@apidoc[pekko.persistence.query.typed.*.EventsBySliceQuery] and @apidoc[pekk
 
 A variation of these are 
@apidoc[pekko.persistence.query.typed.*.EventsBySliceStartingFromSnapshotsQuery]
 and 
@apidoc[pekko.persistence.query.typed.*.CurrentEventsBySliceStartingFromSnapshotsQuery].
 
+@apidoc[pekko.persistence.query.typed.*.EventsBySliceFirehoseQuery] can give 
better scalability when many
+consumers retrieve the same events, for example many Projections of the same 
entity type. The purpose is
+to share the stream of events from the database and fan out to connected 
consumer streams. Thereby fewer queries
+and loading of events from the database. It is typically used together with 
@ref:[Sharded Daemon Process with colocated 
processes](typed/cluster-sharded-daemon-process.md#colocate-processes).
+
 ### Materialized values of queries
 
 Journals are able to provide additional information related to a query by 
exposing @ref:[Materialized 
values](stream/stream-quickstart.md#materialized-values-quick),
diff --git a/docs/src/main/paradox/typed/cluster-sharded-daemon-process.md 
b/docs/src/main/paradox/typed/cluster-sharded-daemon-process.md
index dcc31f29ce..26c8d6925f 100644
--- a/docs/src/main/paradox/typed/cluster-sharded-daemon-process.md
+++ b/docs/src/main/paradox/typed/cluster-sharded-daemon-process.md
@@ -18,8 +18,8 @@ To use Pekko Sharded Daemon Process, you must add the 
following dependency in yo
 ## Introduction
 
 Sharded Daemon Process provides a way to run `N` actors, each given a numeric 
id starting from `0` that are then kept alive
-and balanced across the cluster. When a rebalance is needed the actor is 
stopped and, triggered by a keep alive running on 
-all nodes, started on a new node (the keep alive should be seen as an 
implementation detail and may change in future versions).
+and balanced across the cluster. When a rebalance is needed the actor is 
stopped and, triggered by a keep alive from
+a Cluster Singleton (the keep alive should be seen as an implementation detail 
and may change in future versions).
 
 The intended use case is for splitting data processing workloads across a set 
number of workers that each get to work on a subset
 of the data that needs to be processed. This is commonly needed to create 
projections based on the event streams available
@@ -46,6 +46,40 @@ An additional factory method is provided for further 
configurability and providi
 In use cases where you need to send messages to the daemon process actors it 
is recommended to use the @ref:[system receptionist](actor-discovery.md)
 either with a single `ServiceKey` which all daemon process actors register 
themeselves to for broadcasts or individual keys if more fine grained messaging 
is needed.
 
+## Dynamic scaling of number of workers
+
+Starting the sharded daemon process with `initWithContext` returns an 
`ActorRef[ShardedDaemonProcessCommand]` that accepts a 
@apidoc[ChangeNumberOfProcesses] command to rescale the process to a new number 
of workers.
+
+The rescaling process among other things includes the process actors stopping 
themselves in response to a stop message 
+so may be a relatively slow operation. If a subsequent request to rescale is 
sent while one is in progress it is responded
+to with a failure response.
+
+A rolling upgrade switching from a static number of workers to a dynamic 
number is possible. 
+It is not safe to do a rolling upgrade from dynamic number of workers to 
static without a full cluster shutdown.
+
+### Colocate processes
+
+When using the default shard allocation strategy the processes for different 
names are allocated independent of
+each other, i.e. the same process index for different process names may be 
allocated to different nodes.
+Colocating processes can be useful to share resources, such as Projections 
with 
@ref:[EventsBySliceFirehoseQuery](../persistence-query.md#eventsbyslice-and-currenteventsbyslice)
+
+To colocate such processes you can use the 
@apidoc[pekko.cluster.sharding.ConsistentHashingShardAllocationStrategy]
+as `shardAllocationStrategy` parameter of the `init` or `initWithContext` 
methods. 
+
+@@@ note
+Create a new instance of the `ConsistentHashingShardAllocationStrategy` for 
each Sharded Daemon Process name, i.e. a 
`ConsistentHashingShardAllocationStrategy` instance must not be shared.
+@@@
+
+The shard identifier that is used by Sharded Daemon Process is the same as the 
process index, i.e. processes with
+the same index will be colocated.
+
+The allocation strategy is using [Consistent 
Hashing](https://tom-e-white.com/2007/11/consistent-hashing.html)
+of the Cluster membership ring to assign a shard to a node. When adding or 
removing nodes it will rebalance
+according to the new consistent hashing, but that means that only a few shards 
will be rebalanced and others
+remain on the same location. When there are changes to the Cluster membership 
the shards may be on different
+nodes for a while, but eventually, when the membership is stable, the shards 
with the same identifier will
+end up on the same node.
+
 ## Scalability  
 
 This cluster tool is intended for small numbers of consumers and will not 
scale well to a large set. In large clusters 
diff --git a/docs/src/main/paradox/typed/cluster-sharding.md 
b/docs/src/main/paradox/typed/cluster-sharding.md
index 1192a2ce5e..25be120b96 100644
--- a/docs/src/main/paradox/typed/cluster-sharding.md
+++ b/docs/src/main/paradox/typed/cluster-sharding.md
@@ -236,6 +236,44 @@ support a greater number of shards.
 is an example project that can be downloaded, and with instructions of how to 
run, that demonstrates how to use
 external sharding to co-locate Kafka partition consumption with shards.
 
+### Colocate Shards
+
+When using the default shard allocation strategy the shards for different 
entity types are allocated independent of
+each other, i.e. the same shard identifier for the different entity types may 
be allocated to different nodes.
+Colocating shards can be useful if it's known that certain entities interact 
or share resources with some other
+entities and that can be defined by using the same shard identifier.
+
+To colocate such shards you can use the 
@apidoc[pekko.cluster.sharding.ConsistentHashingShardAllocationStrategy].
+
+Let's look at an example where the purpose is to colocate `Device` entities 
with the `Building` entity they belong to.
+To use the same shard identifier we need to use a custom 
@apidoc[pekko.cluster.sharding.typed.ShardingMessageExtractor]
+for the `Device` and `Building` entities:
+
+Scala
+: @@snip 
[ConsistentHashingShardAllocationCompileOnlySpec](/cluster-sharding-typed/src/test/scala/docs/org/apache/pekko/cluster/sharding/typed/ConsistentHashingShardAllocationCompileOnlySpec.scala)
 { #building #device }
+
+Java
+: @@snip 
[ConsistentHashingShardAllocationCompileOnlyTest](/cluster-sharding-typed/src/test/java/jdocs/org/apache/pekko/cluster/sharding/typed/ConsistentHashingShardAllocationCompileOnlyTest.java)
 { #building #device }
+
+Set the allocation strategy and message extractor on your 
@apidoc[typed.*.Entity]:
+
+Scala
+: @@snip 
[ConsistentHashingShardAllocationCompileOnlySpec](/cluster-sharding-typed/src/test/scala/docs/org/apache/pekko/cluster/sharding/typed/ConsistentHashingShardAllocationCompileOnlySpec.scala)
 { #init }
+
+Java
+: @@snip 
[ConsistentHashingShardAllocationCompileOnlyTest](/cluster-sharding-typed/src/test/java/jdocs/org/apache/pekko/cluster/sharding/typed/ConsistentHashingShardAllocationCompileOnlyTest.java)
 { #init }
+
+@@@ note
+Create a new instance of the `ConsistentHashingShardAllocationStrategy` for 
each entity type, i.e. a `ConsistentHashingShardAllocationStrategy` instance 
must not be shared between different entity types.
+@@@
+
+The allocation strategy is using [Consistent 
Hashing](https://tom-e-white.com/2007/11/consistent-hashing.html)
+of the Cluster membership ring to assign a shard to a node. When adding or 
removing nodes it will rebalance
+according to the new consistent hashing, but that means that only a few shards 
will be rebalanced and others
+remain on the same location. When there are changes to the Cluster membership 
the shards may be on different
+nodes for a while, but eventually, when the membership is stable, the shards 
with the same identifier will
+end up on the same node.
+
 ### Custom shard allocation
 
 An optional custom shard allocation strategy can be passed into the optional 
parameter when initializing an entity type 


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to