Siyao Meng created HDDS-17331:
---------------------------------

             Summary: Concurrent node state change and replication streams 
limit reconfiguration can leave the datanode replication pool at the wrong size 
or throw
                 Key: HDDS-17331
                 URL: https://issues.apache.org/jira/browse/HDDS-17331
             Project: Apache Ozone
          Issue Type: Bug
            Reporter: Siyao Meng


h3. Mechanism
{{ReplicationSupervisor.resize}} runs without a lock. It is called from the 
command processor thread through {{nodeStateUpdated}} (a 
{{SetNodeOperationalStateCommand}} from SCM, via 
{{SetNodeOperationalStateCommandHandler}}) and from the reconfiguration thread 
through {{setReplicationMaxStreams}} and {{setPerVolumePoolSize}} 
({{HddsDatanodeService.reconfigReplicationStreamsLimit}} and 
{{reconfigPerVolumeStreamsLimit}}). Each call reads the streams limit and the 
node state and computes a pool size. The default pool updater from 
{{ReplicationSupervisor.Builder.build}} then reads {{getCorePoolSize}} and 
calls {{setMaximumPoolSize}} and {{setCorePoolSize}} as two separate steps. The 
per volume pools are resized the same way by {{HddsServerUtil.setPoolSize}}.

When both threads resize at the same time, two things can go wrong:
* *Lost update.* One thread computes its size from a value the other thread is 
about to change, and its setters land last. Both calls return normally, but the 
pool ends at a size that does not match the final streams limit and node state.
* *Exception.* One thread resizes the pool between the other thread's two 
setters, so the second setter violates {{core <= max}} and throws 
{{IllegalArgumentException}}. The throwing thread skips the per volume resize. 
From {{nodeStateUpdated}} the exception escapes 
{{SetNodeOperationalStateCommandHandler.handle}} and {{CommandDispatcher}} logs 
it at ERROR. From {{setReplicationMaxStreams}} it fails the reconfiguration, 
although {{ReplicationServer}} has already been resized and the new limit is 
already stored in {{ReplicationConfig}}. On the per volume pools the same 
exception is caught and logged at WARN per pool by 
{{VolumeReplicationThreadPools.setPoolSize}}.

Both can happen in the same race: the thread that throws may be the one that 
computed the correct size, and the other thread's stale size then remains.

The race came with HDDS-10237, which made the supervisor pool reconfigurable at 
runtime. Before that only the command processor thread resized it.

h3. Trigger
An SCM node state change for the datanode (decommission, maintenance, or the 
way back to IN_SERVICE) handled at the same moment as an admin reconfiguration 
of {{hdds.datanode.replication.streams.limit}} (or 
{{hdds.datanode.replication.per.volume.streams.limit}}, only with 
{{hdds.datanode.replication.per.volume.enabled}}, which is off by default) on 
that datanode. The window is short: from the start of {{resize}} to its pool 
setters for the lost update (one INFO log call in between), and between the two 
setters for the exception. Both events are rare and independent, so this needs 
an operator who changes the streams limit while the node state is changing, for 
example to speed up a decommission that was just started.

h3. Impact
* The global replication executor uses an unbounded {{PriorityBlockingQueue}}, 
so it never runs more threads than its core size. After a lost update fewer 
replication tasks run at once than configured (4 instead of 6 in the 
reproduction), while the reconfiguration reported success.
* Any later resize recomputes from the current state and limits and repairs the 
pool, but a resize only happens on a real node state change or a 
reconfiguration to a different value. A repeated 
{{SetNodeOperationalStateCommand}} with the same state does nothing 
({{nodeStateUpdated}} returns early when the state is unchanged), and a 
reconfiguration to the value already stored is skipped. During a decommission 
the next state change (to DECOMMISSIONED) comes after the drain, which is the 
phase the out of service scaling is meant to speed up. The reverse direction 
lasts longest: a node back IN_SERVICE gets no further state change, so a stale 
size stays until the next streams limit change or a restart.
* On the exception path the admin is told that the reconfiguration failed 
although the new limit is already stored in {{ReplicationConfig}}, and 
depending on the interleaving the pool has either the new size or the stale one 
(both were run). Because the failed reconfiguration is not recorded, retrying 
it applies the limit again (read from the code, not run). The datanode logs an 
ERROR for the node state command. The persisted node state is not affected.

