Gil Ganz created CASSANDRA-21636:
------------------------------------
Summary: Node can remain DOWN forever after a single lost ECHO:
inflightEcho entry is never cleared on failure (regression introduced by
CASSANDRA-18866)
Key: CASSANDRA-21636
URL: https://issues.apache.org/jira/browse/CASSANDRA-21636
Project: Apache Cassandra
Issue Type: Bug
Components: Cluster/Gossip
Reporter: Gil Ganz
```
h2. Summary
Since CASSANDRA-18866 ("Node does not send multiple inflight echos", fix
versions 4.0.20 / 4.1.11 / 5.0.7 / 6.0), gossip no longer heals after a
transient network problem. A single failed ECHO_REQ/ECHO_RSP permanently
prevents an observer from ever marking a peer UP again: the
{{Gossiper.inflightEcho}} entry added by that change is removed only on echo
*success*, so after a failure every subsequent {{markAlive}} trigger is
silently skipped ("Skipping ECHO_REQ to {} since it is already inflight"). The
implicit retry-on-next-heartbeat loop that has healed lost echoes since
CASSANDRA-6590 (2014) still executes, but the stale entry turns it into a no-op.
The practical effect for us: on 4.1.5, when a packet-loss event ended, every
node saw every other node UP again within seconds. On 5.0.8, the same event
leaves nodes DOWN indefinitely with no self-recovery - repeatedly, in
day-to-day operation, we see up to half the cluster reporting dozens of nodes
DN across several datacenters after loss clears, and it takes multiple rounds
of {{nodetool disablegossip}} / {{enablegossip}} on many nodes to clear.
Without that manual intervention the phantom-DOWN state persists.
Any event that can lose an echo now wedges permanently, so we hit the same end
state from two everyday operations: transient packet loss, and *multiple node
restarts within a short window* (a rolling upgrade, but equally any routine
batch of restarts, or a node restarted a few times in a row) - the restarted
nodes' GossipStage backlogs drop echoes in both directions while the cluster is
churning.
h2. Environment
Seen on *multiple* production clusters, all upgraded 4.1.5 -> 5.0.8, all with
similar topology:
* 7 DCs spread around the world, dozens of nodes per DC (largest cluster
analyzed in depth: 251 nodes), inter-DC links subject to occasional packet loss.
* {{num_tokens}} ranging from 16 to 128 depending on DC/cluster;
GossipingPropertyFileSnitch; NetworkTopologyStrategy keyspaces replicated
across all DCs.
* {{internode_encryption: none}}, {{internode_timeout: true}},
{{request_timeout: 10000ms}} (default), storage_compatibility_mode CASSANDRA_4.
* No comparable incidents on the same clusters/topologies/procedures while on
4.1.5.
h2. Symptom
Two operational triggers, same wedge:
*(1) Transient packet loss / partial connectivity loss (the dominant one in
steady-state operation).* When a loss event ends, affected pairs never return
to UP on their own. Views are pairwise-asymmetric and often mutual: A sees B
down while C sees B fine, and B simultaneously sees A down. Recovery requires
{{disablegossip}}/{{enablegossip}} on the DN targets, frequently several rounds
across many nodes (see "Why multiple rounds are needed" below), and the set of
DN nodes spans multiple DCs.
*(2) Multiple node restarts within a short window* - a rolling upgrade or
restart, a batch/rack wave, or simply the same node bounced a few times in a
row. Here the echo loss is local rather than network: ECHO_REQ/ECHO_RSP are
handled on the single-threaded GossipStage with a {{request_timeout}} (10s)
expiry, and a restarting node's GossipStage is backlogged applying every peer's
state (251 endpoints x ~27,000 vnode tokens x 7 DCs x 9 keyspaces), so inbound
echoes expire in the stage queue and are dropped before {{EchoVerbHandler}}
runs.
A single restart on an otherwise quiet cluster is usually asymmetric and
survivable: observers fire their one echo into the peak of the restarted node's
backlog and lose it, while the restarted node's own echoes land on idle peers
and succeed - so the restarted node sees everyone UP and only the observers
wedge (which is why the restarted node's own {{nodetool status}} must never be
trusted as the check). Once several restarts overlap in time, both directions
are sending echoes into busy GossipStages and both directions wedge, so a
restarted node also ends up seeing dozens of peers DN. Restarting again does
not recover it: each new generation buys exactly one fresh echo per observer,
and on a still-churning cluster that one attempt is likely lost too, so
repeated restarts in a window tend to accumulate wedged pairs rather than clear
them. During our 4.1.5 -> 5.0.8 roll of the 251-node cluster, 12 nodes ended up
phantom-DN cluster-wide (one of them itself seeing 33 phantom-DN peers),
concentrated where restart parallelism was highest, with seeds over-represented.
In both cases, while a pair is wedged:
* Cassandra is fully up and serving on the "down" node;
* all four internode channels are established in both directions
({{successfully connected ... encryption = unencrypted}}, messaging version 12);
* gossip state keeps flowing: current generation, advancing heartbeat, schema
in agreement, on the very observers showing DN (gossip digests use
{{longTimeout}} = max(request_timeout, 5 min), so state survives where the 10s
echo does not);
* no {{is now DOWN}} is logged at the moment the node becomes
unreachable-forever; observers may log {{Node /X has restarted, now UP}}
(printed by {{handleMajorStateChange}} *before* the echo round-trip) and then
keep the node in UNREACHABLE.
Network causes were excluded for the persistent state (TCP connects both ways,
no TLS, PMTUD healthy, clocks in sync) - i.e. connectivity is healthy again and
the DN state still does not clear. TRACE logging on a wedged observer shows the
smoking gun repeating for the peer:
{code}
TRACE ... Gossiper.java - Skipping ECHO_REQ to /10.x.x.x:7000 since it is
already inflight
{code}
h2. Root cause (5.0.8 source)
{{Gossiper.markAlive}} (Gossiper.java:1435):
{code:java}
private void markAlive(final InetAddressAndPort addr, final EndpointState
localState)
{
localState.markDead();
EndpointState prevState = inflightEcho.put(addr, localState);
boolean sendEcho = !localState.equals(prevState);
if (sendEcho)
{
Message<NoPayload> echoMessage = Message.out(ECHO_REQ, noPayload);
RequestCallback echoHandler = msg ->
{
runInGossipStageBlocking(() -> {
EndpointState epState = inflightEcho.remove(addr);
if (epState != null)
realMarkAlive(addr, epState);
});
};
MessagingService.instance().sendWithCallback(echoMessage, addr,
echoHandler);
}
else
logger.trace("Skipping ECHO_REQ to {} since it is already inflight",
addr);
}
{code}
# The callback implements only the response case; {{invokeOnFailure()}}
defaults to false, so a failed or expired echo leaves the {{inflightEcho}}
entry in place forever.
# {{EndpointState}} does not override {{equals}}, and {{localState}} is the
*same* {{EndpointState}} object for the endpoint's whole generation (the
{{localEpStatePtr}} held in {{endpointStateMap}}). So every later {{markAlive}}
for that generation compares the object against itself, dedups, and skips. The
CASSANDRA-6590 heal loop is disabled.
# Nothing clears the entry once the peer is already DOWN. {{markDead ->
silentlyMarkDead}} does remove it (Gossiper.java:1506), but it is unreachable
in this state: {{convict()}} early-returns when the endpoint is not alive
(Gossiper.java:612), and {{markAlive}} itself starts with
{{localState.markDead()}}. {{clearUnsafe()}} is test-only.
# The only escape in normal operation is a *generation change* on the target,
which replaces the {{EndpointState}} object via {{handleMajorStateChange}} and
therefore breaks the identity dedup. That is exactly why restarting the target,
or {{disablegossip}}/{{enablegossip}} ({{forceNewerGeneration()}}), fixes it -
and why it grants only *one* fresh echo attempt per observer.
h3. How a packet-loss event arms the trap
# Loss starts. The failure detector convicts the peer -> {{markDead ->
silentlyMarkDead}} clears any inflight entry. Peer is DN; nothing stuck yet.
# Loss continues. The observer still learns newer heartbeats for that peer
*indirectly*, relayed by a third node whose path is intact (very likely with 7
DCs and partial loss). {{applyStateLocally}} then hits Gossiper.java:1751-1752:
{{if (!localEpStatePtr.isAlive() && !isDeadState(localEpStatePtr))
markAlive(ep, localEpStatePtr);}} -> one ECHO_REQ is sent into the lossy path
and fails -> {{inflightEcho}} entry now stuck, with no generation change to
ever clear it.
# Loss ends. Heartbeats resume directly, {{markAlive}} is called ~1/s, and
every call is skipped. The pair is DN forever. On 4.1.5 step 3 healed within
~1s because each heartbeat sent a fresh echo.
h3. Why multiple rounds of disablegossip/enablegossip are needed
{{disablegossip}} broadcasts shutdown -> observers run {{markAsShutdown ->
markDead -> silentlyMarkDead}}, clearing the stuck entry; {{enablegossip}}
calls {{forceNewerGeneration()}}, so every observer sends one fresh echo. If
any of those echoes fails (residual loss, or a busy GossipStage on a cluster
still churning), that observer is wedged again for the new generation - one
shot per generation, no retry. With dozens of wedged pairs across 7 DCs,
operators end up cycling the union of DN targets repeatedly until every pair
happens to win its single race.
h3. On CASSANDRA-18866's reasoning
That ticket's description states "there is no retry logic for failed ECHO
requests, but this is the case even both before and after". That is true only
for *explicit* retries: before 18866, the per-heartbeat re-send of ECHO in
{{markAlive}} was an implicit retry loop (the CASSANDRA-6590 mechanism). The
dedup removed it without replacement, converting every transient echo loss into
a permanent phantom-DOWN.
h2. Reproduction
*Minimal (network variant, 3 nodes, no scale needed):*
# 3-node cluster (A, B, C) on 5.0.7+ / 4.1.11+ / 4.0.20+.
# Drop internode traffic between A and B only, both directions ({{iptables -A
INPUT -s B -p tcp --dport 7000 -j DROP}} on A and symmetrically on B), leaving
both able to gossip with C. Hold it well past FD conviction plus
{{request_timeout}} (~60s): A and B mark each other DOWN, then each re-attempts
{{markAlive}} on the peer's C-relayed heartbeat and loses its single echo.
# Remove the rules. Connections re-establish, digests and heartbeats flow,
{{nodetool gossipinfo}} shows a fresh generation and advancing heartbeat for
the peer - and {{nodetool status}} keeps showing DN on both sides indefinitely.
TRACE on Gossiper prints {{Skipping ECHO_REQ ... already inflight}} once per
heartbeat.
# Same test on 4.1.5 (or any pre-18866 build): both nodes are UP within ~1s of
the rules being removed.
*Restart variant (needs scale, or an artificially stalled GossipStage):*
restart a node on a cluster where the GossipStage drain exceeds
{{request_timeout}} (100+ nodes with 128 vnodes and several NTS keyspaces
suffices). Observers send their single ECHO_REQ as soon as they learn the new
generation, i.e. into the peak of the restarted node's backlog; it expires
in-queue and is dropped. Those observers show the node DN forever. Restarting
several nodes within the same window (rack waves, a rolling upgrade, or one
node bounced repeatedly) wedges both directions at once and compounds:
probability scales with cluster size - each restart is N-1 independent echo
races, so even 2% per-race loss gives ~99% chance of at least one wedge per
restart at N=251, and a fleet-wide roll is ~N^2 races (~62k).
h2. Workarounds (validated in production)
* {{nodetool disablegossip && sleep 15 && nodetool enablegossip}} on the
phantom-DN *target*, repeated over the union of nodes seen DN from any vantage
point, until clean - see "Why multiple rounds are needed".
* Raising the echo budget live ({{nodetool settimeout misc 30000}}) makes
losses, and therefore new wedges, much rarer for the GossipStage-backlog
variant. It is a probability lever only: it cannot unwedge existing pairs, and
any loss longer than the budget - which is what a packet-loss event is - still
wedges permanently.
h2. Proposed fix
Keep the 18866 dedup (one echo in flight per peer) but clear the entry on
failure, so the existing heartbeat-driven path retries - restoring the
CASSANDRA-6590 guarantee:
{code:java}
RequestCallback<NoPayload> echoHandler = new RequestCallback<NoPayload>()
{
@Override
public void onResponse(Message<NoPayload> msg)
{
runInGossipStageBlocking(() -> {
EndpointState epState = inflightEcho.remove(addr);
if (epState != null)
realMarkAlive(addr, epState);
});
}
@Override
public boolean invokeOnFailure()
{
return true;
}
@Override
public void onFailure(InetAddressAndPort from, RequestFailureReason
failureReason)
{
// Unblock the retry that fires on the next received heartbeat for this
endpoint.
// Two-arg remove so a newer inflight entry (new EndpointState after a
// generation change) is not clobbered.
runInGossipStageBlocking(() -> inflightEcho.remove(addr, localState));
logger.trace("ECHO_REQ to {} failed ({}); will retry on next state
update", addr, failureReason);
}
};
{code}
This bounds echo traffic to at most one in flight per peer (preserving the
18543/18866 goal) while guaranteeing eventual UP-marking once connectivity
allows a round-trip within the timeout.
h2. Related
* CASSANDRA-18866 - introduced the inflightEcho dedup (4.0.20 / 4.1.11 / 5.0.7
/ 6.0)
* CASSANDRA-18845 / CASSANDRA-18854 - earlier attempt and rollback
* CASSANDRA-18543 - echo flooding / slow UP-marking that motivated the dedup
* CASSANDRA-6590 - original 2014 fix for "gossip does not heal after partition
at startup"; its heal mechanism is what the stuck entry disables
* CASSANDRA-9218 - GC-pause echo loss (another loss source that is now
permanent)
```
--
This message was sent by Atlassian Jira
(v8.20.10#820010)
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]