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

   ## Summary
   
   `Replicator.isKnownNode` is built from the sets that the member-event 
handlers populate. A member whose *first* observed status is `Leaving`, 
`Exiting`, `Down`, `PreparingForShutdown` or `ReadyForShutdown` is not recorded 
in any of the sets that `isKnownNode` consults, so its `Read`, `Write` and 
`DeltaPropagation` messages are silently discarded.
   
   ## Evidence
   
   
`distributed-data/src/main/scala/org/apache/pekko/cluster/ddata/Replicator.scala:1406-1407`:
   
   ```scala
     private def isKnownNode(node: UniqueAddress): Boolean =
       nodes(node) || weaklyUpNodes(node) || joiningNodes(node) || 
selfUniqueAddress == node
   ```
   
   Note that `exitingNodes` — declared at `:1401` and populated at `:2466` — is 
not part of the check.
   
   The single call site is `:1590-1604`:
   
   ```scala
       case msg: SendingSystemUid =>
         msg.fromNode match {
           case Some(fromNode) if !isKnownNode(fromNode) =>
             // When restarting a node with same host:port it is possible that 
a Replicator on another node
             // is sending messages to the restarted node even if it hasn't 
joined the same cluster.
             // Therefore we check that the message was from a known cluster 
member
             log.info("Ignoring message [{}] from [{}] unknown node [{}]", 
Logging.simpleName(msg), replyTo, fromNode)
   
           case _ =>
             msg match {
               case Read(key, _)                          => receiveRead(key)
               case Write(key, envelope, _)               => receiveWrite(key, 
envelope)
               case DeltaPropagation(from, reply, deltas) => 
receiveDeltaPropagation(from, reply, deltas)
             }
         }
   ```
   
   The sets are populated by the dispatch at `:1615-1621`:
   
   ```scala
       case MemberJoined(m)     => receiveMemberJoining(m)     // joiningNodes 
+=   (:2444)
       case MemberWeaklyUp(m)   => receiveMemberWeaklyUp(m)    // weaklyUpNodes 
+=  (:2448)
       case MemberUp(m)         => receiveMemberUp(m)          // nodes +=      
    (:2456)
       case MemberExited(m)     => receiveMemberExiting(m)     // exitingNodes 
+=   (:2466)
       case MemberRemoved(m, _) => receiveMemberRemoved(m)
       case ClusterShuttingDown => receiveMemberRemoved(cluster.selfMember)
       case evt: MemberEvent    => receiveOtherMemberEvent(evt.member)
   ```
   
   and `receiveOtherMemberEvent` (`:2487-2492`) only maintains `leader`:
   
   ```scala
     def receiveOtherMemberEvent(m: Member): Unit =
       if (matchingRole(m)) {
         // replace, it's possible that the ordering is changed since it based 
on MemberStatus
         leader = leader.filterNot(_.uniqueAddress == m.uniqueAddress)
         leader += m
       }
   ```
   
   `MemberLeft`, `MemberDowned`, `MemberPreparingForShutdown` and 
`MemberReadyForShutdown` all fall into that catch-all.
   
   ## When this is reachable
   
   The Replicator subscribes with `InitialStateAsEvents` (`:58`). 
`ClusterEvent.diffMemberEvents` 
(`cluster/src/main/scala/org/apache/pekko/cluster/ClusterEvent.scala:552-561`) 
emits exactly **one** event per member, chosen from that member's *current* 
status:
   
   ```scala
           case m if m.status == Joining              => MemberJoined(m)
           case m if m.status == WeaklyUp             => MemberWeaklyUp(m)
           case m if m.status == Up                   => MemberUp(m)
           case m if m.status == Leaving              => MemberLeft(m)
           case m if m.status == Exiting              => MemberExited(m)
           case m if m.status == Down                 => MemberDowned(m)
           case m if m.status == PreparingForShutdown => 
MemberPreparingForShutdown(m)
           case m if m.status == ReadyForShutdown     => 
MemberReadyForShutdown(m)
   ```
   
   So a Replicator that starts while a peer is already past `Up` never receives 
a `MemberUp` for that peer:
   
   | peer's current status | event delivered | set updated | `isKnownNode` |
   |---|---|---|---|
   | `Joining` | `MemberJoined` | `joiningNodes` | true |
   | `WeaklyUp` | `MemberWeaklyUp` | `weaklyUpNodes` | true |
   | `Up` | `MemberUp` | `nodes` | true |
   | `Leaving` | `MemberLeft` | *(only `leader`)* | **false** |
   | `Exiting` | `MemberExited` | `exitingNodes` | **false** — the set is not 
consulted |
   | `Down` | `MemberDowned` | *(only `leader`)* | **false** |
   | `PreparingForShutdown` / `ReadyForShutdown` | respective event | *(only 
`leader`)* | **false** |
   
   The ordinary live path is unaffected: a member that transitions `Up → 
Leaving → Exiting` is already in `nodes` from its earlier `MemberUp`, and 
`nodes` is only pruned at `MemberRemoved` (`:2475`).
   
   Concrete scenario: node C joins a cluster in which node B has already begun 
leaving. C's Replicator replays B as `MemberLeft`, records nothing but 
`leader`, and then discards B's `Write` / `DeltaPropagation` with only an 
`log.info`. B's `Read` of C likewise goes unanswered.
   
   ## Impact
   
   Silent data loss and non-convergence for the affected peer, plus unanswered 
reads (the sender times out). A `Leaving` or `Exiting` member is still a full 
cluster member and still participates in replication until it is `Removed`, so 
dropping its writes is not the intended behaviour. The only signal is an 
INFO-level log line.
   
   Note the discarded set is `Read`/`Write`/`DeltaPropagation` only — `Status` 
and `Gossip` take the separate branch at `:1584-1588` and are not gated by 
`isKnownNode`.
   
   ## Suggested direction
   
   Two gaps need closing, and they are separable:
   
   1. `Exiting` — add `exitingNodes` to `isKnownNode`. Small and targeted.
   2. `Leaving` / `Down` / `PreparingForShutdown` / `ReadyForShutdown` — 
nothing records these members at all when they are first seen in that status. 
Either `receiveOtherMemberEvent` should track them in a set that `isKnownNode` 
consults, or the initial-state handling needs to record every current member 
regardless of status.
   
   Worth preserving the original intent of the check, which per the comment at 
`:1593-1595` is to reject messages from a *restarted* peer that reuses the same 
host:port but has not rejoined — that is what the `UniqueAddress` (address + 
uid) comparison is for, and it should keep working after the fix.
   
   ## Test
   
   A multi-jvm case in `distributed-data/src/multi-jvm/` where one node begins 
leaving (or is downed) before a second node's Replicator starts, then asserts 
the second node applies a `Write` / `DeltaPropagation` originating from the 
first.
   


-- 
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