He-Pin opened a new issue, #3697:
URL: https://github.com/apache/pekko/issues/3697

   ## Summary
   
   When the shard **coordinator singleton's** lease is lost, 
`ClusterSingletonManager` sends the configured `terminationMessage` to the 
singleton and returns to `AcquiringLease`, intending to re-acquire and carry 
on. For cluster sharding that message is `ShardCoordinator.Internal.Terminate`, 
which makes the coordinator gracefully shut down the **local** `ShardRegion` 
and then stop itself. The node is still `Up` and still supposed to host shards, 
but its local region is gone and is never recreated, because `ClusterSharding` 
caches the region ref and `start` short-circuits on the cache.
   
   A transient lease-backend blip therefore permanently disables that shard 
type on that node for the lifetime of the `ActorSystem`.
   
   ### Scope
   
   This requires a lease to be configured **for the coordinator singleton**, 
i.e. `pekko.cluster.sharding.coordinator-singleton.use-lease`. That is a 
separate setting from `pekko.cluster.sharding.use-lease`, which per 
`cluster-sharding/src/main/resources/reference.conf:423-426` applies only to 
per-shard leases:
   
   ```
     # Config path of the lease that each shard must acquire before starting 
entity actors
     # default is no lease
     # A lease can also be used for the singleton coordinator by settings it in 
the coordinator-singleton properties
     use-lease = ""
   ```
   
   and `reference.conf:369-370`:
   
   ```
     # A lease can be configured in these settings for the coordinator singleton
     coordinator-singleton = ${pekko.cluster.singleton}
   ```
   
   ## The chain
   
   **1. The termination message is `ShardCoordinator.Internal.Terminate`** — 
`cluster-sharding/src/main/scala/org/apache/pekko/cluster/sharding/ClusterSharding.scala:833-837`:
   
   ```scala
         context.actorOf(
           ClusterSingletonManager
             .props(singletonProps, terminationMessage = 
ShardCoordinator.Internal.Terminate, singletonSettings)
   ```
   
   Note also `:826`, `.withFinalStopMessage(_ == 
ShardCoordinator.Internal.Terminate)` — the backoff supervisor treats this 
message as final, so the coordinator child is not restarted by backoff either.
   
   **2. Lease loss fires it while the node is still `Up`** — 
`cluster-tools/src/main/scala/org/apache/pekko/cluster/singleton/ClusterSingletonManager.scala:990-997`:
   
   ```scala
       case Event(LeaseLost(reason), OldestData(singleton)) =>
         log.warning("Lease has been lost. Reason: {}. Terminating singleton 
and trying to re-acquire lease", reason)
         singleton match {
           case Some(s) =>
             s ! terminationMessage
             
goto(AcquiringLease).using(AcquiringLeaseData(leaseRequestInProgress = false, 
singleton))
           case None =>
             tryAcquireLease()
         }
   ```
   
   Nothing here consults the node's own member status. The intent, per the log 
message, is explicitly to re-acquire afterwards.
   
   **3. The coordinator shuts down the local region** — 
`cluster-sharding/src/main/scala/org/apache/pekko/cluster/sharding/ShardCoordinator.scala:964-978`:
   
   ```scala
     private def terminate(): Unit = {
       if (aliveRegions.exists(_.path.address.hasLocalScope) || 
gracefulShutdownInProgress.exists(
           _.path.address.hasLocalScope)) {
         aliveRegions
           .find(_.path.address.hasLocalScope)
           .foreach(region =>
             // region will get this from taking part in coordinated shutdown, 
but for good measure
             region ! ShardRegion.GracefulShutdown)
   
         log.debug("{}: Deferring coordinator termination until local region 
has terminated", typeName)
         waitingForLocalRegionToTerminate = true
   ```
   
   `terminate()` has no notion of *why* it was called. The comment shows the 
assumption is a coordinated shutdown, which is not the case here.
   
   **4. The region drains and stops** — `ShardRegion.scala:935-957` sets 
`gracefulShutdownInProgress = true` (the 
`CoordinatedShutdown.getShutdownReason()` check at `:945-952` only gates the 
*timeout timer*, not the shutdown itself), then `:1109-1113`:
   
   ```scala
     private def tryCompleteGracefulShutdownIfInProgress(): Unit =
       if (gracefulShutdownInProgress && shards.isEmpty && 
shardBuffers.isEmpty) {
         log.debug("{}: Completed graceful shutdown of region.", typeName)
         context.stop(self) // all shards have been rebalanced, complete 
graceful shutdown
       }
   ```
   
   **5. The coordinator then stops itself** — 
