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

markt-asf pushed a commit to branch 9.0.x
in repository https://gitbox.apache.org/repos/asf/tomcat.git


The following commit(s) were added to refs/heads/9.0.x by this push:
     new 7ff1a8c36d Review "Deduplicate ... when merging static and dynamic 
membership"
7ff1a8c36d is described below

commit 7ff1a8c36dc8a2a407f1a2a2c1d6d312c74a8afd
Author: Mark Thomas <[email protected]>
AuthorDate: Wed Oct 21 19:46:35 2026 +0100

    Review "Deduplicate ... when merging static and dynamic membership"
    
    Less of a review and more of a rewrite. Performing the merge in
    getMembers() created all sorts of edge cases where different components
    had different views of the current membership.
    
    This approach ensures a consistent view at the cost of some complexity.
    The short version is that if a node is receives a dynamic "member add"
    notification for a static node it already knows about, the dynamic node
    replaces the static one.
    
    Co-authored by: Claude / Sonnet 5.5
    Co-authored by: CoPilot / GPT-5.6 Sol
---
 .../group/interceptors/LocalStrings.properties     |   3 +
 .../interceptors/StaticMembershipInterceptor.java  | 281 ++++++++--
 .../group/interceptors/TcpFailureDetector.java     |  28 +
 .../TestStaticMembershipInterceptor.java           | 577 ++++++++++++++++-----
 .../TestTcpFailureDetectorLivenessChecks.java      |  65 +++
 webapps/docs/changelog.xml                         |  20 +
 6 files changed, 816 insertions(+), 158 deletions(-)

diff --git 
a/java/org/apache/catalina/tribes/group/interceptors/LocalStrings.properties 
b/java/org/apache/catalina/tribes/group/interceptors/LocalStrings.properties
index fd9e1d6f9b..d80dbee32b 100644
--- a/java/org/apache/catalina/tribes/group/interceptors/LocalStrings.properties
+++ b/java/org/apache/catalina/tribes/group/interceptors/LocalStrings.properties
@@ -89,10 +89,13 @@ 
nonBlockingCoordinator.waitForCoordMsgTimeout.tooSmall=Coordination message wait
 
 orderInterceptor.messageAdded.sameCounter=Message added has the same counter, 
synchronization bug. Disable the order interceptor
 
+staticMembershipInterceptor.duplicateStaticMember=The static member [{0}] has 
the same host and ports as the static member [{1}] that is already configured 
and is ignored. This is a configuration error.
+staticMembershipInterceptor.memberPortMismatch=The statically configured 
member [{0}] and the dynamically discovered member [{1}] have the same host but 
only some of the same ports. This is a configuration error, the two have not 
been treated as the same node.
 staticMembershipInterceptor.no.failureDetector=There is no TcpFailureDetector. 
Automatic detection of static members does not work properly. By defining the 
StaticMembershipInterceptor under the TcpFailureDetector, automatic detection 
of the static members will work.
 staticMembershipInterceptor.no.pingInterceptor=There is no TcpPingInterceptor. 
The health check of static members does not work properly. By defining the 
TcpPingInterceptor, the health check of static members will work.
 staticMembershipInterceptor.sendLocalMember.failed=Local member notification 
failed.
 staticMembershipInterceptor.sendShutdown.failed=Shutdown notification failed.
+staticMembershipInterceptor.staticMemberPortMismatch=The static member [{0}] 
has the same host as the static member [{1}] but only some of the same ports. 
This is a configuration error, the two have been treated as different nodes.
 
 tcpFailureDetector.already.disappeared=Verification complete. Member already 
disappeared [{0}]
 tcpFailureDetector.failureDetection.failed=Unable to perform failure detection 
check, assuming member down. [{0}]
diff --git 
a/java/org/apache/catalina/tribes/group/interceptors/StaticMembershipInterceptor.java
 
b/java/org/apache/catalina/tribes/group/interceptors/StaticMembershipInterceptor.java
index 8b2c2221ca..eabd56d55c 100644
--- 
a/java/org/apache/catalina/tribes/group/interceptors/StaticMembershipInterceptor.java
+++ 
b/java/org/apache/catalina/tribes/group/interceptors/StaticMembershipInterceptor.java
@@ -18,7 +18,9 @@ package org.apache.catalina.tribes.group.interceptors;
 
 import java.util.ArrayList;
 import java.util.Arrays;
+import java.util.HashMap;
 import java.util.List;
+import java.util.Map;
 import java.util.concurrent.ScheduledExecutorService;
 
 import org.apache.catalina.tribes.Channel;
@@ -61,6 +63,11 @@ public class StaticMembershipInterceptor extends 
ChannelInterceptorBase implemen
      * List of static members configured for this interceptor.
      */
     protected final ArrayList<Member> members = new ArrayList<>();
+    /**
+     * Static members that have been replaced by the dynamically discovered 
member for the same node, keyed by the
+     * member that replaced them. Guarded by {@link #members}.
+     */
+    private final Map<Member,Member> replaced = new HashMap<>();
     /**
      * The local member for this interceptor.
      */
