Siyao Meng created HDDS-17268:
---------------------------------
Summary: Datanode ignores the heartbeat SCM term before term init
and updates it without the lock, so a stale SCM leader's command can run
Key: HDDS-17268
URL: https://issues.apache.org/jira/browse/HDDS-17268
Project: Apache Ozone
Issue Type: Bug
Reporter: Siyao Meng
h3. Mechanism
The datanode fences commands of a stale SCM leader with
{{StateContext.termOfLeaderSCM}} (HDDS-3988): {{getNextCommand}} drops a
command whose term is lower than the latest term seen. HDDS-7621 added the term
to heartbeat responses so that the datanode also follows a new leader that
sends no commands. Two defects in {{StateContext.updateTermOfLeaderSCM(long)}},
the method that applies the heartbeat term, weaken that fence.
* *Heartbeat term ignored before init.* The method returns without doing
anything while {{termOfLeaderSCM}} is empty, which it is after every datanode
start. {{initTermOfLeaderSCM}} later sets the term from the queued commands
only. If the first commands come from the old leader, the datanode settles on
the old term and runs them, although it had already received the new term in
heartbeat responses. The javadoc of {{initTermOfLeaderSCM}} says the datanode
must "detect latest leader SCM before handling any SCMCommand, so that it won't
be disturbed by stale leader SCM".
* *Unlocked check then set.* {{HeartbeatEndpointTask.processResponse}} calls
the method without {{StateContext.lock}}, while {{addCommand}} and
{{getNextCommand}} call it under the lock. Each SCM endpoint has its own
heartbeat thread. Two responses with terms 2 and 3 processed at the same time
can leave the term at 2 after 3 was applied, and {{getNextCommand}} then
returns a queued term 2 command instead of dropping it.
An old leader does deliver commands of its term. It stamps the term when it
creates a command and can create them until Ratis notifies it that it lost
leadership. {{CommandQueue}} is not cleared on step down, and
{{SCMNodeManager.processHeartbeat}} hands queued commands out on followers too,
without a term in the response.
h3. Trigger
Heartbeat term ignored before init: a datanode starts (or restarts) while SCM
leadership has moved from SCM A (term 1) to SCM B (term 2), and A still has
commands of term 1 queued for that datanode, or A has not yet stepped down. B
answers the datanode's heartbeats with term 2 but has no command for it yet.
A's command is then the first in the datanode's queue.
Unlocked update: two SCMs answer the same datanode as leader at the same time
(an old leader at term 2 that has not stepped down yet, the new leader at term
3), the two responses are processed concurrently and the term 2 write lands
between the read and the write of the term 3 update.
h3. Impact
* A command decided by the old leader runs on the datanode after the new leader
took over, for example a force delete of a container replica. In the
reproduction a force delete of term 1 deleted the replica after the datanode
had received five heartbeat responses with term 2. The new leader did not
decide that delete and does not know about it: when an SCM becomes leader,
{{ReplicationManager.notifyStatusChanged}} clears the pending replication and
delete ops, with the comment "They are no longer needed as the DN will discard
any commands when the term changes". Its own over replication handling can
therefore delete another replica of the same container, which is the double
delete of HDDS-16449, there described as masked by this datanode term gate.
Shown for one replica, multiple replicas and the resulting redundancy were not
exercised. Re-replication restores redundancy afterwards.
* The damage is bounded in time. ReplicationManager commands carry a datanode
deadline ({{hdds.scm.replication.event.timeout}} 12m minus
{{hdds.scm.replication.event.timeout.datanode.offset}} 6m) after which
{{DeleteContainerCommandHandler}} and {{ReplicationSupervisor}} drop them, so a
stale ReplicationManager delete runs only within about 6 minutes of its
creation, and the new leader's ReplicationManager waits
{{hdds.scm.wait.time.after.safemode.exit}} (5m) after it becomes ready before
it acts. The reproduction sends a command without a deadline.
* With the race, the term goes back from 3 to 2 until the next heartbeat of the
new leader (default interval 30s), and term 2 commands queued in that time are
dispatched. The race needs two SCMs answering as leader at once, so it is rare.
* A datanode that already has its term is not affected by the first defect; it
drops the same command ("Detect and drop a SCMCommand ... from stale leader
SCM").
h3. Reproduction
Both attached classes pass on unmodified source when the bug is present (target
path and Maven command in each class comment). Not run with real SCMs or a real
SCM failover.
* [^TestBugMC4StaleLeaderCommandBeforeTermInit.java]
({{hadoop-hdds/container-service}}): a real {{DatanodeStateMachine}} daemon
over Hadoop RPC against three {{ScmTestMock}} SCMs that put their term into the
heartbeat response only while they believe they lead, like
{{SCMDatanodeProtocolServer.sendHeartbeat}}. Two cases: the old leader still
believes it leads, or it is a follower that still holds the command. In both,
the term 1 force delete removes the container (about 15s).
* [^TestBugMC5HeartbeatTermRegression.java]
({{hadoop-hdds/container-service}}): two real {{HeartbeatEndpointTask}}s on
their own threads against mocked SCM proxies (term 2 with a close container
command, term 3 without). A Mockito spy on the {{StateContext}} holds the term
2 write between its check and its write until the term 3 update has finished,
which makes the race deterministic. The term ends at 2 and {{getNextCommand}}
returns the term 2 command.
h3. Patch
[^MC-4-use-heartbeat-term-before-term-init-and-lock-term-update.patch], against
b10a7d427bdf83807a7e48a1be507d3b02d5a503. It also applies to master at
e0de543cbe9.
While the term is not initialized, {{updateTermOfLeaderSCM(long)}} records the
highest term it is given, and {{initTermOfLeaderSCM}} uses the larger of that
and the highest queued command term. The method takes {{StateContext.lock}},
the lock its other two callers already hold, which also keeps recording and
init atomic. {{termOfLeaderSCM}} becomes volatile for the command handlers that
read it from their own threads. Nothing changes once the term is set, and init
still happens at the same moment.
This narrows the window but does not close it: if the datanode initializes its
term before it has processed a heartbeat response of the new leader (init only
needs a majority of SCMs in HEARTBEAT state), a command of the old term is
still accepted. Waiting for a leader heartbeat term before init would close it,
but would stop command processing when no SCM sends a term (an SCM older than
HDDS-7621, or no leader), so it is left out. Clearing {{CommandQueue}} when an
SCM steps down would remove the follower route on the SCM side.
Covered by {{dropsCommandOfStaleLeaderIfNewerTermWasSeenBeforeInit}},
{{initsTermFromCommandIfOlderTermWasSeenBeforeInit}} (negative case) and
{{concurrentTermUpdatesKeepHighestTerm}} in the existing {{TestStateContext}}.
Without the change the first fails with the term 1 command returned and the
third with term 2 instead of 3. With it {{TestStateContext}},
{{TestHeartbeatEndpointTask}}, {{TestHeartbeatEndpointTaskDnsRefresh}},
{{TestDeleteContainerCommandHandler}}, {{TestReplicationSupervisor}},
{{TestRunningDatanodeState}} and {{TestCloseContainerCommandHandler}} pass (113
tests), checkstyle is clean, and both attached reproductions fail because the
stale command is dropped.
Found by TLA+ model checking and code review of the datanode registration and
heartbeat paths under HDDS-15926, on commit
b10a7d427bdf83807a7e48a1be507d3b02d5a503. Checked against HDDS issues and
apache/ozone pull requests for duplicates before filing. Related but not
duplicates: HDDS-3988 (introduced the term fencing), HDDS-7621 (added the
heartbeat term and the early return), HDDS-5330 (stale commands in sub queues
after the term is set), HDDS-16449 (redundant delete after an SCM leadership
transfer, masked by the datanode term gate) and HDDS-16804
({{ReplicationSupervisor}} loses a command of the new leader behind an equal
task of the old leader). The attached patch is a proposal for review. Generated
with Specula (Claude Opus 5.5).
--
This message was sent by Atlassian Jira
(v8.20.10#820010)
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]