`ShardCoordinator.scala:1236-1243`:
   
   ```scala
           aliveRegions -= ref
           allocateShardHomesForRememberEntities()
           if (ref.path.address.hasLocalScope && 
waitingForLocalRegionToTerminate) {
             // handoff optimization: singleton told coordinator to stop but we 
deferred stop until the local region
             // had completed the handoff
             log.debug("{}: Local region stopped, terminating coordinator", 
typeName)
             terminate()
           }
   ```
   
   **6. The dead region ref stays cached forever** — 
`ClusterSharding.scala:189` `private val regions: ConcurrentHashMap[String, 
ActorRef]`; `start` at `:301-315`:
   
   ```scala
         regions.get(typeName) match {
           case null => ... regions.put(typeName, shardRegion); shardRegion
           case ref => ref // already started, use cached ActorRef
         }
   ```
   
   and `shardRegion(typeName)` at `:644-654` returns the same cached ref. 
Nothing watches the region or removes the entry when it terminates.
   
   **Net effect:** the coordinator singleton does come back — 
`ClusterSingletonManager` re-acquires the lease and starts a fresh one — but 
the local `ShardRegion` does not. 
`ClusterSharding(system).shardRegion(typeName)` keeps returning a terminated 
ref, so entity messages go to dead letters, with no log line connecting it to 
the earlier lease warning.
   
   ## Existing coverage gap
   
   
`cluster-sharding/src/test/scala/org/apache/pekko/cluster/sharding/ClusterShardingLeaseSpec.scala`
 has a "recover if lease lost" case, and it does pass — but it exercises a 
**shard** lease, not the coordinator's:
   
   ```scala
       "recover if lease lost" in {
         region ! 4
         ...
         val testLease = leaseForShard(4)
         testLease.initialPromise.complete(Success(true))
         expectMsg(4)
         testLease.getCurrentCallback()(Option(LeaseFailed("oh dear")))
         awaitAssert({ region ! 4; expectMsg(4) }, max = 10.seconds)
       }
   ```
   
   with `leaseForShard` / `leaseNameFor` at `:109-114` resolving to 
`s"${system.name}-shard-$typeName-$shardId"`. The spec's config sets 
`pekko.cluster.sharding.use-lease`, which does not give the coordinator 
singleton a lease. So the coordinator path is untested.
   
   ## Suggested direction
   
   `ShardCoordinator.terminate()` needs to distinguish "this node is leaving 
the cluster, so drain and stop the local region" from "the singleton is being 
restarted for a reason unrelated to membership". A guard along the lines of *is 
this node actually leaving* — `cluster.isTerminated`, or a 
`CoordinatedShutdown` reason is present, or the self member's status is 
`Leaving`/`Exiting`/`Down` — would let the lease-loss path stop only the 
coordinator and leave the local region running.
   
   An independent, complementary hardening is to stop serving a terminated ref 
from the `regions` cache (watch the region and evict the entry, so a later 
`start` recreates it). That is tracked separately and would limit the blast 
radius of any future path that stops a region unexpectedly.
   
   ## Reproduction recipe
   
   Configure `pekko.cluster.sharding.coordinator-singleton.use-lease = 
"test-lease"` on top of the existing `ClusterShardingLeaseSpec` setup. The 
coordinator singleton's lease name is derived at 
`ClusterSingletonManager.scala:521-525`:
   
   ```scala
     val lease: Option[Lease] = settings.leaseSettings.map { settings =>
       val leaseName =
         if (settings.leaseName.isEmpty) 
s"${context.system.name}-singleton-${self.path}"
         else settings.leaseName
   ```
   
   so it can be resolved from `TestLeaseExt` once the singleton has started. 
Then:
   
   1. join a single-node cluster, start sharding, let the coordinator acquire 
its lease;
   2. send a message and confirm an entity responds;
   3. fire `coordinatorLease.getCurrentCallback()(Option(LeaseFailed("blip")))`;
   4. assert that `ClusterSharding(system).shardRegion(typeName)` still 
delivers — today it should instead be a terminated ref, with the message 
landing in dead letters.
   
   Step 4 is the assertion that should fail before the fix.
   
   ## Verification status
   
   The chain above is traced statically; every link is cited from current 
`main`. I have **not** yet run the reproduction — the recipe is included so the 
fix can be developed against a failing test, per the contribution guideline 
that a bug-fix test must expose the issue first. If the reproduction does not 
fail as described, the analysis above is wrong somewhere and this should be 
closed.
   


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

To unsubscribe, e-mail: [email protected]

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


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

Reply via email to