@@ -75,28 +82,67 @@ public class StaticMembershipInterceptor extends 
ChannelInterceptorBase implemen
 
     /**
      * Adds a static member to the membership list.
+     *
      * @param member the member to add
      */
     public void addStaticMember(Member member) {
+        List<Member> withdrawn = new ArrayList<>();
         synchronized (members) {
-            if (!members.contains(member)) {
+            if (!members.contains(member) && !isDuplicateStaticMember(member)) 
{
                 members.add(member);
+                if (getNext() != null) {
+                    // the node may already have been discovered dynamically
+                    for (Member dynamic : super.getMembers()) {
+                        withdrawn.addAll(replaceConfiguredMember(dynamic));
+                    }
+                }
+            }
+        }
+        // outside of the lock, as the failure detector notifies the 
interceptors above
+        withdrawConfiguredMembers(withdrawn);
+    }
+
+    /**
+     * Checks if the static member to be added is for the same node as a 
static member that has been configured before,
+     * including the ones that are currently replaced by a dynamically 
discovered member. The same host and enabled
+     * ports with a different unique ID is a configuration error, and the 
later entry is ignored since the node would
+     * otherwise receive every message twice. The same host with only some of 
the same enabled ports is also reported as
+     * a configuration error, but both entries are kept. Must be called while 
holding the lock on {@link #members}.
+     *
+     * @param member the static member to be added
+     *
+     * @return {@code true} if a static member for the same node has been 
configured
+     */
+    private boolean isDuplicateStaticMember(Member member) {
+        List<Member> configured = new ArrayList<>(members);
+        configured.addAll(replaced.values());
+        for (Member other : configured) {
+            MemberMatch match = match(other, member);
+            if (match == MemberMatch.SAME_NODE) {
+                
log.error(sm.getString("staticMembershipInterceptor.duplicateStaticMember", 
member, other));
+                return true;
+            } else if (match == MemberMatch.PORT_MISMATCH) {
+                
log.error(sm.getString("staticMembershipInterceptor.staticMemberPortMismatch", 
member, other));
             }
         }
+        return false;
     }
 
     /**
      * Removes a static member from the membership list.
+     *
      * @param member the member to remove
      */
     public void removeStaticMember(Member member) {
         synchronized (members) {
             members.remove(member);
+            replaced.values().remove(member);
         }
     }
 
     /**
      * Sets the local member for this interceptor.
+     *
      * @param member the local member
      */
     public void setLocalMember(Member member) {
@@ -142,55 +188,194 @@ public class StaticMembershipInterceptor extends 
ChannelInterceptorBase implemen
         } else {
             synchronized (members) {
                 Member[] others = super.getMembers();
-                ArrayList<Member> result = new ArrayList<>(members.size() + 
others.length);
-                result.addAll(Arrays.asList(others));
-                for (Member member : members) {
-                    // A statically configured node that is also discovered
-                    // dynamically would otherwise be returned twice and
-                    // receive every message sent to the full member list.
-                    // The two copies are only equal by Member.equals when the
-                    // static configuration pins the same unique id the node
-                    // announces, so the destination TCP endpoint is used to
-                    // detect the duplicate instead
-                    if (!containsDestination(result, member)) {
-                        result.add(member);
-                    }
+                Member[] result = new Member[members.size() + others.length];
+                System.arraycopy(others, 0, result, 0, others.length);
+                for (int i = 0; i < members.size(); i++) {
+                    result[i + others.length] = members.get(i);
                 }
-                Member[] sorted = result.toArray(new Member[0]);
-                AbsoluteOrder.absoluteOrder(sorted);
-                return sorted;
+                AbsoluteOrder.absoluteOrder(result);
+                return result;
             } // sync
         } // end if
     }
 
     /**
-     * Checks whether the list of members already includes a member pointing 
at the same TCP
-     * destination as the given member. Two nodes cannot listen on the same 
TCP port on the same
-     * host, so host plus effective TCP port identifies the node, 
independently of the unique id
-     * that each copy happens to carry.
+     * Replaces the statically configured member for a node with the member 
for the same node that was discovered
+     * dynamically. The two represent the same node when the host and all of 
the enabled ports (TCP, secure and UDP) are
+     * the same. The unique ID of a dynamically discovered member is generated 
when the node starts, so it can not be
+     * known by the static configuration, and without the replacement the node 
would be present twice. If the host and
+     * only some of the enabled ports match, the static configuration is not 
consistent with the node and is reported as
+     * a configuration error.
+     *
+     * @param dynamic the member that was discovered dynamically
      *
-     * @param list   the members already collected for the result
-     * @param member the member to look for
-     * @return true if the list already contains the same TCP destination
+     * @return the replaced configured members, which have to be withdrawn by 
the caller, outside of the lock on
+     *             {@link #members}
      */
-    private static boolean containsDestination(List<Member> list, Member 
member) {
-        int port = member.getPort() >= 0 ? member.getPort() : 
member.getSecurePort();
-        for (Member other : list) {
-            int otherPort = other.getPort() >= 0 ? other.getPort() : 
other.getSecurePort();
-            if (otherPort == port && Arrays.equals(other.getHost(), 
member.getHost())) {
-                return true;
+    private List<Member> replaceConfiguredMember(Member dynamic) {
+        List<Member> withdrawn = new ArrayList<>();
+        synchronized (members) {
+            // The node may have been replaced earlier by a member that is no 
longer current, for example when the
+            // node was restarted and now announces a new unique ID
+            Member previous = null;
+            Member previousConfigured = null;
+            for (Map.Entry<Member,Member> entry : replaced.entrySet()) {
+                MemberMatch match = match(entry.getValue(), dynamic);
+                if (match == MemberMatch.SAME_NODE) {
+                    previous = entry.getKey();
+                    previousConfigured = entry.getValue();
+                } else if (match == MemberMatch.PORT_MISMATCH) {
+                    log.error(
+                            
sm.getString("staticMembershipInterceptor.memberPortMismatch", 
entry.getValue(), dynamic));
+                }
+            }
+            if (previous != null) {
+                replaced.remove(previous);
+                replaced.put(dynamic, previousConfigured);
+            }
+            for (int i = 0; i < members.size();) {
+                Member configured = members.get(i);
+                MemberMatch match = match(configured, dynamic);
+                if (match == MemberMatch.SAME_NODE) {
+                    members.remove(i);
+                    replaced.put(dynamic, configured);
+                    withdrawn.add(configured);
+                } else {
+                    if (match == MemberMatch.PORT_MISMATCH) {
+                        
log.error(sm.getString("staticMembershipInterceptor.memberPortMismatch", 
configured, dynamic));
+                    }
+                    i++;
+                }
             }
         }
-        return false;
+        return withdrawn;
+    }
+
+    /**
+     * Withdraws configured members that have been replaced by a dynamically 
discovered member from the failure detector
+     * above this interceptor. The failure detector has to forget them, as it 
would otherwise keep them as suspects and
+     * add them again next to the discovered member, and it tells the 
interceptors above it that they disappeared.
+     * Without a failure detector the configured members are not withdrawn.
+     *
+     * @param withdrawn the configured members to withdraw
+     */
+    private void withdrawConfiguredMembers(List<Member> withdrawn) {
+        if (withdrawn.isEmpty()) {
+            return;
+        }
+        TcpFailureDetector failureDetector = getFailureDetector();
+        if (failureDetector != null) {
+            for (Member configured : withdrawn) {
+                failureDetector.forgetMember(configured);
+            }
+        }
+    }
+
+    /**
+     * Finds the closest failure detector above this interceptor.
+     *
+     * @return the failure detector, or {@code null} if there is none
+     */
+    private TcpFailureDetector getFailureDetector() {
+        ChannelInterceptor prev = getPrevious();
+        while (prev != null) {
+            if (prev instanceof TcpFailureDetector failureDetector) {
+                return failureDetector;
+            }
+            prev = prev.getPrevious();
+        }
+        return null;
+    }
+
+    private enum MemberMatch {
+        /** Different nodes. */
+        DIFFERENT_NODE,
+        /** The host and all ports match. */
+        SAME_NODE,
+        /** The host matches and some, but not all, of the ports match. */
+        PORT_MISMATCH
+    }
+
+    /*
+     * Only ports that are used by at least one of the members are compared. A 
port that neither member uses is -1 on
+     * both, and must not make two nodes with different TCP ports on the same 
host look partially identical.
+     */
+    private static MemberMatch match(Member configured, Member dynamic) {
+        if (!Arrays.equals(configured.getHost(), dynamic.getHost())) {
+            return MemberMatch.DIFFERENT_NODE;
+        }
+        int used = 0;
+        int matching = 0;
+        int[][] ports =
+                { { configured.getPort(), dynamic.getPort() }, { 
configured.getSecurePort(), dynamic.getSecurePort() },
+                        { configured.getUdpPort(), dynamic.getUdpPort() } };
+        for (int[] pair : ports) {
+            if (pair[0] >= 0 || pair[1] >= 0) {
+                used++;
+                if (pair[0] == pair[1]) {
+                    matching++;
+                }
+            }
+        }
+        if (used == 0 || matching == 0) {
+            return MemberMatch.DIFFERENT_NODE;
+        } else if (matching == used) {
+            return MemberMatch.SAME_NODE;
+        } else {
+            return MemberMatch.PORT_MISMATCH;
+        }
+    }
+
+    @Override
+    public void memberAdded(Member member) {
+        // The lock on members is only held to update the state. The failure 
detector verifies the member over the
+        // network and notifies the interceptors above, which must not happen 
while holding the lock.
+        List<Member> withdrawn;
+        boolean replacing;
+        synchronized (members) {
+            withdrawn = replaceConfiguredMember(member);
+            replacing = !withdrawn.isEmpty() || replaced.containsKey(member);
+        }
+        // the configured member is withdrawn before the member replacing it 
is announced
+        withdrawConfiguredMembers(withdrawn);
+        super.memberAdded(member);
+        if (replacing) {
+            // The member may have disappeared while it was being announced, 
and the disappearance may have been
+            // handled before the announcement. The failure detector would 
then keep a member that is gone.
+            boolean stale;
+            synchronized (members) {
+                stale = !replaced.containsKey(member);
+            }
+            if (stale) {
+                TcpFailureDetector failureDetector = getFailureDetector();
+                if (failureDetector != null) {
+                    failureDetector.forgetMember(member);
+                }
+            }
+        }
+    }
+
+    @Override
+    public void memberDisappeared(Member member) {
+        synchronized (members) {
+            // the dynamic member is gone, the node stays a member as it was 
configured
+            Member configured = replaced.remove(member);
+            if (configured != null && !members.contains(configured)) {
+                members.add(configured);
+            }
+        }
+        super.memberDisappeared(member);
     }
 
     @Override
     public Member getMember(Member mbr) {
-        if (members.contains(mbr)) {
-            return members.get(members.indexOf(mbr));
-        } else {
-            return super.getMember(mbr);
+        synchronized (members) {
+            int index = members.indexOf(mbr);
+            if (index >= 0) {
+                return members.get(index);
+            }
         }
+        return super.getMember(mbr);
     }
 
     @Override
@@ -215,11 +400,21 @@ public class StaticMembershipInterceptor extends 
ChannelInterceptorBase implemen
         if ((Channel.SND_TX_SEQ & svc) == Channel.SND_TX_SEQ) {
             super.start(Channel.SND_TX_SEQ);
         }
-        final ChannelInterceptorBase base = this;
         ScheduledExecutorService executor = getChannel().getUtilityExecutor();
-        for (final Member member : members) {
+        Member[] configured;
+        synchronized (members) {
+            configured = members.toArray(new Member[0]);
+        }
+        for (final Member member : configured) {
             Runnable r = () -> {
-                base.memberAdded(member);
+                synchronized (members) {
+                    if (!members.contains(member)) {
+                        // replaced by the dynamically discovered member in 
the meantime
+                        return;
+                    }
+                }
+                // the configured member is not a dynamically discovered member
+                StaticMembershipInterceptor.super.memberAdded(member);
                 if (getfirstInterceptor().getMember(member) != null) {
                     sendLocalMember(new Member[] { member });
                 }
@@ -229,13 +424,10 @@ public class StaticMembershipInterceptor extends 
ChannelInterceptorBase implemen
         super.start(svc & (~Channel.SND_RX_SEQ) & (~Channel.SND_TX_SEQ));
 
         // check required interceptors
-        TcpFailureDetector failureDetector = null;
+        TcpFailureDetector failureDetector = getFailureDetector();
         TcpPingInterceptor pingInterceptor = null;
         ChannelInterceptor prev = getPrevious();
         while (prev != null) {
-            if (prev instanceof TcpFailureDetector) {
-                failureDetector = (TcpFailureDetector) prev;
-            }
             if (prev instanceof TcpPingInterceptor) {
                 pingInterceptor = (TcpPingInterceptor) prev;
             }
@@ -264,6 +456,7 @@ public class StaticMembershipInterceptor extends 
ChannelInterceptorBase implemen
 
     /**
      * Sends a local member start message to the given members.
+     *
      * @param members the members to notify
      */
     protected void sendLocalMember(Member[] members) {
@@ -276,6 +469,7 @@ public class StaticMembershipInterceptor extends 
ChannelInterceptorBase implemen
 
     /**
      * Sends a shutdown message to the given members.
+     *
      * @param members the members to notify
      */
     protected void sendShutdown(Member[] members) {
@@ -288,6 +482,7 @@ public class StaticMembershipInterceptor extends 
ChannelInterceptorBase implemen
 
     /**
      * Returns the first (bottom-most) interceptor in the chain.
+     *
      * @return the first channel interceptor
      */
     protected ChannelInterceptor getfirstInterceptor() {
@@ -302,8 +497,10 @@ public class StaticMembershipInterceptor extends 
ChannelInterceptorBase implemen
 
     /**
      * Sends a member message to the given members.
+     *
      * @param members the members to send to
      * @param message the message payload
+     *
      * @throws ChannelException if sending fails
      */
     protected void sendMemberMessage(Member[] members, byte[] message) throws 
ChannelException {
diff --git 
a/java/org/apache/catalina/tribes/group/interceptors/TcpFailureDetector.java 
b/java/org/apache/catalina/tribes/group/interceptors/TcpFailureDetector.java
index 6a8a48c6a0..5eb8f5ffae 100644
--- a/java/org/apache/catalina/tribes/group/interceptors/TcpFailureDetector.java
+++ b/java/org/apache/catalina/tribes/group/interceptors/TcpFailureDetector.java
@@ -215,6 +215,34 @@ public class TcpFailureDetector extends 
ChannelInterceptorBase implements TcpFai
         }
     }
 
+    /**
+     * Forgets a member that is represented by another member from now on, for 
example a statically configured
+     * member whose node has been discovered dynamically. Unlike {@link 
#memberDisappeared(Member)}, the member is
+     * not kept as a suspect, so it is not added again by the member checks. 
If the member was a member of this
+     * detector, the interceptors above are told that it disappeared.
+     *
+     * @param member the member to forget
+     */
+    public void forgetMember(Member member) {
+        if (membership == null) {
+            setupMembership();
+        }
+        boolean removed;
+        synchronized (membership) {
+            // supersedes running checks of the member
+            recordMemberEvent(member);
+            removed = membership.contains(member);
+            if (removed) {
+                membership.removeMember(member);
+            }
+            removeSuspects.remove(member);
+            addSuspects.remove(member);
+        }
+        if (removed) {
+            super.memberDisappeared(member);
+        }
+    }
+
     @Override
     public void memberDisappeared(Member member) {
         if (membership == null) {
diff --git 
a/test/org/apache/catalina/tribes/group/interceptors/TestStaticMembershipInterceptor.java
 
b/test/org/apache/catalina/tribes/group/interceptors/TestStaticMembershipInterceptor.java
index 8d0ee184ec..0eb92c6cf4 100644
--- 
a/test/org/apache/catalina/tribes/group/interceptors/TestStaticMembershipInterceptor.java
+++ 
b/test/org/apache/catalina/tribes/group/interceptors/TestStaticMembershipInterceptor.java
@@ -16,12 +16,20 @@
  */
 package org.apache.catalina.tribes.group.interceptors;
 
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+
 import org.junit.Assert;
 import org.junit.Test;
 
 import org.apache.catalina.tribes.Member;
 import org.apache.catalina.tribes.group.ChannelInterceptorBase;
 import org.apache.catalina.tribes.membership.MemberImpl;
+import org.apache.catalina.tribes.membership.StaticMember;
 
 public class TestStaticMembershipInterceptor {
 
@@ -34,181 +42,518 @@ public class TestStaticMembershipInterceptor {
         public Member[] getMembers() {
             return members;
         }
+
+        @Override
+        public Member getLocalMember(boolean incAlive) {
+            return localMember;
+        }
+
+        private final Member localMember = createLocalMember();
+
+        private static Member createLocalMember() {
+            try {
+                return new StaticMember("localhost", 5000, -1);
+            } catch (Exception e) {
+                throw new IllegalStateException(e);
+            }
+        }
     }
 
-    private static MemberImpl member(String host, int port) throws Exception {
-        return new MemberImpl(host, port, -1);
+    // Upstream stand-in: records the member events the interceptor forwards
+    private static class RecordingInterceptor extends ChannelInterceptorBase {
+
+        final List<Member> added = Collections.synchronizedList(new 
ArrayList<>());
+        final List<Member> disappeared = Collections.synchronizedList(new 
ArrayList<>());
+        // The events in the order they were received
+        final List<String> events = Collections.synchronizedList(new 
ArrayList<>());
+
+        @Override
+        public void memberAdded(Member member) {
+            added.add(member);
+            events.add("added:" + member.getUniqueId()[0]);
+        }
+
+        @Override
+        public void memberDisappeared(Member member) {
+            disappeared.add(member);
+            events.add("disappeared:" + member.getUniqueId()[0]);
+        }
     }
 
-    /*
-     * The copy the channel sees through dynamic discovery for the node 
represented by the
-     * given statically configured member: the same host and TCP port but a 
different unique
-     * id, since the unique id of a node that does not pin one explicitly is 
random per JVM
-     * start and cannot match the statically configured copy.
-     */
-    private static MemberImpl discoveredCopy(MemberImpl configured) throws 
Exception {
-        return member("localhost", configured.getPort());
+    // Failure detector that does not use the network
+    private static class AliveDetector extends TcpFailureDetector {
+
+        volatile boolean alive = true;
+
+        @Override
+        protected boolean memberAlive(Member mbr) {
+            return alive;
+        }
+    }
+
+    // Failure detector that holds the withdrawal of a replaced member
+    private static class GatedForgetDetector extends AliveDetector {
+
+        final CountDownLatch forgetStarted = new CountDownLatch(1);
+        final CountDownLatch releaseForget = new CountDownLatch(1);
+
+        @Override
+        public void forgetMember(Member member) {
+            forgetStarted.countDown();
+            try {
+                Assert.assertTrue("Timed out waiting to release the 
withdrawal",
+                        releaseForget.await(10, TimeUnit.SECONDS));
+            } catch (InterruptedException e) {
+                Thread.currentThread().interrupt();
+                throw new IllegalStateException(e);
+            }
+            super.forgetMember(member);
+        }
     }
 
-    private static void setUniqueId(MemberImpl member, int first) {
+    private final StaticMembershipInterceptor interceptor = new 
StaticMembershipInterceptor();
+    private final DynamicMembershipChain chain = new DynamicMembershipChain();
+    private final RecordingInterceptor recorder = new RecordingInterceptor();
+
+    public TestStaticMembershipInterceptor() {
+        interceptor.setNext(chain);
+        interceptor.setPrevious(recorder);
+    }
+
+    private static StaticMember member(String host, int port, int 
uniqueIdStart) throws Exception {
+        StaticMember member = new StaticMember(host, port, -1);
         byte[] uniqueId = new byte[16];
         for (int i = 0; i < uniqueId.length; i++) {
-            uniqueId[i] = (byte) (first + i);
+            uniqueId[i] = (byte) (uniqueIdStart + i);
         }
         member.setUniqueId(uniqueId);
+        return member;
     }
 
-    private static int countOfPort(Member[] members, int port) {
-        int count = 0;
-        for (Member m : members) {
-            int effectivePort = m.getPort() >= 0 ? m.getPort() : 
m.getSecurePort();
-            if (effectivePort == port) {
-                count++;
-            }
+    /*
+     * The member the channel sees through dynamic discovery for the node that 
is configured statically as the
+     * given member: the same host and ports, but a different unique id, since 
the unique id of a dynamically
+     * discovered node is random per JVM start and can not be known by the 
static configuration.
+     */
+    private static MemberImpl discoveredCopy(MemberImpl configured, int 
uniqueIdStart) throws Exception {
+        MemberImpl copy = new MemberImpl("localhost", configured.getPort(), 
-1);
+        copy.setUniqueId(member("localhost", configured.getPort(), 
uniqueIdStart).getUniqueId());
+        copy.setSecurePort(configured.getSecurePort());
+        copy.setUdpPort(configured.getUdpPort());
+        return copy;
+    }
+
+    // Puts a failure detector between the interceptor and the recorder, as in 
the documented stack
+    private AliveDetector installDetector() {
+        return installDetector(new AliveDetector());
+    }
+
+    private AliveDetector installDetector(AliveDetector detector) {
+        detector.setNext(interceptor);
+        detector.setPrevious(recorder);
+        interceptor.setPrevious(detector);
+        return detector;
+    }
+
+    private void discover(Member... discovered) {
+        chain.members = discovered;
+        for (Member member : discovered) {
+            interceptor.memberAdded(member);
         }
-        return count;
     }
 
     @Test
-    public void testGetMembersDeduplicatesStaticAndDynamic() throws Exception {
-        StaticMembershipInterceptor interceptor = new 
StaticMembershipInterceptor();
-        DynamicMembershipChain chain = new DynamicMembershipChain();
-        interceptor.setNext(chain);
+    public void testDiscoveredMemberReplacesConfiguredMember() throws 
Exception {
+        MemberImpl configured = member("localhost", 4001, 17);
+        MemberImpl discovered = discoveredCopy(configured, 49);
+        Assert.assertNotEquals("The two copies must not be equal by 
Member.equals", configured, discovered);
+        interceptor.addStaticMember(configured);
 
-        MemberImpl staticOnly = member("localhost", 4000);
-        setUniqueId(staticOnly, 1);
-        MemberImpl bothConfigured = member("localhost", 4001);
-        setUniqueId(bothConfigured, 17);
-        MemberImpl dynamicOnly = member("localhost", 4002);
-        setUniqueId(dynamicOnly, 33);
-        // The statically configured node is also discovered dynamically, as a
-        // different instance of the same node with a different unique id, so
-        // Member.equals does not match the two copies
-        MemberImpl bothDiscovered = discoveredCopy(bothConfigured);
-        setUniqueId(bothDiscovered, 49);
-        Assert.assertNotEquals("The two copies must not be equal by 
Member.equals",
-                bothConfigured, bothDiscovered);
-        chain.members = new Member[] { dynamicOnly, bothDiscovered };
-        interceptor.addStaticMember(bothConfigured);
-        interceptor.addStaticMember(staticOnly);
+        discover(discovered);
 
         Member[] result = interceptor.getMembers();
-        Assert.assertEquals("Each destination must be returned exactly once", 
3, result.length);
-        Assert.assertEquals(1, countOfPort(result, 4000));
-        Assert.assertEquals(1, countOfPort(result, 4001));
-        Assert.assertEquals(1, countOfPort(result, 4002));
+        Assert.assertEquals("The node must be returned exactly once", 1, 
result.length);
+        Assert.assertSame("The discovered member must replace the configured 
member", discovered, result[0]);
+        Assert.assertTrue("The configured member must have been replaced", 
interceptor.members.isEmpty());
+        Assert.assertEquals("The discovered member must be forwarded", 1, 
recorder.added.size());
+        Assert.assertSame(discovered, recorder.added.get(0));
+        Assert.assertTrue("A configured member that was never announced must 
not be withdrawn",
+                recorder.disappeared.isEmpty());
     }
 
     @Test
-    public void testGetMembersDeduplicatesSecurePortOnlyListeners() throws 
Exception {
-        StaticMembershipInterceptor interceptor = new 
StaticMembershipInterceptor();
-        DynamicMembershipChain chain = new DynamicMembershipChain();
-        interceptor.setNext(chain);
+    public void testReplacedConfiguredMemberIsForgottenByFailureDetector() 
throws Exception {
+        AliveDetector detector = installDetector();
+        StaticMember configured = member("localhost", 4001, 17);
+        MemberImpl discovered = discoveredCopy(configured, 49);
+        interceptor.addStaticMember(configured);
+        // announced as the interceptor does when it starts
+        detector.memberAdded(configured);
+        Assert.assertNotNull(detector.getMember(configured));
+
+        discover(discovered);
+
+        Member[] result = detector.getMembers();
+        Assert.assertEquals("The failure detector must know the node once", 1, 
result.length);
+        Assert.assertSame(discovered, result[0]);
+        Assert.assertEquals("The configured member must be withdrawn before 
the discovered member is announced",
+                Arrays.asList("added:17", "disappeared:17", "added:49"), 
recorder.events);
+    }
+
+    @Test
+    public void 
testReplacedConfiguredMemberIsNotAddedAgainByFailureDetectorHeartbeat() throws 
Exception {
+        AliveDetector detector = installDetector();
+        StaticMember configured = member("localhost", 4001, 17);
+        MemberImpl discovered = discoveredCopy(configured, 49);
+        interceptor.addStaticMember(configured);
+        detector.memberAdded(configured);
+        discover(discovered);
+
+        detector.checkMembers(false);
+        detector.checkMembers(true);
+
+        Member[] result = detector.getMembers();
+        Assert.assertEquals("The configured member must not be added again", 
1, result.length);
+        Assert.assertSame(discovered, result[0]);
+        Assert.assertEquals(Arrays.asList("added:17", "disappeared:17", 
"added:49"), recorder.events);
+    }
+
+    @Test
+    public void 
testReplacedConfiguredMemberRejectedByFailureDetectorIsNotAddedLater() throws 
Exception {
+        AliveDetector detector = installDetector();
+        StaticMember configured = member("localhost", 4001, 17);
+        MemberImpl discovered = discoveredCopy(configured, 49);
+        interceptor.addStaticMember(configured);
+        // the node is not reachable yet, so the failure detector keeps the 
configured member as a suspect
+        detector.alive = false;
+        detector.memberAdded(configured);
+        Assert.assertNull(detector.getMember(configured));
+        Assert.assertTrue(detector.addSuspects.containsKey(configured));
+
+        detector.alive = true;
+        discover(discovered);
+        detector.checkMembers(false);
+
+        Member[] result = detector.getMembers();
+        Assert.assertEquals("The configured member must not be added later", 
1, result.length);
+        Assert.assertSame(discovered, result[0]);
+        Assert.assertTrue(detector.addSuspects.isEmpty());
+        Assert.assertEquals("Nothing was announced for the configured member", 
Arrays.asList("added:49"),
+                recorder.events);
+    }
+
+    @Test
+    public void testDisappearanceDuringReplacementLeavesOnlyConfiguredMember() 
throws Exception {
+        GatedForgetDetector detector = new GatedForgetDetector();
+        installDetector(detector);
+        StaticMember configured = member("localhost", 4001, 17);
+        MemberImpl discovered = discoveredCopy(configured, 49);
+        interceptor.addStaticMember(configured);
+        detector.memberAdded(configured);
+        chain.members = new Member[] { discovered };
+
+        // The replacement is held after the configured member was replaced, 
before the discovered member is
+        // announced
+        Thread replacementThread = new Thread(() -> 
interceptor.memberAdded(discovered));
+        replacementThread.start();
+        Assert.assertTrue("The withdrawal did not start", 
detector.forgetStarted.await(10, TimeUnit.SECONDS));
+        Assert.assertTrue(interceptor.members.isEmpty());
+
+        // The discovered member disappears meanwhile, without waiting for the 
replacement
+        chain.members = new Member[0];
+        interceptor.memberDisappeared(discovered);
+        Assert.assertSame("The configured member is restored", configured, 
interceptor.members.get(0));
+
+        detector.releaseForget.countDown();
+        replacementThread.join(10000);
+        Assert.assertFalse(replacementThread.isAlive());
+
+        // The discovered member that was announced after it had disappeared 
must not be kept
+        Assert.assertNull("The announcement of a member that is gone must be 
undone", detector.getMember(discovered));
+        detector.checkMembers(false);
+        Member[] result = detector.getMembers();
+        Assert.assertEquals("Only the configured member remains", 1, 
result.length);
+        Assert.assertSame(configured, result[0]);
+    }
+
+    @Test
+    public void 
testDisappearanceDuringAddingStaticMemberLeavesOnlyConfiguredMember() throws 
Exception {
+        GatedForgetDetector detector = new GatedForgetDetector();
+        installDetector(detector);
+        StaticMember configured = member("localhost", 4001, 17);
+        MemberImpl discovered = discoveredCopy(configured, 49);
+        detector.memberAdded(configured);
+        chain.members = new Member[] { discovered };
+        detector.memberAdded(discovered);
+
+        Thread addThread = new Thread(() -> 
interceptor.addStaticMember(configured));
+        addThread.start();
+        Assert.assertTrue("The withdrawal did not start", 
detector.forgetStarted.await(10, TimeUnit.SECONDS));
+        Assert.assertTrue(interceptor.members.isEmpty());
+
+        // The discovered member disappears meanwhile, and is not alive
+        chain.members = new Member[0];
+        detector.alive = false;
+        interceptor.memberDisappeared(discovered);
+        Assert.assertSame("The configured member is restored", configured, 
interceptor.members.get(0));
+
+        detector.alive = true;
+        detector.releaseForget.countDown();
+        addThread.join(10000);
+        Assert.assertFalse(addThread.isAlive());
+
+        // The withdrawal was applied to the restored member, the next 
heartbeat finds it again
+        detector.checkMembers(false);
+        Member[] result = detector.getMembers();
+        Assert.assertEquals("Only the configured member remains", 1, 
result.length);
+        Assert.assertSame(configured, result[0]);
+    }
+
+    @Test
+    public void testDiscoveredMemberWithSameUniqueIdReplacesConfiguredMember() 
throws Exception {
+        MemberImpl configured = member("localhost", 4001, 17);
+        MemberImpl discovered = discoveredCopy(configured, 17);
+        Assert.assertEquals(configured, discovered);
+        interceptor.addStaticMember(configured);
 
-        MemberImpl bothConfigured = new MemberImpl("localhost", -1, -1);
-        bothConfigured.setSecurePort(4433);
-        setUniqueId(bothConfigured, 17);
-        MemberImpl bothDiscovered = new MemberImpl("localhost", -1, -1);
-        bothDiscovered.setSecurePort(4433);
-        setUniqueId(bothDiscovered, 49);
-        chain.members = new Member[] { bothDiscovered };
-        interceptor.addStaticMember(bothConfigured);
+        discover(discovered);
 
         Member[] result = interceptor.getMembers();
-        Assert.assertEquals("The secure port destination must be returned 
once", 1, result.length);
+        Assert.assertEquals("The node must be returned exactly once", 1, 
result.length);
+        Assert.assertSame(discovered, result[0]);
     }
 
     @Test
-    public void testGetMembersKeepsDifferentDestinations() throws Exception {
-        StaticMembershipInterceptor interceptor = new 
StaticMembershipInterceptor();
-        DynamicMembershipChain chain = new DynamicMembershipChain();
-        interceptor.setNext(chain);
+    public void testSecureAndUdpPortsMustAllMatch() throws Exception {
+        MemberImpl configured = member("localhost", 4001, 17);
+        configured.setSecurePort(4433);
+        configured.setUdpPort(4500);
+        MemberImpl discovered = discoveredCopy(configured, 49);
+        interceptor.addStaticMember(configured);
 
-        // Same host, different port: distinct nodes, both must be kept
-        MemberImpl dynamic = member("localhost", 4000);
-        setUniqueId(dynamic, 1);
-        MemberImpl staticOtherPort = member("localhost", 4001);
-        setUniqueId(staticOtherPort, 17);
-        chain.members = new Member[] { dynamic };
-        interceptor.addStaticMember(staticOtherPort);
+        discover(discovered);
 
         Member[] result = interceptor.getMembers();
-        Assert.assertEquals(2, result.length);
-        Assert.assertEquals(1, countOfPort(result, 4000));
-        Assert.assertEquals(1, countOfPort(result, 4001));
+        Assert.assertEquals(1, result.length);
+        Assert.assertSame(discovered, result[0]);
     }
 
     @Test
-    public void testGetMembersKeepsSamePortOnDifferentHosts() throws Exception 
{
-        StaticMembershipInterceptor interceptor = new 
StaticMembershipInterceptor();
-        DynamicMembershipChain chain = new DynamicMembershipChain();
-        interceptor.setNext(chain);
+    public void testSecurePortOnlyMismatchIsNotReplaced() throws Exception {
+        MemberImpl configured = member("localhost", 4001, 17);
+        MemberImpl discovered = discoveredCopy(configured, 49);
+        discovered.setSecurePort(4433);
+        interceptor.addStaticMember(configured);
+
+        discover(discovered);
+
+        Assert.assertEquals("A partial port match is a configuration error and 
is not merged", 2,
+                interceptor.getMembers().length);
+        Assert.assertTrue(interceptor.members.contains(configured));
+    }
+
+    @Test
+    public void testUdpPortOnlyMismatchIsNotReplaced() throws Exception {
+        MemberImpl configured = member("localhost", 4001, 17);
+        MemberImpl discovered = discoveredCopy(configured, 49);
+        discovered.setUdpPort(4500);
+        interceptor.addStaticMember(configured);
+
+        discover(discovered);
+
+        Assert.assertEquals(2, interceptor.getMembers().length);
+        Assert.assertTrue(interceptor.members.contains(configured));
+    }
 
-        MemberImpl dynamic = member("127.0.0.1", 4000);
-        setUniqueId(dynamic, 1);
-        MemberImpl staticOtherHost = member("127.0.0.2", 4000);
-        setUniqueId(staticOtherHost, 17);
-        chain.members = new Member[] { dynamic };
-        interceptor.addStaticMember(staticOtherHost);
+    @Test
+    public void testTcpPortOnlyMismatchIsNotReplaced() throws Exception {
+        MemberImpl configured = member("localhost", 4001, 17);
+        configured.setSecurePort(4433);
+        MemberImpl discovered = member("localhost", 4002, 49);
+        discovered.setSecurePort(4433);
+        interceptor.addStaticMember(configured);
+
+        discover(discovered);
+
+        Assert.assertEquals(2, interceptor.getMembers().length);
+        Assert.assertTrue(interceptor.members.contains(configured));
+    }
+
+    @Test
+    public void testDifferentTcpPortOnSameHostIsDifferentNode() throws 
Exception {
+        // Two nodes on one host that only differ in the TCP port, secure and 
UDP ports unused
+        MemberImpl configured = member("localhost", 4001, 17);
+        MemberImpl discovered = member("localhost", 4000, 1);
+        interceptor.addStaticMember(configured);
+
+        discover(discovered);
+
+        Assert.assertEquals(2, interceptor.getMembers().length);
+        Assert.assertTrue(interceptor.members.contains(configured));
+    }
+
+    @Test
+    public void testSamePortOnDifferentHostsIsDifferentNode() throws Exception 
{
+        MemberImpl configured = member("127.0.0.2", 4000, 17);
+        MemberImpl discovered = member("127.0.0.1", 4000, 1);
+        interceptor.addStaticMember(configured);
+
+        discover(discovered);
+
+        Assert.assertEquals("Two hosts may share a port", 2, 
interceptor.getMembers().length);
+        Assert.assertTrue(interceptor.members.contains(configured));
+    }
+
+    @Test
+    public void testConfiguredMemberRestoredWhenDiscoveredMemberDisappears() 
throws Exception {
+        MemberImpl configured = member("localhost", 4001, 17);
+        MemberImpl discovered = discoveredCopy(configured, 49);
+        interceptor.addStaticMember(configured);
+        discover(discovered);
+
+        chain.members = new Member[0];
+        interceptor.memberDisappeared(discovered);
 
         Member[] result = interceptor.getMembers();
-        Assert.assertEquals("Two hosts may share a port", 2, result.length);
+        Assert.assertEquals("The configured node stays a member", 1, 
result.length);
+        Assert.assertSame(configured, result[0]);
+        Assert.assertEquals("The disappearance must be forwarded", 1, 
recorder.disappeared.size());
+        Assert.assertSame(discovered, recorder.disappeared.get(0));
     }
 
     @Test
-    public void testGetMembersAllStatic() throws Exception {
-        StaticMembershipInterceptor interceptor = new 
StaticMembershipInterceptor();
-        DynamicMembershipChain chain = new DynamicMembershipChain();
-        interceptor.setNext(chain);
+    public void testRestartedNodeReplacesConfiguredMemberAgain() throws 
Exception {
+        MemberImpl configured = member("localhost", 4001, 17);
+        MemberImpl before = discoveredCopy(configured, 49);
+        MemberImpl after = discoveredCopy(configured, 65);
+        interceptor.addStaticMember(configured);
+        discover(before);
+
+        // The restarted node announces a new unique id before the old one is 
dropped
+        chain.members = new Member[] { before, after };
+        interceptor.memberAdded(after);
+        chain.members = new Member[] { after };
+        interceptor.memberDisappeared(before);
+
+        Member[] result = interceptor.getMembers();
+        Assert.assertEquals("The node must be returned exactly once", 1, 
result.length);
+        Assert.assertSame(after, result[0]);
+        Assert.assertTrue("The configured member must not be restored", 
interceptor.members.isEmpty());
+    }
 
-        MemberImpl first = member("localhost", 4000);
-        setUniqueId(first, 1);
-        MemberImpl second = member("localhost", 4001);
-        setUniqueId(second, 17);
+    @Test
+    public void testConfiguredMemberAddedAfterDiscoveryIsReplaced() throws 
Exception {
+        MemberImpl configured = member("localhost", 4001, 17);
+        MemberImpl discovered = discoveredCopy(configured, 49);
+        chain.members = new Member[] { discovered };
+
+        interceptor.addStaticMember(configured);
+
+        Member[] result = interceptor.getMembers();
+        Assert.assertEquals(1, result.length);
+        Assert.assertSame(discovered, result[0]);
+    }
+
+    @Test
+    public void testRemovedConfiguredMemberIsNotRestored() throws Exception {
+        MemberImpl configured = member("localhost", 4001, 17);
+        MemberImpl discovered = discoveredCopy(configured, 49);
+        interceptor.addStaticMember(configured);
+        discover(discovered);
+
+        interceptor.removeStaticMember(configured);
+        chain.members = new Member[0];
+        interceptor.memberDisappeared(discovered);
+
+        Assert.assertEquals(0, interceptor.getMembers().length);
+    }
+
+    @Test
+    public void testGetMembersAllStatic() throws Exception {
+        MemberImpl first = member("localhost", 4000, 1);
+        MemberImpl second = member("localhost", 4001, 17);
         interceptor.addStaticMember(first);
         interceptor.addStaticMember(second);
         interceptor.addStaticMember(first);
 
-        Member[] result = interceptor.getMembers();
-        Assert.assertEquals("Each static destination must be returned exactly 
once", 2, result.length);
-        Assert.assertEquals(1, countOfPort(result, 4000));
-        Assert.assertEquals(1, countOfPort(result, 4001));
+        Assert.assertEquals("Each static member must be returned exactly 
once", 2, interceptor.getMembers().length);
     }
 
     @Test
     public void testGetMembersCollidesSameDestinationTwiceStatic() throws 
Exception {
-        StaticMembershipInterceptor interceptor = new 
StaticMembershipInterceptor();
-        DynamicMembershipChain chain = new DynamicMembershipChain();
-        interceptor.setNext(chain);
-
-        // The same node configured twice with different unique ids: a
-        // destination misconfiguration, collapsed to one entry
-        MemberImpl first = member("localhost", 4000);
-        setUniqueId(first, 1);
-        MemberImpl second = member("localhost", 4000);
-        setUniqueId(second, 17);
+        // The same node configured twice with different unique ids is a 
configuration error, the later entry is
+        // ignored
+        StaticMember first = member("localhost", 4000, 1);
+        StaticMember second = member("localhost", 4000, 17);
         interceptor.addStaticMember(first);
         interceptor.addStaticMember(second);
 
         Member[] result = interceptor.getMembers();
         Assert.assertEquals("One destination must be returned once", 1, 
result.length);
+        Assert.assertSame("The first entry is kept", first, result[0]);
     }
 
     @Test
-    public void testGetMembersAllDynamic() throws Exception {
-        StaticMembershipInterceptor interceptor = new 
StaticMembershipInterceptor();
-        DynamicMembershipChain chain = new DynamicMembershipChain();
-        interceptor.setNext(chain);
+    public void testPartiallyMatchingStaticMembersAreKept() throws Exception {
+        // Reported as a configuration error, but not merged
+        StaticMember first = member("localhost", 4000, 1);
+        StaticMember second = member("localhost", 4000, 17);
+        second.setSecurePort(4433);
+        interceptor.addStaticMember(first);
+        interceptor.addStaticMember(second);
+
+        Assert.assertEquals(2, interceptor.getMembers().length);
+    }
 
-        MemberImpl first = member("localhost", 4000);
-        setUniqueId(first, 1);
-        MemberImpl second = member("localhost", 4001);
-        setUniqueId(second, 17);
-        chain.members = new Member[] { first, second };
+    @Test
+    public void testStaticMembersOnSameHostWithDifferentTcpPortsAreKept() 
throws Exception {
+        interceptor.addStaticMember(member("localhost", 4000, 1));
+        interceptor.addStaticMember(member("localhost", 4001, 17));
+
+        Assert.assertEquals(2, interceptor.getMembers().length);
+    }
+
+    @Test
+    public void testDuplicateOfReplacedStaticMemberIsIgnored() throws 
Exception {
+        StaticMember configured = member("localhost", 4001, 17);
+        StaticMember duplicate = member("localhost", 4001, 33);
+        MemberImpl discovered = discoveredCopy(configured, 49);
+        interceptor.addStaticMember(configured);
+        discover(discovered);
+
+        // The first entry is currently replaced by the discovered member, the 
duplicate must not come back
+        interceptor.addStaticMember(duplicate);
 
         Member[] result = interceptor.getMembers();
-        Assert.assertEquals(2, result.length);
-        Assert.assertEquals(1, countOfPort(result, 4000));
-        Assert.assertEquals(1, countOfPort(result, 4001));
+        Assert.assertEquals(1, result.length);
+        Assert.assertSame(discovered, result[0]);
+        Assert.assertTrue(interceptor.members.isEmpty());
+
+        chain.members = new Member[0];
+        interceptor.memberDisappeared(discovered);
+        result = interceptor.getMembers();
+        Assert.assertEquals(1, result.length);
+        Assert.assertSame("The first entry is restored", configured, 
result[0]);
+    }
+
+    @Test
+    public void testGetMembersAllDynamic() throws Exception {
+        MemberImpl first = member("localhost", 4000, 1);
+        MemberImpl second = member("localhost", 4001, 17);
+        discover(first, second);
+
+        Assert.assertEquals(2, interceptor.getMembers().length);
+        Assert.assertEquals(2, recorder.added.size());
+    }
+
+    @Test
+    public void testGetMembersMergesStaticAndDynamic() throws Exception {
+        MemberImpl staticOnly = member("localhost", 4000, 1);
+        MemberImpl dynamicOnly = member("localhost", 4002, 33);
+        interceptor.addStaticMember(staticOnly);
+
+        discover(dynamicOnly);
+
+        Assert.assertEquals(2, interceptor.getMembers().length);
     }
 }
diff --git 
a/test/org/apache/catalina/tribes/group/interceptors/TestTcpFailureDetectorLivenessChecks.java
 
b/test/org/apache/catalina/tribes/group/interceptors/TestTcpFailureDetectorLivenessChecks.java
index 03ca67b2e9..e09322367d 100644
--- 
a/test/org/apache/catalina/tribes/group/interceptors/TestTcpFailureDetectorLivenessChecks.java
+++ 
b/test/org/apache/catalina/tribes/group/interceptors/TestTcpFailureDetectorLivenessChecks.java
@@ -505,4 +505,69 @@ public class TestTcpFailureDetectorLivenessChecks {
                 detector.getMember(member));
         Assert.assertEquals("Only the forced check may have reported the 
member", 1, recorder.added.size());
     }
+
+    @Test
+    public void testForgetMemberIsNotAddedAgainByHeartbeat() throws Exception {
+        FakeMembershipChain chain = new FakeMembershipChain();
+        RecordingInterceptor recorder = new RecordingInterceptor();
+        CheckRecordingDetector detector = createDetector(chain, recorder);
+        StaticMember member = new StaticMember("localhost", 4000, -1);
+        detector.setAlive(true);
+        detector.memberAdded(member);
+        Assert.assertNotNull(detector.getMember(member));
+
+        detector.forgetMember(member);
+
+        Assert.assertNull("The member must be removed", 
detector.getMember(member));
+        Assert.assertEquals("The disappearance must be forwarded once", 1, 
recorder.disappeared.size());
+        Assert.assertTrue("The member must not become a suspect", 
detector.addSuspects.isEmpty());
+        Assert.assertTrue(detector.removeSuspects.isEmpty());
+        detector.checkMembers(false);
+        Assert.assertNull("The heartbeat must not add the member again", 
detector.getMember(member));
+        Assert.assertEquals(1, recorder.added.size());
+    }
+
+    @Test
+    public void testForgetMemberClearsSuspectThatWasNeverAdded() throws 
Exception {
+        FakeMembershipChain chain = new FakeMembershipChain();
+        RecordingInterceptor recorder = new RecordingInterceptor();
+        CheckRecordingDetector detector = createDetector(chain, recorder);
+        StaticMember member = new StaticMember("localhost", 4000, -1);
+        detector.setAlive(false);
+        detector.memberAdded(member);
+        Assert.assertTrue(detector.addSuspects.containsKey(member));
+
+        detector.forgetMember(member);
+
+        Assert.assertTrue("The suspect must be forgotten", 
detector.addSuspects.isEmpty());
+        Assert.assertTrue("A member the detector never accepted is not 
reported", recorder.disappeared.isEmpty());
+        detector.setAlive(true);
+        detector.checkMembers(false);
+        Assert.assertNull("The heartbeat must not add the member", 
detector.getMember(member));
+        Assert.assertTrue(recorder.added.isEmpty());
+    }
+
+    @Test
+    public void testForgetMemberSupersedesRunningAddCheck() throws Exception {
+        FakeMembershipChain chain = new FakeMembershipChain();
+        RecordingInterceptor recorder = new RecordingInterceptor();
+        GatedAddDetector detector = new GatedAddDetector();
+        detector.setChannel(new GroupChannel());
+        detector.setNext(chain);
+        detector.setPrevious(recorder);
+        chain.localMember = new StaticMember("localhost", 5000, -1);
+        StaticMember member = new StaticMember("localhost", GATED_PORT, -1);
+        Thread addThread = new Thread(() -> detector.memberAdded(member));
+        addThread.start();
+        Assert.assertTrue("Liveness check did not start",
+                detector.checkStarted.await(10, TimeUnit.SECONDS));
+
+        detector.forgetMember(member);
+        detector.releaseCheck.countDown();
+        addThread.join(10000);
+
+        Assert.assertFalse("Member add thread did not finish", 
addThread.isAlive());
+        Assert.assertNull("A forgotten member must not be added by a running 
check", detector.getMember(member));
+        Assert.assertTrue(recorder.added.isEmpty());
+    }
 }
diff --git a/webapps/docs/changelog.xml b/webapps/docs/changelog.xml
index 3b2deec1dc..be07fb5285 100644
--- a/webapps/docs/changelog.xml
+++ b/webapps/docs/changelog.xml
@@ -579,6 +579,26 @@
         with membership listeners that call back into the
         interceptor. (remm)
       </fix>
+      <fix>
+        When a node that is configured with the
+        <code>StaticMembershipInterceptor</code> is also discovered
+        dynamically, the <code>StaticMembershipInterceptor</code> now
+        replaces the statically configured member with the dynamically
+        discovered member if the host and all of the ports (TCP, secure
+        and UDP) match. Previously, the node was a member twice because
+        the unique id of a dynamically discovered member is random and
+        can not match the statically configured unique id, and every
+        message sent to all members was sent to the node twice. If only
+        some of the ports match, the mismatch is logged as a
+        configuration error and the members are not merged. The
+        failure detector is told to forget the statically configured
+        member before the discovered member is announced, so that it is
+        not added again by the member checks. If the dynamically
+        discovered member disappears, the statically configured member
+        is restored. A second statically configured member with the
+        same host and ports as an earlier one is logged as a
+        configuration error and ignored. (markt)
+      </fix>
       <fix>
         The <code>TwoPhaseCommitInterceptor</code> now forwards the
         interceptor payload, and with it the <code>ErrorHandler</code>


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

Reply via email to