[ 
https://issues.apache.org/jira/browse/NIFI-16536?page=com.atlassian.jira.plugin.system.issuetabpanels:all-tabpanel
 ]

Bryan Bende updated NIFI-16536:
-------------------------------
    Status: Patch Available  (was: Open)

> Decommission leaves node stranded outside the cluster when it is the only 
> connected node
> ----------------------------------------------------------------------------------------
>
>                 Key: NIFI-16536
>                 URL: https://issues.apache.org/jira/browse/NIFI-16536
>             Project: Apache NiFi
>          Issue Type: Bug
>            Reporter: Bryan Bende
>            Assignee: Bryan Bende
>            Priority: Major
>          Time Spent: 10m
>  Remaining Estimate: 0h
>
> h3. Summary
> When a node is decommissioned (for example via \{{nifi.sh decommission}}, 
> which sends DELETE \{{/health/cluster}} to the management server) while it is 
> the only node in the CONNECTED state, \{{ClusterDecommissionTask}} fails with 
> \{{IllegalNodeDisconnectionException}} after it has already stopped 
> heartbeating and marked the FlowController as not clustered. Nothing rolls 
> those changes back, so the node is left outside the cluster: it never sends a 
> connection request again, it never offloads, and it does not rejoin after 
> other nodes reconnect. Only a restart of the node recovers it.
> This is easy to hit in orchestrated environments (Kubernetes scale-down, 
> rolling restarts): the decommission of one node coincides with another node 
> restarting, so the node being decommissioned is briefly the only connected 
> node.
> Steps to reproduce
> # Start a two-node cluster (node A and node B).
> # Stop or restart node A so that node B is the only CONNECTED node.
> # On node B, run \{{bin/nifi.sh decommission --shutdown=false}}.
> # Start node A again and let it connect.
> *Expected:* the decommission is refused or rolled back, and node B remains 
> (or returns to being) a connected cluster member.
> *Actual:* node B logs the exception below, logs "Cluster State changed from 
> Clustered to Not Clustered", stops heartbeating, and stays out of the cluster 
> indefinitely. After node A connects, node B only keeps logging that the 
> Cluster Coordinator is located at node A; it never reconnects, offloads, or 
> is removed. Its \{{/health/cluster}} status remains non-CONNECTED, so it also 
> stays not ready for any readiness probe built on that endpoint.
> {code}
> ERROR org.apache.nifi.runtime.StandardUncaughtExceptionHandler Uncaught 
> Exception in Thread [DecommissionTask]
> org.apache.nifi.cluster.manager.exception.IllegalNodeDisconnectionException: 
> Cannot disconnect node <node-b>:8443 because it is the only node currently 
> connected
>     at 
> org.apache.nifi.cluster.coordination.node.NodeClusterCoordinator.requestNodeDisconnect(NodeClusterCoordinator.java:577)
>     at 
> org.apache.nifi.cluster.lifecycle.ClusterDecommissionTask.disconnectNode(ClusterDecommissionTask.java:102)
>     at 
> org.apache.nifi.cluster.lifecycle.ClusterDecommissionTask.decommission(ClusterDecommissionTask.java:75)
>     at 
> org.apache.nifi.runtime.HealthClusterHttpHandler.lambda$startDecommission$0(HealthClusterHttpHandler.java:92)
> {code}
> h3. Cause
> * \{{ClusterDecommissionTask.decommission()}} calls 
> \{{flowController.stopHeartbeating()}} and 
> \{{flowController.setClustered(false, null)}} before \{{disconnectNode()}}.
> * \{{NodeClusterCoordinator.requestNodeDisconnect()}} throws 
> \{{IllegalNodeDisconnectionException}} *synchronously* when the target is the 
> only CONNECTED node. The retry loop in \{{disconnectNode()}} only catches 
> \{{ExecutionException}} from \{{future.get()}}, so this exception escapes the 
> loop and ends the task.
> * \{{HealthClusterHttpHandler.startDecommission()}} runs the task on a 
> virtual thread that only handles \{{InterruptedException}}, so the failure is 
> only logged by the uncaught exception handler. The heartbeat and clustered 
> state are never restored.
> * \{{offloadNode()}} has the same pattern: \{{requestNodeOffload()}} can 
> throw \{{IllegalNodeOffloadException}} synchronously, and that exception 
> would also escape the loop.
> h3. Proposed changes
> # *Fail fast:* before stopping heartbeats, check whether the local node is 
> the only CONNECTED node 
> (\{{clusterCoordinator.getNodeIdentifiers(NodeConnectionState.CONNECTED)}}). 
> If it is, log a clear warning and abort the decommission without changing any 
> local state.
> # *Roll back on failure:* catch \{{RuntimeException}} from 
> \{{requestNodeDisconnect()}} and \{{requestNodeOffload()}}. For non-retryable 
> failures (\{{IllegalNodeDisconnectionException}}, 
> \{{IllegalNodeOffloadException}}), restore the clustered state and 
> heartbeating (or request reconnection for the local node) so the node stays a 
> healthy cluster member instead of being stranded. The check in (1) narrows 
> the window but cannot close it, so (2) is still needed.
> # *Single decommission at a time:* each DELETE on \{{/health/cluster}} starts 
> a new virtual thread. Because \{{decommission()}} is synchronized, repeated 
> requests queue up and run again after the first one finishes. A request while 
> a decommission is already running should return 202 (or 409) without starting 
> another task.
> # *Report the outcome:* GET \{{/health/cluster}} only reports the connection 
> state, so a caller that starts a decommission cannot tell that it failed and 
> will poll indefinitely. Adding the decommission outcome to the response, for 
> example a second line \{{Decommission Status: IN_PROGRESS | FAILED: <reason> 
> | COMPLETED}}, lets tools that drive decommission through \{{nifi.sh 
> decommission}} and \{{nifi.sh cluster-status}} react to a failure. The 
> existing \{{Cluster Status:}} line stays unchanged for compatibility.
> Items 1 and 2 fix the bug. Items 3 and 4 are related hardening and could be 
> split into a separate improvement.
> h3. Testing
> There are currently no unit tests for \{{ClusterDecommissionTask}} or 
> \{{HealthClusterHttpHandler}}. A new \{{ClusterDecommissionTaskTest}} with a 
> mocked \{{ClusterCoordinator}} and \{{FlowController}} could cover these 
> cases:
> * the only-connected-node case aborts without calling \{{stopHeartbeating()}};
> * a synchronous \{{IllegalNodeDisconnectionException}} restores the clustered 
> state and heartbeating;
> * the normal path still disconnects, offloads, and removes the node.
> {code}
> {code}



--
This message was sent by Atlassian Jira
(v10.3.26#10030026)

Reply via email to