Siyao Meng created HDDS-17519:
---------------------------------

             Summary: After a long SCM pause the node health checker marks live 
datanodes STALE or DEAD, as it skips only one run and misses some pauses
                 Key: HDDS-17519
                 URL: https://issues.apache.org/jira/browse/HDDS-17519
             Project: Apache Ozone
          Issue Type: Bug
            Reporter: Siyao Meng
         Attachments: MC-4-hold-off-node-health-checks-after-scm-pause.patch, 
TestBugMC3PauseInsideHealthCheck.java, TestBugMC4PauseHoldOff.java

h3. Mechanism
HDDS-1454 added {{NodeStateManager.shouldSkipCheck}} so that an SCM pause (GC, 
VM suspend, live migration, SIGSTOP) does not mark every datanode stale and 
close all pipelines. {{NodeStateManager.run}} skips the health check when the 
time since the previous run is at least {{min(stale, dead)}} interval. Three 
gaps remain:
# *One skipped run is too short.* After a detected pause the next run comes one 
checker interval later ({{ozone.scm.heartbeat.thread.interval}}, 3s), while 
datanodes heartbeat every {{hdds.heartbeat.interval}} (30s). A datanode whose 
last heartbeat before the pause is older than the stale interval and whose next 
heartbeat is processed more than 3s after resume is marked STALE by that run, 
and DEAD by the next one if the pause was at least the dead interval. A 
heartbeat sent during the pause can be lost: the datanode's call fails after 
{{ozone.scm.heartbeat.rpc-timeout}} (5s), the proxy's retry policy only covers 
connection setup, and the datanode retries at its next heartbeat.
# *Pauses just under the stale interval are not detected.* A datanode can be up 
to one heartbeat interval into its cycle when SCM pauses, so a pause between 
the stale interval minus one heartbeat interval and the stale interval (4.5 to 
5 min by default) makes it look stale, but is not skipped. The checker task was 
due during the pause, so it runs at the moment SCM resumes.
# *The pause check and the deadlines read the clock separately.* 
{{shouldSkipCheck}} reads the clock, then {{checkNodesHealth}} reads it again 
for the stale and dead deadlines. A pause between the two readings is not 
detected and the deadlines are taken from after the pause. 
{{scheduleNextHealthCheck}} has a similar window: a pause after the next run is 
scheduled but before {{lastHealthCheck}} is read starts the next run at once 
with a fresh {{lastHealthCheck}}. These windows are a few statements wide, so 
this case is very unlikely in practice.

h3. Trigger
An SCM process that stops for at least the stale interval minus one heartbeat 
interval (4.5 min by default), with datanodes running. Gap 1 also needs a 
datanode whose first heartbeat after resume is processed more than one checker 
interval after resume. By reading the code (not run on a real transport):
* With Kerberos, the SASL handshake cannot complete while SCM is stopped, so 
nothing is queued at SCM. The datanode retries the handshake 
({{ipc.client.connect.max.retries.on.sasl}}, 5) with a random backoff of up to 
5s, so its first heartbeat after resume can come more than 3s later.
* With default SIMPLE authentication a heartbeat written to an accepted 
connection stays in SCM's socket buffer and is processed after resume, with 
SCM's own clock, so it counts as fresh. Every heartbeat opens a new connection, 
because {{ipc.client.connection.maxidletime}} (10s) is shorter than the 
heartbeat interval and a timed out call also closes its connection, so each 
datanode adds about one entry per heartbeat interval to SCM's accept queue 
while SCM is stopped. That queue holds the smaller of 
{{ipc.server.listen.queue.size}} (256 by default) and the kernel's 
{{net.core.somaxconn}}. Gap 1 is therefore masked for clusters with fewer 
datanodes than that, and otherwise only the datanodes whose first connection 
during the pause did not fit are exposed; they reconnect on the TCP and IPC 
connect retry schedule.
* Gap 2 does not depend on the transport: the run at resume races the 
processing of the queued heartbeats. It is the main route with SIMPLE 
authentication.

h3. Impact
A STALE transition makes {{StaleNodeHandler}} close every pipeline of the 
datanode. A DEAD transition makes {{DeadNodeHandler}} close its containers, 
destroy its pipelines, remove its replicas from SCM's container map, notify 
ReplicationManager and clear its command queue. The datanode returns to HEALTHY 
with its next heartbeat, but none of this is undone. In a cluster wide pause 
this hits many datanodes at once, which is the outcome HDDS-1454 set out to 
prevent. The DEAD outcome needs a pause of at least the dead interval and more 
than two checker intervals (6s) of silence after resume, which was shown only 
with heartbeats withheld, not on a real transport. With SIMPLE authentication 
the reachable outcome is mainly STALE.

