HDDS-15533. DNS refresh on heartbeat failure for DN to SCM#10488
HDDS-15533. DNS refresh on heartbeat failure for DN to SCM#10488kerneltime wants to merge 4 commits into
Conversation
78e8646 to
45b4dd8
Compare
|
Rebased onto the updated PR-3 (#10487) tip and retitled to HDDS-15533 per @szetszwo's subtask request. Copilot's earlier review pass errored out and posted no inline comments. Will re-request a Copilot review once this PR's status is settled. The substantive Copilot findings on PR-1, PR-2, and PR-3 have been addressed in their owning PRs and propagate forward via rebase. |
3d9ba8b to
9fd26c3
Compare
szetszwo
left a comment
There was a problem hiding this comment.
@kerneltime , thanks for working on this!
Currently, InetSocketAddress is used to identify SCM node (e.g. SCMConnectionManager.scmMachines, StateContext.endpoints). We probably should change them to use hostAndPort. What do you think?
BTW, could you ask the AI to use 120 characters? It generate a lot of short lines.
| * from a peer pod IP change within seconds. | ||
| */ | ||
| public static final String OZONE_DN_SCM_HEARTBEAT_REFRESH_THRESHOLD_KEY = | ||
| "ozone.datanode.scm.heartbeat.address.refresh.threshold"; |
There was a problem hiding this comment.
The existing confs have profix "hdds.heartbeat.xxx" and they are in HddsConfigKeys. Please follow the current convention.
| LOG.warn("Failed to re-resolve {}: {}", hostAndPort, ex.getMessage()); | ||
| return null; |
There was a problem hiding this comment.
It should throw an exception:
throw new IllegalStateException("Malformed host address: " + hostAndPort, ex);| if (refreshed.isUnresolved()) { | ||
| LOG.warn("Re-resolution of {} produced an unresolved address; " | ||
| + "leaving cached address {} in place.", hostAndPort, address); | ||
| return null; | ||
| } | ||
| // Null-safe comparison: if the cached address itself was unresolved | ||
| // (initial DNS lookup failed; ctor accepted it with a warning), | ||
| // address.getAddress() is null and a successful re-resolution | ||
| // counts as a change. NPE-ing here would wedge the heartbeat | ||
| // refresh path on exactly the case it most needs to fix. | ||
| java.net.InetAddress cachedIp = address.getAddress(); | ||
| if (cachedIp != null | ||
| && refreshed.getAddress().equals(cachedIp)) { | ||
| return null; | ||
| } | ||
| return refreshed; |
There was a problem hiding this comment.
Get refreshedIp and check null.
final InetAddress refreshedIp = refreshed.getAddress();
if (refreshedIp == null) {
LOG.warn("Failed to resolve {}; reusing previous address {}.", hostAndPort, address);
return null;
}
if (refreshedIp.equals(address.getAddress())) {
return null;
}
return refreshed;| LoggerFactory.getLogger(SCMConnectionManager.class); | ||
|
|
||
| private final ReadWriteLock mapLock; | ||
| private final Map<InetSocketAddress, EndpointStateMachine> scmMachines; |
There was a problem hiding this comment.
We should change the key of this map to hostAndPort first. It does not make sense to check if the resolved addresses collide.
I also notice this, AI likes to wrap lines unnecessarily probably due to the surrounding codes. @kerneltime Have you tried using the https://github.com/apache/ozone/blob/master/AGENTS.md? It has the "Do not wrap lines early just to make them look uniform. The project limit is 120 characters; use the space." to prevent wrapping lines. cc: @szetszwo |
|
@ivandika3 @szetszwo thank you for the feedback and the pointers. Will address them shortly. |
EndpointStateMachine.address is constructed at DN startup from the
configured host:port and reused for the lifetime of the DN heartbeat
loop. InetSocketAddress freezes the resolved IP at construction; when
an SCM pod is rescheduled to a new IP under a stable DNS name
(Kubernetes), every heartbeat to that peer dials the gone-away IP
forever. The DN's endpoints set still contains the broken peer's
EndpointStateMachine, but that machine never recovers without a DN
process restart.
Apply DNS-refresh-on-failure for the DN heartbeat path. Reuses
ConnectionFailureUtils and the ozone.client.failover.resolve-needed
flag landed in PR-2. Adds a separate threshold knob since the
heartbeat path runs at a much higher cadence than the failover-proxy
seams.
EndpointStateMachine.resolveLatestAddress():
- Re-resolve the preserved hostAndPort via NetUtils.createSocketAddr.
- Return the freshly-resolved InetSocketAddress only if its
getAddress() differs from the cached one.
- Return null on legacy endpoints (no preserved hostAndPort),
unresolved DNS, or unchanged IP -- so callers can opt into a
swap without committing to one.
SCMConnectionManager.refreshSCMServer() -- 4-phase atomic swap:
- PHASE A (read lock): snapshot endpoint reference + hostAndPort.
- PHASE B (no lock): resolveLatestAddress. DNS lookup must NEVER
hold any lock; a slow / dead resolver under
lock would freeze every concurrent heartbeat
and reconfiguration path.
- PHASE C (write lock): re-check snapshot (defends against
concurrent removeSCMServer / refresh races),
enforce collision invariant (refuse swap if
the resolved IP collides with another
registered peer key -- transient kube-DNS
can return peer-B's IP for peer-A's
hostname; overwriting peer-B would leak
its executor and orphan its task thread),
BUILD replacement endpoint BEFORE removing
stale (build failure must NOT leave the
peer absent from scmMachines), commit swap.
- PHASE D (no lock): close stale endpoint. RPC.stopProxy +
socket teardown blocks; holding the write
lock across that stalls every concurrent
heartbeat.
StateContext.migrateEndpoint -- preserve in-flight reports across
swap. Per-endpoint queues (incrementalReportsQueue, containerActions,
pipelineActions, isFullReportReadyToBeSent) are keyed by
InetSocketAddress; without migration a swap orphans every queued
report. Migration ordering preserves the invariant "every endpoint
in `endpoints` has a queue at every observable point":
1. PUBLISH: install new-key queues alongside old-key queues.
2. SWITCH: add newEndpoint to endpoints; remove oldEndpoint.
3. RETIRE: drop old-key queues (no producer can reach them now).
endpoints is now a CopyOnWriteArraySet (was HashSet).
incrementalReportsQueue / containerActions / pipelineActions /
isFullReportReadyToBeSent are now ConcurrentHashMap. Producers
null-skip queue lookups as defense-in-depth against producer-vs-
migration races. The full-report flags get a special case: a swapped
endpoint is effectively a fresh peer (the new SCM pod has no idea
which reports we already shipped), so its isFullReportReadyToBeSent
flags are seeded fresh -- not copied from the old key.
HeartbeatEndpointTask trigger:
In the heartbeat catch block, after logIfNeeded(ex):
if (resolveOnFailureEnabled
&& missedCount >= refreshThreshold
&& ConnectionFailureUtils.isConnectionFailure(ex)
&& hostAndPort != null) {
maybeRefreshScmAddress();
}
All four gates required. Application-level errors do NOT trigger.
Endpoints without a preserved hostname (legacy code path) do NOT
trigger. Threshold prevents over-reaction to a one-off blip.
New config knob:
- ozone.datanode.scm.heartbeat.address.refresh.threshold = 3.
Conservative default: at the typical 30-second heartbeat interval
and 6-second socketTimeout, this means at most ~108 seconds of
dialing the stale IP before the first DNS retry. In practice
failures are usually fast (TCP RST or routing failure), so
recovery is much faster.
Tests:
- TestSCMConnectionManager (extended, 7 = 1 prior + 6 new):
resolveLatestAddress edge cases; refreshSCMServer happy-path
swap; no-op when hostAndPort not preserved; rollback regression
-- when buildScmEndpoint throws, stale endpoint stays registered
(uses @VisibleForTesting overridable hook to inject the
failure).
- TestHeartbeatEndpointTaskDnsRefresh (new, 6): production trigger
chain. HeartbeatEndpointTask.call() catch block fires
refreshSCMServer only when (a) flag enabled, (b) threshold met,
(c) cause is connection-class, (d) hostAndPort preserved.
AccessControlException at threshold does NOT trigger. After
successful swap, StateContext's incremental-reports map has the
new key and not the old key.
- TestSCMConnectionManagerDnsRefreshE2E (new, 1, @timeout(30)):
real-RPC swap mechanism. Stands up a real ScmTestMock RPC server
on a loopback OS-assigned port, primes the connection manager
with a stale 127.0.0.99 cache + preserved localhost:port, calls
refreshSCMServer, asserts a real sendHeartbeat round-trips
through the swapped endpoint.
Existing TestEndPoint (17) and TestHeartbeatEndpointTask (8)
verified non-regressed.
Real-world failure shapes this fix targets:
- AWS EC2 / EKS silent packet drop: stale-IP packets are silently
dropped, surfacing as SocketTimeoutException after socketTimeout.
Without this fix, the DN retries the dead IP forever.
- OpenStack TCP RST / ICMP unreachable: stale-IP packets fast-
rejected, surfacing as ConnectException. Same recovery path.
Scope: the refresh fires from the HEARTBEAT phase. If a DN starts
up with the SCM peer already at a stale IP and never reaches
HEARTBEAT, the recovery path does NOT engage. Initial-bringup DNS
staleness is HDDS-5919's
ozone.network.jvm.address.cache.enabled=false's concern.
This is PR 4 of 4 splitting HDDS-15514. Stacked on PR-3
(HDDS-15514-om-scm-refresh).
The two warning logs on the refresh failure paths logged only ex.getMessage(), dropping the stack trace (same review feedback applied on the OM-to-SCM PR). Pass the throwable to SLF4J so refreshSCMServer and stale-endpoint close() failures are diagnosable.
Fixture.datanodeStateMachine was assigned but never read. Drop the field, its constructor parameter, and the call-site argument; the local mock is still used to wire up the connection manager and StateContext.
…den resolveLatestAddress Per @szetszwo's review on apache#10488: - Move the DN->SCM heartbeat refresh threshold to HddsConfigKeys following the hdds.heartbeat.* convention: hdds.heartbeat.address.refresh.threshold. - EndpointStateMachine.resolveLatestAddress now throws IllegalStateException on a malformed host:port, and extracts and null-checks the refreshed IP before comparing it to the cached address. The InetSocketAddress -> hostAndPort re-key of scmMachines and StateContext.endpoints is proposed as a focused follow-up (see PR discussion).
19befa2 to
8a527a9
Compare
|
Thanks @szetszwo. Addressed the three concrete comments in the latest push (also rebased on master to clear the
On the larger point — keying Also fixed my tooling to follow |
I think it's still wrapping unnecessarily. Examples: connectionManager.addSCMServer(scmAddress, hostAndPort,
context.getThreadNamePrefix());
...
EndpointStateMachine endPoint =
buildScmEndpoint(address, hostAndPort, threadNamePrefix);
Am I the only one who finds these 10+ line comments annoying? Explaining motivation for some code is OK, but for every single |
How about doing it first? Let me see how big the change would be. |
I will submit a PR soon.
I also found that the AI generated code has too long comments. It actually makes the code hard to read and becomes a maintenance issue since the future changes also have to update the long comments. |
|
Submitted: #10612 |
I did not refactor the entire PR to address the line length..
With AI based code and comment generation we have some hope to keep design specs, their evolution and the documentation for the code generational rational consistent for the first time. I actually do not mind peeking into the inner thoughts of LLMs via such comments to reason about the change. |
Sure. Taking a look at your new PR. Thanks @szetszwo |
|
Opened a daisy-chained PR for this feature on top of @szetszwo's HDDS-15682 (#10612): szetszwo#11 On #10612's stable host:port identity, the feature collapses a lot — the new PR drops the Leaving this PR (#10488) as-is for now. Next: once #10612 merges to master, retarget the stacked PR's base to master and close this one. (Confirmed #10612 alone does not fix the stale-IP bug — Generated-by: Claude Code (AI-assisted) |
Thanks @kerneltime, #10612 is merged. I cannot retarget szetszwo#11 to |
What changes were proposed in this pull request?
This is PR 4 of 4 splitting HDDS-15514 (originally proposed as a single ~160KB patch in #10473, split per @szetszwo's review feedback).
This PR fixes the DN → SCM heartbeat path — the largest and most invasive of the four split PRs. Unlike the failover-proxy-provider seams, the DN does not failover; it heartbeats every SCM in parallel via the
EndpointStateMachine/SCMConnectionManagerabstraction. The fix introduces:EndpointStateMachineswap when DNS re-resolution detects an IP change.StateContextso in-flight reports survive the swap.ozone.datanode.scm.heartbeat.address.refresh.threshold, default 3) — the heartbeat path runs at a much higher cadence than the failover-proxy path, so a count-based gate prevents over-reaction to transient blips.Why this matters
EndpointStateMachine.addressis the cachedInetSocketAddressthat the DN heartbeat task uses to dial each SCM peer. It is constructed at DN startup from the configuredhost:portand never re-resolved. When an SCM pod is rescheduled in Kubernetes, every heartbeat to that peer dials the now-defunct IP forever. The DN'sendpointsset still contains the broken peer'sEndpointStateMachine, but that machine never recovers without a DN process restart.This is the path the AWC ozone-operator's existing 7-layer workaround was built to defeat: after watching SCM pod IP changes, the operator force-restarts every DN. PR-4 is the upstream fix that lets the operator drop those restarts.
What this PR does
1.
EndpointStateMachinepreserves a hostnamefinal String hostAndPortfield.resolveLatestAddress(): re-resolveshostAndPortviaNetUtils.createSocketAddrand returns the freshly-resolvedInetSocketAddressonly if itsgetAddress()differs from the cached one. Returns null on legacy endpoints (no preservedhostAndPort), unresolved DNS, or unchanged IP.2.
SCMConnectionManager.refreshSCMServer— 4-phase atomic swapCrucial properties (each had a corresponding bug in the original combined PR that Copilot's failure-injection lens caught):
buildScmEndpointthrows (transient DNS, peer not yet accepting on the new IP, NetUtils refusing the address), the stale endpoint stays registered. Otherwise the peer would disappear fromscmMachinesentirely and no heartbeat could recover it. Tested byTestSCMConnectionManager.testRefreshSCMServerLeavesStaleEndpointOnBuildFailureusing a@VisibleForTestingoverridablebuildScmEndpointhook.EndpointStateMachinewould be silently replaced, leaking its executor and orphaning its task thread.removeSCMServeror refresh may have raced ahead while we were resolving. The write-lock phase verifies the snapshot is still current before swapping.close()outside the lock. Stale-endpoint teardown blocks onRPC.stopProxy; holdingwriteLock()across that would stall every concurrent heartbeat / reconfiguration.3.
StateContext.migrateEndpoint— preserve in-flight reports across swapPer-endpoint queues (
incrementalReportsQueue,containerActions,pipelineActions,isFullReportReadyToBeSent) are keyed byInetSocketAddress. Without migration, a swap would orphan all queued reports for that peer. The migration is ordered to preserve the invariant "every endpoint inendpointshas a queue at every observable point":newEndpointto the endpoints set; removeoldEndpointfrom the endpoints set.endpointsis now aCopyOnWriteArraySet(wasHashSet).incrementalReportsQueue,containerActions,pipelineActions, andisFullReportReadyToBeSentare nowConcurrentHashMap(some already were). Producers null-skip queue lookups as defense-in-depth — a producer racing migration MUST NOT NPE on a concurrentremove.The full-report flags get a special case: a swapped endpoint is effectively a fresh peer (the new SCM pod has no idea which reports we have already shipped), so its
isFullReportReadyToBeSent[type]flags are seeded fresh rather than copied from the old key. Tested inTestHeartbeatEndpointTaskDnsRefresh.4.
HeartbeatEndpointTasktriggerIn the heartbeat catch block, after
logIfNeeded(ex):All four gates are required. Application-level errors don't trigger refresh. Endpoints without a preserved hostname (legacy code path) don't trigger. The threshold prevents over-reaction to a one-off blip.
5. New config knob
ozone.datanode.scm.heartbeat.address.refresh.threshold(default 3). Conservative default — at the typical 30-second heartbeat interval and 6-secondsocketTimeout, this means at most ~108 seconds of dialing the stale IP before the first DNS retry. In practice the failures are usually fast (TCP RST or routing failure), so the recovery is much faster.Real-world failure shapes this fix targets
Two distinct failure modes drove the requirement:
scm-0after the pod has moved, AWS silently drops the packet. The TCP retry loop expires aftersocketTimeout(default 6 seconds in Ozone). Without this PR, the DN retries the same dead IP forever. With this PR, afterthresholdconsecutiveSocketTimeoutExceptions, the DN re-resolves DNS and swaps to the new IP.ConnectException. Same recovery path: afterthresholdconsecutive failures, refresh.How was this patch tested?
TestSCMConnectionManager(extended)resolveLatestAddressedge cases.refreshSCMServerhappy-path swap. No-op whenhostAndPortnot preserved. Rollback regression: whenbuildScmEndpointthrows, the stale endpoint remains registered (uses@VisibleForTestingoverridable hook to inject the failure).TestHeartbeatEndpointTaskDnsRefresh(new)HeartbeatEndpointTask.call()catch block firesrefreshSCMServeronly when (a) flag enabled, (b) threshold met, (c) cause is connection-class, (d)hostAndPortpreserved.AccessControlExceptionat threshold does NOT trigger. After a successful swap,StateContext's incremental-reports map has the new key and not the old key.TestSCMConnectionManagerDnsRefreshE2E(new)@Timeout(30))ScmTestMockRPC server on a loopback OS-assigned port, primes the connection manager with a stale127.0.0.99cache + preservedlocalhost:port, callsrefreshSCMServer, asserts a realsendHeartbeatround-trips through the swapped endpoint. Lives inhadoop-hdds/server-scmbecause it depends onScmTestMock.Existing regression suite verified non-regressed:
TestEndPoint(17),TestHeartbeatEndpointTask(8).Scope and known limitations
HEARTBEATphase viaHeartbeatEndpointTask. If a DN starts up with the SCM peer already at a stale IP and never reachesHEARTBEAT, the recovery path does not engage. Initial-bringup DNS staleness is the existing concern of HDDS-5919'sozone.network.jvm.address.cache.enabled=false.InitDatanodeState.javaalready postpones initialization on initial-resolution failure.What is the link to the Apache JIRA?
https://issues.apache.org/jira/browse/HDDS-15514