Skip to content

[Fix-18274][Registry] Handle expired JDBC heartbeat sessions - #18416

Open
qiuyanjun888 wants to merge 10 commits into
apache:devfrom
qiuyanjun888:Fix-18274-alt-20260715T021630Z
Open

qiuyanjun888 wants to merge 10 commits into
apache:devfrom
qiuyanjun888:Fix-18274-alt-20260715T021630Z

Conversation

@qiuyanjun888

Copy link
Copy Markdown
Contributor

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

  • Treat a zero-row JDBC heartbeat update as an expired registry session.
  • Stop heartbeat refresh work after the registry server reaches DISCONNECTED.
  • Persist the current heartbeat timestamp and add focused state-machine regression tests.

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 test
  • Result: 4 tests, 0 failures, 0 errors, and BUILD SUCCESS across the selected reactor.
  • ./mvnw -pl dolphinscheduler-registry/dolphinscheduler-registry-plugins/dolphinscheduler-registry-jdbc spotless:apply

Pull Request Notice

Pull Request Notice

This pull request contains no incompatible change.

@SbloodyS

SbloodyS commented Jul 15, 2026

Copy link
Copy Markdown
Member

When updateById(clone) returns false, the new RegistryException is caught by the generic error handler. If the server is currently STARTED, the catch block only moves it to SUSPENDED; it does not immediately invoke onDisConnected().

This can happen after a long JVM pause or heartbeat scheduler starvation:

  1. Another registry server considers the session expired and removes its heartbeat, ephemeral data, and locks.
  2. The original server resumes while its local state is still STARTED.
  3. updateById() returns false.
  4. The original server enters SUSPENDED and remains active until at least the next heartbeat cycle.

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 DISCONNECTED, triggering the disconnection callback exactly once.

The new test sets the server state to SUSPENDED and lastSuccessHeartbeat to zero, so it bypasses the problematic STARTED branch. Please also add a test verifying that a zero-row heartbeat update while STARTED causes an immediate disconnection.

@qiuyanjun888

Copy link
Copy Markdown
Contributor Author

When updateById(clone) returns false, the new RegistryException is caught by the generic error handler. If the server is currently STARTED, the catch block only moves it to SUSPENDED; it does not immediately invoke onDisConnected().

This can happen after a long JVM pause or heartbeat scheduler starvation:

  1. Another registry server considers the session expired and removes its heartbeat, ephemeral data, and locks.
  2. The original server resumes while its local state is still STARTED.
  3. updateById() returns false.
  4. The original server enters SUSPENDED and remains active until at least the next heartbeat cycle.

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 DISCONNECTED, triggering the disconnection callback exactly once.

The new test sets the server state to SUSPENDED and lastSuccessHeartbeat to zero, so it bypasses the problematic STARTED branch. Please also add a test verifying that a zero-row heartbeat update while STARTED causes an immediate disconnection.

Thanks for your suggestions! I have already implemented, can you please help review?

@SbloodyS SbloodyS added the bug Something isn't working label Jul 17, 2026
@SbloodyS SbloodyS added this to the 3.5.0 milestone Jul 17, 2026
@SbloodyS

Copy link
Copy Markdown
Member

Thanks for addressing the previous feedback. A zero-row heartbeat update now disconnects the server immediately, and the added test covers the STARTED path.

However, there is still a shutdown race:

  1. refreshClientsHeartbeat() passes the initial state check while the server is STARTED.
  2. close() concurrently changes the state to STOPPED and deletes the heartbeat rows.
  3. The in-flight updateById() returns false.
  4. This branch unconditionally overwrites STOPPED with DISCONNECTED and invokes onDisConnected() during a normal shutdown.

Previously, the exception handler inspected the current state, so STOPPED was left unchanged. Please make the transition to DISCONNECTED conditional/atomic so that close() cannot race with the heartbeat task and have its terminal state overwritten. A regression test coordinating an in-flight heartbeat update with close() would also be useful.

@qiuyanjun888

Copy link
Copy Markdown
Contributor Author