No data is lost. The effect is on replication throughput and on the feedback 
the operator gets.

h3. Reproduction
Two self contained JUnit classes for {{hadoop-hdds/container-service}} (target 
path and Maven command are in each class comment). Both build a real 
{{ReplicationSupervisor}} on a real {{ThreadPoolExecutor}} configured like the 
default one. The pool size updater, passed through the existing 
{{executorThreadUpdater}} builder hook, performs the same steps as the default 
updater and holds one thread until the other thread has finished its own resize 
(or, with a fix, is blocked waiting for the first one). No product code is 
changed. Both pass on unmodified source, which shows the bug.
* [^TestBugMC2ResizeLostUpdate.java]: streams limit 2, node IN_SERVICE. 
{{nodeStateUpdated(DECOMMISSIONING)}} and {{setReplicationMaxStreams(3)}} both 
return normally, and the pool ends at core 4 / max 4 where 3 x 2 = 6 is 
required. A repeated {{nodeStateUpdated(DECOMMISSIONING)}} leaves it at 4, and 
of 6 replication tasks submitted through {{addTask}} only 4 run at once while 2 
wait in the queue.
* [^TestBugMC1ResizeIllegalArgument.java]: the {{IllegalArgumentException}} 
escapes the real {{SetNodeOperationalStateCommandHandler.handle}} when a 
reconfiguration (limit 2 to 1) resizes between the command's two setters. It 
also escapes {{setReplicationMaxStreams(3)}} when a change from DECOMMISSIONING 
to IN_SERVICE resizes between the reconfiguration's two setters, while the pool 
and {{ReplicationConfig}} already reflect the new limit. That 
{{ReconfigurationHandler}} then reports the reconfiguration as failed was read 
from the code, not run.
* The combined case (the reconfiguration throws and the pool stays at the stale 
4 / 4 where 6 is required) was run as a scratch test with the same hook and is 
not attached.

Without any hook, a loop of 20000 iterations with two plain threads and the 
unmodified default updater lost the update 4 times on unmodified source (pool 
at 3/3 or 4/4, and the kept case ran 3 of 6 tasks at once), and never with the 
patch. That run showed no exception; the exception without a hook was seen only 
under extra CPU load in the original investigation. These loops are slow and 
probabilistic and are not attached.

h3. Patch
[^MC-1-serialize-replication-supervisor-pool-resize.patch], against 
07512078fe639f387acd52104491d5a7f3edb84c. It also applies to master at 
2851f5a3057.

{{nodeStateUpdated}}, {{setReplicationMaxStreams}} and {{setPerVolumePoolSize}} 
become {{synchronized}}. They are the only callers of {{resize}}, and the node 
state and the limits are only written inside them, so each resize now reads the 
latest state and limits and runs both setters before the next one starts. One 
change removes both the lost update and the exception. Nothing called under the 
lock takes another lock that is held while waiting for the supervisor, and the 
only other user of that monitor is the counter initialization for a new task 
type in {{initCounters}}. The lock also keeps {{maxQueueSize}}, which 
{{resize}} writes from the same stale read, consistent with the final state. 
Behaviour without concurrent calls is unchanged.

Covered by {{concurrentNodeStateChangeAndReconfigurationApplyLatestPoolSize}} 
in the existing {{TestReplicationSupervisor}}, for both directions (IN_SERVICE 
to DECOMMISSIONING and back, while the limit goes from 2 to 3). Without the 
change it fails with "Expecting AtomicInteger(4) to have value: 6" (2 instead 
of 3 for the way back). With it {{TestReplicationSupervisor}} (56 tests), the 
command handler tests in {{hadoop-hdds/container-service}}, 
{{TestPerVolumePushReplication}} and {{TestOzoneIntegrationNonHA}} on a mini 
cluster (340 tests, including the datanode reconfiguration tests) pass, and 
checkstyle is clean. With the patch both reproduction classes fail, because the 
second resize waits for the first, nothing throws and the pool ends at the 
required size.

Found by TLA+ model checking and code review of the datanode 
ReplicationSupervisor thread pool resize and metrics paths under HDDS-15926, on 
commit 07512078fe639f387acd52104491d5a7f3edb84c. Checked against HDDS issues 
and apache/ozone pull requests for duplicates before filing. Related but not a 
duplicate: HDDS-8554 (resolved, setter order when a single thread shrinks the 
pool). 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