[Fix-18274][Registry] Handle expired JDBC heartbeat sessions - #18416
qiuyanjun888 wants to merge 10 commits into
Conversation
|
When This can happen after a long JVM pause or heartbeat scheduler starvation:
A missing heartbeat row is definitive evidence that the session has expired, not a transient database error. Continuing to run after that point can overlap with a server that has already taken over, leaving a split-brain window. Please handle this condition separately and transition directly to The new test sets the server state to |
Thanks for your suggestions! I have already implemented, can you please help review? |
|
Thanks for addressing the previous feedback. A zero-row heartbeat update now disconnects the server immediately, and the added test covers the However, there is still a shutdown race:
Previously, the exception handler inspected the current state, so |
|
Addressed the shutdown race described in #18416 (comment) in commit
Validation:
Could you please take another look? |
SbloodyS
left a comment
There was a problem hiding this comment.
There is still a shutdown race in the other state transitions.
close() and transitionToDisconnected() are synchronized, but the successful heartbeat and exception paths still update jdbcRegistryServerState directly:
SUSPENDED -> STARTEDafter a successful refreshSTARTED -> SUSPENDEDafter a refresh exception
For example:
- The heartbeat thread observes
SUSPENDED. close()changes the state toSTOPPED.- The heartbeat thread writes
STARTEDand invokesonReconnected().
The exception path can similarly overwrite STOPPED with SUSPENDED. Since the field is also neither volatile nor consistently accessed under the same lock, state visibility is not guaranteed.
Please make all state transitions atomic and use one synchronization strategy for every read/write, such as synchronized transition methods or an AtomicReference with compare-and-set. Connection callbacks should only run when their corresponding transition succeeds.
Please also add regression coverage for close() racing with:
- A successful heartbeat while the server is
SUSPENDED. - A heartbeat exception while the server is
STARTED.
In both cases, STOPPED must remain the final state and no reconnect/disconnect callback should be triggered after shutdown.
1a62c2a to
79cc4a0
Compare
Thanks for pointing this out. Fixed in commit The server state now uses one synchronization strategy consistently:
I also added the two requested race regressions:
Both assert that Verification:
Result: 7 tests, 0 failures, 0 errors; reactor build succeeded. |
|
PTAL @ruanwenjun |
| log.error("The client heartbeat has expired: {}", jdbcRegistryClientHeartbeatDTO.getId()); | ||
| transitionToDisconnected(); | ||
| return; |
There was a problem hiding this comment.
| log.error("The client heartbeat has expired: {}", jdbcRegistryClientHeartbeatDTO.getId()); | |
| transitionToDisconnected(); | |
| return; | |
| throw xxException() |
There was a problem hiding this comment.
Should consider session timeout.
There was a problem hiding this comment.
I checked this against the session lifecycle. updateById returning false means the heartbeat row no longer exists, and the normal purge path removes that row only after its stored session timeout has elapsed. The session has therefore already expired; waiting for another local timeout would keep a stale server active and reintroduce the split-brain window this PR fixes. I kept the immediate DISCONNECTED transition, with the STARTED zero-row regression test covering this case.
There was a problem hiding this comment.
Only a session timeout can cause the status to change to "disconnected".
There was a problem hiding this comment.
Addressed in 71e8a14. A failed heartbeat update now enters SUSPENDED first; the server transitions to DISCONNECTED only after the configured session timeout. The regression test covers the STARTED -> SUSPENDED -> DISCONNECTED sequence.
| log.error("The client heartbeat has expired: {}", jdbcRegistryClientHeartbeatDTO.getId()); | ||
| transitionToDisconnected(); | ||
| return; |
There was a problem hiding this comment.
Only a session timeout can cause the status to change to "disconnected".
| @Slf4j | ||
| public class JdbcRegistryServer implements IJdbcRegistryServer { | ||
|
|
||
| private static final AtomicReferenceFieldUpdater<JdbcRegistryServer, JdbcRegistryServerState> SERVER_STATE_UPDATER = |
There was a problem hiding this comment.
Use AtomicReference, don't write field name jdbcRegistryServerState here.
There was a problem hiding this comment.
Addressed in 71e8a14. Server state is now stored in an AtomicReference, so reads and compare-and-set transitions no longer depend on AtomicReferenceFieldUpdater or a field-name string.
| private void transitionToStarted() { | ||
| if (SERVER_STATE_UPDATER.compareAndSet( | ||
| this, JdbcRegistryServerState.SUSPENDED, JdbcRegistryServerState.STARTED)) { | ||
| doTriggerReconnectedListener(); | ||
| } | ||
| } | ||
|
|
||
| private void transitionToSuspended() { | ||
| SERVER_STATE_UPDATER.compareAndSet( | ||
| this, JdbcRegistryServerState.STARTED, JdbcRegistryServerState.SUSPENDED); | ||
| } | ||
|
|
||
| private void transitionToDisconnected() { | ||
| while (true) { | ||
| JdbcRegistryServerState currentState = jdbcRegistryServerState; | ||
| if (currentState != JdbcRegistryServerState.STARTED | ||
| && currentState != JdbcRegistryServerState.SUSPENDED) { | ||
| return; | ||
| } | ||
| if (SERVER_STATE_UPDATER.compareAndSet( | ||
| this, currentState, JdbcRegistryServerState.DISCONNECTED)) { | ||
| doTriggerOnDisConnectedListener(); | ||
| return; | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
Need to handle the status set failed case.
There was a problem hiding this comment.
Addressed in 71e8a14. CAS failures are now handled explicitly: the SUSPENDED transition records the current state, and the DISCONNECTED transition logs the failed attempt and retries after re-reading the state. The listener runs only after a successful state change.
There was a problem hiding this comment.
All compareAndSet failed should be handled. Please don't reply to me with an AI response, you should at least know what the reviewer means.
| public void close() { | ||
| jdbcRegistryServerState = JdbcRegistryServerState.STOPPED; | ||
| synchronized (this) { | ||
| if (serverState.getAndSet(JdbcRegistryServerState.STOPPED) == JdbcRegistryServerState.STOPPED) { |
| private void transitionToStarted() { | ||
| if (SERVER_STATE_UPDATER.compareAndSet( | ||
| this, JdbcRegistryServerState.SUSPENDED, JdbcRegistryServerState.STARTED)) { | ||
| doTriggerReconnectedListener(); | ||
| } | ||
| } | ||
|
|
||
| private void transitionToSuspended() { | ||
| SERVER_STATE_UPDATER.compareAndSet( | ||
| this, JdbcRegistryServerState.STARTED, JdbcRegistryServerState.SUSPENDED); | ||
| } | ||
|
|
||
| private void transitionToDisconnected() { | ||
| while (true) { | ||
| JdbcRegistryServerState currentState = jdbcRegistryServerState; | ||
| if (currentState != JdbcRegistryServerState.STARTED | ||
| && currentState != JdbcRegistryServerState.SUSPENDED) { | ||
| return; | ||
| } | ||
| if (SERVER_STATE_UPDATER.compareAndSet( | ||
| this, currentState, JdbcRegistryServerState.DISCONNECTED)) { | ||
| doTriggerOnDisConnectedListener(); | ||
| return; | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
All compareAndSet failed should be handled. Please don't reply to me with an AI response, you should at least know what the reviewer means.
| jdbcRegistryServerState = JdbcRegistryServerState.STARTED; | ||
| doTriggerReconnectedListener(); | ||
| } | ||
| transitionToStarted(); |
There was a problem hiding this comment.
Don't add such method, looks so strange, this only transite from SUSPENDED to STARTED.
| private void transitionToSuspended() { | ||
| if (!serverState.compareAndSet(JdbcRegistryServerState.STARTED, JdbcRegistryServerState.SUSPENDED)) { | ||
| log.debug("Failed to transition JdbcRegistryServer to SUSPENDED; current state is {}", serverState.get()); | ||
| } | ||
| } |
There was a problem hiding this comment.
Please remove this kind of method.
Was this PR generated or assisted by AI?
YES. This pull request was assisted by Hermes Agent / OpenAI Codex for code changes, focused tests, review feedback analysis, and local verification. The scope and final submission were directed by the contributor.
Purpose of the pull request
Closes #18274.
When the JDBC registry database is unavailable longer than the session timeout, another server can purge the stale heartbeat row. After recovery, the still-running client previously ignored the zero-row heartbeat update and incorrectly treated the refresh as successful.
This is an independent alternative related to #18275. It addresses the outstanding technical concerns discussed there by not recreating or upserting an expired heartbeat, which could revive a failed-over identity. Instead, a missing heartbeat enters the existing disconnect state machine so the owning service can terminate.
Brief change log
DISCONNECTED.Verify this pull request
This change added tests and can be verified as follows:
./mvnw clean -pl dolphinscheduler-registry/dolphinscheduler-registry-plugins/dolphinscheduler-registry-jdbc -am -DskipITs -Dtest=JdbcRegistryServerTest,JdbcRegistryDataChangeListenerAdapterTest -Dsurefire.failIfNoSpecifiedTests=false testBUILD SUCCESSacross the selected reactor../mvnw -pl dolphinscheduler-registry/dolphinscheduler-registry-plugins/dolphinscheduler-registry-jdbc spotless:applyPull Request Notice
Pull Request Notice
This pull request contains no incompatible change.