h3. Reproduction
Two self contained classes for {{hadoop-hdds/server-scm}} (target path and 
Maven command in each class comment). They run a real SCM from 
{{HddsTestUtils.getScm}} with its event queue and the real {{StaleNodeHandler}} 
and {{DeadNodeHandler}}. Both pass on unmodified source, which means the defect 
is present.
* [^TestBugMC4PauseHoldOff.java] uses the existing 
{{SCMNodeManager.pauseHealthCheck}} / {{unpauseHealthCheck}} hook with timing 
scaled to the default ratios (checker 100ms, heartbeat 1s, stale 3s, dead 6s). 
A heartbeat lost during the pause is modelled by not calling 
{{processHeartbeat}}. After a 6.5s pause the check is skipped once, and the 
datanode is DEAD when it heartbeats 611ms after resume; its only replica of a 
CLOSED container is removed (gap 1). After a 2.5s pause that starts 500ms into 
the datanode's heartbeat interval nothing is skipped, and the datanode is STALE 
when it heartbeats 606ms after resume (gap 2). A control with the heartbeat 
right after the skipped run keeps the datanode HEALTHY and the replica.
* [^TestBugMC3PauseInsideHealthCheck.java] models a pause between the two clock 
readings by mocking {{Time.monotonicNow}} on the test thread, so that the 
second reading in one {{run}} is later than the first by more than the stale 
interval, and calls {{run}} on three datanodes that have just heartbeated. No 
check is skipped, all three become STALE and all of their pipelines (five) are 
CLOSED (gap 3). The same jump before the run is skipped and changes nothing.

h3. Patch
[^MC-4-hold-off-node-health-checks-after-scm-pause.patch], against 
6752db7978761a749a7d42a72a85311818e3d870. It also applies to master at 
9118d5a85a3 and on top of the open apache/ozone#11688. It conflicts textually 
with the draft apache/ozone#11349 (HDDS-16627), which replaces the 
{{Time.monotonicNow}} calls in the same methods with an injected clock, so 
whichever lands second needs a small rebase.

{{NodeStateManager.run}} reads the clock once and uses that reading for the 
pause check, for the deadlines and as {{lastHealthCheck}}, so a pause anywhere 
in a run is part of the gap the next run sees (gap 3). A gap of at least the 
stale interval minus one heartbeat interval counts as a pause (gap 2). After a 
pause, health checks are skipped for one heartbeat interval plus the heartbeat 
RPC timeout (35s by default) instead of for one run, so every live datanode has 
had a heartbeat cycle to report to SCM (gap 1). There is no new configuration 
and no protocol change.

Recon runs the same checker, but datanodes heartbeat Recon every 
{{hdds.heartbeat.recon.interval}} (60s by default), while the patch takes SCM's 
heartbeat interval. For Recon the change therefore narrows gaps 1 and 2 without 
closing them; the harm there is limited to Recon's own view. Passing the 
expected heartbeat interval from the node manager to {{NodeStateManager}} is 
the follow up.

What changes for a cluster that never pauses: nothing, as its gaps stay near 
the checker interval. After a pause, a datanode that really died is marked 
STALE about 35s after resume instead of 3s, and, if the pause was at least the 
dead interval, DEAD one checker interval later; otherwise DEAD comes at its 
normal time. During the hold off no transition happens at all, so a recovered 
datanode also returns to HEALTHY up to 35s later. A single run that itself 
takes 4.5 min would now also count as a pause. The INFO line is logged once per 
detected pause with the gap and the hold off, instead of "Skipping heartbeat 
checks for one iteration." per skipped run.

Covered by {{TestSCMNodeManager.testScmHandleJvmPause}}, now parameterized with 
a pause just under and at the stale interval and a heartbeat interval longer 
than the checker interval: half a heartbeat interval after resume both 
datanodes must still be HEALTHY, and the datanode that does not heartbeat must 
still become STALE. And by 
{{TestNodeStateManager.testPauseDuringHealthCheckRunDoesNotMarkNodesStale}} for 
gap 3. Without the change they fail with "We did not skip any heartbeat 
checks", "expected: <2> but was: <0>" and "expected: <HEALTHY> but was: 
<STALE>". With it the SCM node package tests pass (177 tests, including 
{{node.states}}) and checkstyle is clean. With the patch the gap 1 and gap 2 
cases of [^TestBugMC4PauseHoldOff.java] and the inside run case of 
[^TestBugMC3PauseInsideHealthCheck.java] fail, because the datanodes stay 
HEALTHY; the control cases still pass.

Found by TLA+ model checking and code review of the SCM datanode health checker 
and node state paths under HDDS-15926, on commit 
6752db7978761a749a7d42a72a85311818e3d870. Checked against HDDS issues and 
apache/ozone pull requests for duplicates before filing; the only related issue 
is HDDS-1454, which introduced the skip. The attached patch is a proposal for 
review. Generated with Specula (Claude Opus 5.5).



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

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

Reply via email to