Addressed the shutdown race described in #18416 (comment) in commit 8d9599e.

  • The transition to DISCONNECTED is now conditional and synchronized with close(), so a completed STOPPED transition cannot be overwritten and the disconnection callback is not invoked when close wins.
  • Added a latch-controlled regression test that holds an in-flight heartbeat update, completes close(), then returns a zero-row update result.

Validation:

  • JdbcRegistryServerTest: 5 tests, 0 failures, 0 errors
  • module spotless:check: BUILD SUCCESS

Could you please take another look?

@SbloodyS SbloodyS left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 -> STARTED after a successful refresh
  • STARTED -> SUSPENDED after a refresh exception

For example:

  1. The heartbeat thread observes SUSPENDED.
  2. close() changes the state to STOPPED.
  3. The heartbeat thread writes STARTED and invokes onReconnected().

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.

@qiuyanjun888
qiuyanjun888 force-pushed the Fix-18274-alt-20260715T021630Z branch from 1a62c2a to 79cc4a0 Compare July 30, 2026 05:06
@qiuyanjun888

Copy link
Copy Markdown
Contributor Author

Please make all state transitions atomic and use one synchronization strategy for every read/write. Connection callbacks should only run when their corresponding transition succeeds.

Thanks for pointing this out. Fixed in commit 79cc4a0cde.

The server state now uses one synchronization strategy consistently:

  • start(), getServerState(), the close() state update, and all heartbeat state transitions synchronize on the same server instance.
  • Successful heartbeats use a guarded SUSPENDED -> STARTED transition.
  • Heartbeat exceptions use a guarded STARTED -> SUSPENDED transition.
  • Expired heartbeat records use a guarded STARTED/SUSPENDED -> DISCONNECTED transition.
  • Reconnect and disconnect callbacks run inside their corresponding synchronized transition, only after the expected source state is confirmed. If close() wins and sets STOPPED, later heartbeat results cannot overwrite it or emit reconnect/disconnect callbacks.

I also added the two requested race regressions:

  • close() racing with a successful heartbeat while SUSPENDED.
  • close() racing with a heartbeat exception while STARTED.

Both assert that STOPPED remains final and that no reconnect/disconnect callback fires after shutdown.

Verification:

./mvnw clean -pl dolphinscheduler-registry/dolphinscheduler-registry-plugins/dolphinscheduler-registry-jdbc -am -DskipITs -Dtest=JdbcRegistryServerTest -Dsurefire.failIfNoSpecifiedTests=false test

Result: 7 tests, 0 failures, 0 errors; reactor build succeeded.

SbloodyS
SbloodyS previously approved these changes Sep 1, 2026

@SbloodyS SbloodyS left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@SbloodyS

SbloodyS commented Sep 1, 2026

Copy link
Copy Markdown
Member

PTAL @ruanwenjun

Comment on lines +350 to +352
log.error("The client heartbeat has expired: {}", jdbcRegistryClientHeartbeatDTO.getId());
transitionToDisconnected();
return;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
log.error("The client heartbeat has expired: {}", jdbcRegistryClientHeartbeatDTO.getId());
transitionToDisconnected();
return;
throw xxException()

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should consider session timeout.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Only a session timeout can cause the status to change to "disconnected".

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +350 to +352
log.error("The client heartbeat has expired: {}", jdbcRegistryClientHeartbeatDTO.getId());
transitionToDisconnected();
return;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 =

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use AtomicReference, don't write field name jdbcRegistryServerState here.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +387 to +412
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;
}
}
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Need to handle the status set failed case.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit, use compareAndSet.

Comment on lines +387 to +412
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;
}
}
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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();

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't add such method, looks so strange, this only transite from SUSPENDED to STARTED.

Comment on lines +388 to +392
private void transitionToSuspended() {
if (!serverState.compareAndSet(JdbcRegistryServerState.STARTED, JdbcRegistryServerState.SUSPENDED)) {
log.debug("Failed to transition JdbcRegistryServer to SUSPENDED; current state is {}", serverState.get());
}
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please remove this kind of method.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

backend bug Something isn't working test

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug] [jdbc-registry] After database out of service for longer than session timeout time, client heartbeat will never be updated

3 participants