Skip to content

[Fix-18644][Master] Fix serial command races and restart identity reuse - #18645

Merged
ruanwenjun merged 13 commits into
apache:devfrom
michaellx1057:Fix-18644
Nov 12, 2026
Merged

ruanwenjun merged 13 commits into
apache:devfrom
michaellx1057:Fix-18644

Conversation

@michaellx1057

@michaellx1057 michaellx1057 commented Sep 16, 2026 •

Copy link
Copy Markdown
Contributor

Was this PR generated or assisted by AI?

YES — Codex assisted with the analysis, implementation, tests and this description.

Purpose of the pull request

Related to #18644.

This PR distinguishes HA server instances at the same address and protects SerialCommand transitions at the database layer. A restarted process should not treat its predecessor's selector as its own. When coordinators hold stale WAITING snapshots, only the transaction that claims the row may insert its Command.

The HA worker/EventBus and Coordinator lifecycle changes have been removed from this PR. Startup/watch races and Coordinator lifecycle fixes are left for a separate follow-up.

Brief change log

  • Append the construction timestamp to serverIdentify.
  • Conditionally claim a WAITING SerialCommand before inserting its Command in the same transaction. Update the in-memory snapshot only after the transaction succeeds.
  • Delete a serial record only while it is WAITING, then update its workflow state in the same transaction. Roll back if the subsequent write fails.
  • Stop processing a stale serial group after a claim/discard conflict; fetch current state on the next poll. Keep this behavior consistent across WAIT, DISCARD and PRIORITY.
  • Add focused identity, DAO and handler regression tests.

Registry paths, the Registry SPI, database schema and persisted queue states are unchanged.

Verify this pull request

The reduced patch was rebuilt and tested using Zulu JDK 8 (1.8.0_512, class version 52).

Validation Result
Targeted JUnit tests 55 passed, no failures, errors or skips; includes 5 identity, 11 handler and 4 DAO tests, plus existing Coordinator, Alert and Registry tests
Isolated MySQL 8.0.46 and PostgreSQL 18.4 30 checks per database: 10 scenarios, each repeated 3 times
Original-handler comparison Reproduced duplicate Commands and stale discard with the dev handlers using the same DAO/mapper fixture, 12 checks per database
Formatting Spotless and git diff --check passed

The database checks use production handlers, DAOs, mapper XML and real Spring/JDBC transactions. Stale WAITING snapshots were checked under WAIT, DISCARD and PRIORITY. The 2- and 3-transaction claim races use WAIT to exercise the shared launchSerialCommand path. The checks also cover both launch/discard orderings, insertion-failure/zero-row rollback and workflow-update rollback. Stop RPCs are stubbed; these are database integration checks, not complete Master deployment tests. The previous combined HA validation is not used as acceptance evidence for this reduced patch.

The timestamp is not a strict uniqueness guarantee for same-millisecond construction or clock rollback. The database guards protect the same SerialCommand when competing writers use the updated handlers; they do not establish cluster-wide fencing or global ordering across different records. No production environment was changed during this validation.

Pull Request Notice

Pull Request Notice

@ruanwenjun

Copy link
Copy Markdown
Member

Will there be any issues if the service restarts quickly and assumes the role of the previous leader?

@github-actions github-actions Bot removed the document label Sep 16, 2026
@michaellx1057

Copy link
Copy Markdown
Contributor Author

@ruanwenjun

Will there be any issues if the service restarts quickly and assumes the role of the previous leader?

Thanks for asking. Yes—in our reproduction, the restarted Master became ACTIVE without acquiring ownership of the existing coordinator selector. That selector still belonged to the previous process's etcd lease and was subsequently removed. A matching address does not transfer the old process's lease to the new process.

Deployment and operational context

We run a self-hosted DolphinScheduler 3.4.2 deployment with:

  • Kubernetes v1.33.13: 6 nodes, including 3 control-plane nodes.
  • DS Master: 2 replicas in a StatefulSet for availability and failover, reusing stable addresses such as ds-master-0.ds-master:5678 after restart.
  • DS Registry: a separate 3-member etcd 3.6.14 cluster, distinct from Kubernetes control-plane etcd.

We encountered scheduling problems during Master maintenance, restarts and replica-count changes. The controlled reproduction kept DS at 3.4.2 throughout; it did not involve a mixed-version upgrade. We temporarily reduced the deployment to 1 Master afterward. The proposed HA patch has not been deployed to this environment.

Restarting a Master at the same address

In a separate controlled run on 2026-09-16 (all times below are UTC+8):

  1. Restore the Master StatefulSet to 2 replicas and check both Pods.
  2. Restart ds-master-0 by deleting its Pod, allowing the StatefulSet to recreate it at the same address.
  3. Observe both Masters' role transitions and serial-coordinator logs alongside the etcd selector's lease and watch events.

The recreated Master 0 became ACTIVE while the coordinator selector was still attached to the predecessor's lease.

Both Masters logged ACTIVE and started their coordinators:

# ds-master-0
[WI-0][TI-0] - 2026-09-16 15:08:47.609 INFO  [Master-Server] o.a.d.r.a.h.DefaultServerStatusChangeListener:[27] - The status is active now.
[WI-0][TI-0] - 2026-09-16 15:08:47.610 INFO  [Master-Server] o.a.d.s.m.e.w.s.WorkflowSerialCoordinator:[90] - WorkflowSerialCoordinator started...

# ds-master-1
[WI-0][TI-0] - 2026-09-16 15:08:48.281 INFO  [grpc-default-executor-0] o.a.d.r.a.h.DefaultServerStatusChangeListener:[27] - The status is active now.
[WI-0][TI-0] - 2026-09-16 15:08:48.282 INFO  [grpc-default-executor-0] o.a.d.s.m.e.w.s.WorkflowSerialCoordinator:[90] - WorkflowSerialCoordinator started...

The etcd watch recorded deletion of the old selector, followed by Master 1 writing a new selector. Selected decoded fields:

DELETE /services/dolphinscheduler/nodes/master-coordinator
  mod_revision: 4808188
  value: ""
  prev_value: ds-master-0.ds-master:5678
  prev_lease: 2559627622716187248
  prev_mod_revision: 4807750

PUT /services/dolphinscheduler/nodes/master-coordinator
  mod_revision: 4808190
  value: ds-master-1.ds-master:5678
  lease: 2559627622716203084

More than 7 minutes later, both coordinators were still processing the same workflow instance:

# ds-master-0
[WI-0][TI-0] - 2026-09-16 15:16:18.357 INFO  [WorkflowSerialCoordinator-Thread] o.a.d.s.m.e.w.s.SerialCommandPriorityHandler:[54] - Stop the pre WorkflowInstance: 109018 due to the workflow using SERIAL_PRIORITY strategy

# ds-master-1
[WI-0][TI-0] - 2026-09-16 15:16:18.693 INFO  [WorkflowSerialCoordinator-Thread] o.a.d.s.m.e.w.s.SerialCommandPriorityHandler:[54] - Stop the pre WorkflowInstance: 109018 due to the workflow using SERIAL_PRIORITY strategy

There are recurring entries between these points, so this was sustained coordinator overlap. These logs do not establish duplicate business writes. The overlap also involved the etcd REMOVE payload issue and failure to demote after losing an election; address reuse was the initial ownership problem, not the entire failure chain.

Related scale-down observation

In an earlier round, we scaled from 2 Masters to 1. This exposed the separate REMOVE-event problem tracked in #18640/#18641; it is not the same experiment as the restart above.

Before recovery, the database snapshot showed an unassigned workflow waiting to run:

# 2026-09-16 15:00:16.984562+08; selected fields from the database snapshot
id=111167, state=14 (SERIAL_WAIT), host=<empty>, end_time=<empty>
start_time=2026-09-16 15:00:00.030

The remaining Master logged:

[WI-0][TI-0] - 2026-09-16 15:00:32.273 ERROR [grpc-default-executor-28] o.a.d.s.m.c.AbstractClusterSubscribeListener:[36] - Unknown cluster change event: Event(watchedPath=/nodes/master, eventPath=/nodes/master/ds-master-1.ds-master:5678, eventData=, type=REMOVE)

We initiated a restart of the remaining Master at approximately 15:00:47, using the same DS 3.4.2 image. The delayed instance then completed:

[WI-111167][TI-0] - 2026-09-16 15:03:15.108 INFO  [ds-workflow-eventbus-worker-6] o.a.d.s.m.e.w.s.AbstractWorkflowStateAction:[180] - Success set WorkflowExecuteRunnable: realtime_compare_2m-20260916150000030 state from: RUNNING_EXECUTION to SUCCESS

Database readback confirmed instance 111167 was SUCCESS at 15:03:15.105, and the command queue was empty at 15:04:21. Instance 111168 also completed at 15:03:24.923. Their elapsed times were approximately 195 and 85 seconds, compared with about 5 seconds for adjacent executions. These durations include the recovery process.

DS workflow history after recovery

This screenshot shows the final SUCCESS records after recovery. The fault-time SERIAL_WAIT state is established by the database snapshot; we did not capture a UI screenshot at that point.

Supplementary MySQL check

We also tested the original HA implementation with a real MySQL 8.0.46 Registry. The successor became ACTIVE, but selector row 1 still belonged to the predecessor client, with its value and update time unchanged. This was an isolated fixture deliberately retaining the predecessor, not a production JDBC restart.

This is why I think the new process should acquire its own selector rather than adopt the predecessor's role based on its address. Adding an instance timestamp addresses that ownership ambiguity. Handling a lost election and stopping the old coordinator are separate parts of the fix.

@michaellx1057

michaellx1057 commented Sep 16, 2026 •

Copy link
Copy Markdown
Contributor Author

While checking the demotion and re-election path, I found a related lifecycle issue in the existing Master coordinators. close() interrupts the worker thread, but a database call may continue after interruption. A subsequent start() can reset the shared running flag and let the old polling loop continue alongside the new one.

The exception → standby → retry path in this PR can exercise that sequence, so I have pushed a separate follow-up commit (3bb22b2) rather than treating the role-transition tests as sufficient.

The change keeps the existing dedicated-thread structure. Each run has its own stop flag. If the previous run is still finishing, start() records the restart request; the replacement starts after the previous run finishes its business work. A later close() cancels that request. This applies to both WorkflowSerialCoordinator and TaskGroupCoordinator, since the Master listener restarts both.

The added tests use real coordinators with deliberately blocked DAO/handler calls, including repeated role changes through MasterCoordinatorListener. They check that the old run cannot resume polling, remaining canceled work is discarded, and shutdown cancels a pending restart. The original regression fails before the fix; the affected tests pass on JDK 8.

This addresses overlap within the same coordinator instance. It does not cancel an already-issued database operation or provide fencing between different Masters. If the old database call never returns, its replacement remains pending rather than running concurrently.

@michaellx1057

michaellx1057 commented Sep 16, 2026 •

Copy link
Copy Markdown
Contributor Author

I tested a smaller candidate to check which changes are needed for this fix. 7fab651 now narrows this PR to the instance timestamp, ownership-based role reconciliation, election/role-update synchronization, and the coordinator thread handoff protection from 3bb22b2. It removes immediate demotion on election exceptions, the closed handling, and the related Alert resource-cleanup changes.

One clarification to my earlier coordinator comment: the overlap does not require the exception → standby → retry path. Without the handoff protection, tests also reproduced it during normal loss and reacquisition of leadership. A blocked DAO call was allowed to return after reactivation, and both the old and new threads continued polling. This is why I am keeping the handoff protection while removing the broader lifecycle changes.

Validation

The combined candidate passed 26 targeted tests on JDK 8. The final upstream revision was also clean-built through the 132-module Master/Alert reactor on actual Zulu JDK 8 1.8.0_504: all 36 distinct selected cases passed (13 HA, 6 serial-coordinator, 7 task-group-coordinator and 10 existing Alert cases), with no failures, errors or skips.

We also tested a 3.4.2 backport in the same Kubernetes / etcd deployment described above, including a leader restart at the same address and scaling from 2 Masters to 1. The backport also covers the serial-coordinator restartability fix already present in dev (#18623). It is not a deployment of dev jars into 3.4.2.

Selected fields from the validation logs follow. Times are UTC+8 on 2026-09-16; thread-dump details are omitted for readability. The thread lists contain only the serial and task-group coordinators.

20:56:28 — 2 Masters ready
selector = ds-master-0.ds-master:5678#1789563214717
ds-master-0 = [WorkflowSerialCoordinator-Thread, TaskGroupCoordinator-Thread]
ds-master-1 = []

21:01:36 — after restarting the active Master-0
selector = ds-master-1.ds-master:5678#1789563298359
ds-master-0 = []
ds-master-1 = [WorkflowSerialCoordinator-Thread, TaskGroupCoordinator-Thread]

21:06:11 — after scaling down and removing active Master-1
selector = ds-master-0.ds-master:5678#1789563580896
ds-master-0 = [WorkflowSerialCoordinator-Thread, TaskGroupCoordinator-Thread]

The rebuilt Master-0 remained standby while Master-1 owned the selector. After Master-1 was removed, Master-0 acquired the selector and started both coordinators.

Natural scheduled executions also completed successfully. For example, selected database fields show:

id=111563  host=ds-master-1.ds-master:5678  state=7 (SUCCESS)
start=21:04:00.051  end=21:04:06.056

id=111566  host=ds-master-0.ds-master:5678  state=7 (SUCCESS)
start=21:06:00.008  end=21:06:03.848

The error-command count remained at its baseline of 12 throughout the recorded checkpoints. The original image and single-Master topology were restored afterward.

An earlier attempt restored the original deployment before completing the restart check because the test's 90-second wait was shorter than the configured 120-second termination grace period. The results above are from the subsequent run with the corrected timeout.

These observations support the reduced scope for the reported restart and scale-down scenarios. The snapshots do not prove that no transient overlap occurred between observations, and the patch does not provide cross-Master fencing. The separate etcd REMOVE-value fix remains tracked in #18641.

@ruanwenjun

Copy link
Copy Markdown
Member

The current code looks too messy and feels overly AI-driven, making it difficult to tell exactly what problem it’s meant to solve.
An event-driven approach might be a better solution. The WorkflowSerialCoordinator only needs to ensure that all internal threads have finished its work before the close method returns. The higher-level AbstractHAServer is responsible for executing state-change events and controlling the active/

@michaellx1057

Copy link
Copy Markdown
Contributor Author

The current code looks too messy and feels overly AI-driven, making it difficult to tell exactly what problem it’s meant to solve. An event-driven approach might be a better solution. The WorkflowSerialCoordinator only needs to ensure that all internal threads have finished its work before the close method returns. The higher-level AbstractHAServer is responsible for executing state-change events and controlling the active/

@ruanwenjun Thanks for the suggestions. I've updated the implementation to use a single election worker and simplified the lifecycle logic.

Demotion and normal Master shutdown now share the same cleanup path: both coordinators are asked to stop before we wait for either worker to finish.

@michaellx1057

michaellx1057 commented Sep 17, 2026 •

Copy link
Copy Markdown
Contributor Author

I've updated the PR description to match the current implementation and completed another deployment test of 3afd40f together with #18641 (b47fc0b).

I used a minimal backport to our DolphinScheduler 3.4.2 deployment, running Java 8, Kubernetes 1.33.13 and a 3-member etcd 3.6.14 cluster. The sequence was 1→3 Masters, restarting the elected Master at the same address, then scaling back to 1.

The restart reached the retained-selector window. Selected observations from September 17, UTC+8:

18:13:10.612  recreated master-0 #1789639988110: STAND_BY
18:13:10.832  etcd still holds predecessor master-0 #1789639329898
18:13:12.779  master-1: ACTIVE

The recreated process did not inherit its predecessor’s role. Post-restart thread dumps showed both coordinator workers only on master-1. After scale-down, master-0 became the owner and its slots converged 3→2→1 without restarting that surviving Pod.

Three SERIAL_WAIT canary instances completed at each stage: 9 workflow runs / 27 Shell tasks, with one actual output marker per task log. The original image and complete StatefulSet configuration were restored afterward.

This supports the tested maintenance sequence. Complete live Java shutdown completion was not captured; controlled-DAO tests cover the coordinator shutdown interleavings separately. This was not a partition or all-backend test.

One separate issue remained visible: the broad member-prefix subscription also delivers selector events to the heartbeat parser. With the restored DELETE payload, selector REMOVE logs a caught parse error. Member removal and election still completed in this run.

@ruanwenjun

ruanwenjun commented Nov 6, 2026 •

Copy link
Copy Markdown
Member

@michaellx1057 OK, I understand so “close” can be understood as a graceful shutdown, while “requestStop” is a forced stop.
What if we made “close” a forced stop as well? Or what if we used a graceful shutdown when losing the leader role?

We would need to use transactions at the upper layer to ensure that even if multiple coordinators exist, the same work is not processed repeatedly. Even if we terminate the thread directly, we must ensure that no data in an intermediate state is generated.

@michaellx1057

Copy link
Copy Markdown
Contributor Author

Thanks, @ruanwenjun. requestStop() sets the stop flag and interrupts the worker; an in-flight DAO call can still finish. close() calls that same stop logic and then waits for the worker to exit. The two methods let us signal both coordinators before waiting for either one.

I agree that preventing duplicate work across Masters also needs database-level coordination. I’m keeping that work separate while refining the leadership handoff and reducing the remaining flag checks. I’ll verify the blocked-election and shutdown interleavings before posting the next patch.

@michaellx1057 michaellx1057 changed the title [Fix-18644][Registry] Reconcile HA roles using instance ownership [Fix-18644][Master] Fix HA handoff and serial command races Nov 8, 2026
@michaellx1057

Copy link
Copy Markdown
Contributor Author

We would need to use transactions at the upper layer

Thanks, @ruanwenjun. I've now included the database protection in this PR as a separate commit, e963be2, alongside the HA handoff changes in 3689d40.

The Serial handlers claim a WAITING row before inserting its Command in the same transaction. Discarding also checks WAITING; if another handler has already launched the record, the stale group stops and the next poll reads fresh state. Failed inserts and workflow-state updates roll back the corresponding claim or deletion.

On both MySQL 8.0.46 and PostgreSQL 18.4, the original handlers produced 2 Commands from 2 stale WAITING snapshots; the updated handlers produced 1. I also checked both launch/discard orderings and rollback paths. These checks use real database transactions, with stop RPC stubbed.

The local validation passed 81 targeted tests on JDK 8, 30 isolated etcd runs and 27 database checks per engine. I've updated the PR description with the results and limits.

The new CI workflows are awaiting maintainer approval. Could you approve the runs when convenient? I'll re-request review once CI passes.

@ruanwenjun ruanwenjun 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.

Since we’ve already controlled concurrency at the database layer using transactions, I don’t think any changes are needed at the higher levels.

@ruanwenjun

Copy link
Copy Markdown
Member

@michaellx1057 And then do we still need requestStop method?

@michaellx1057

Copy link
Copy Markdown
Contributor Author

And then do we still need requestStop method?

Thanks, @ruanwenjun. I've removed the separate requestStop() API in 7532ee7 and retained the conditional database updates and transactions for SerialCommand claim/discard. Each coordinator now interrupts and waits for its own worker in close(). The Master closes TaskGroup first, then Serial.

The tradeoff is that Serial may keep handling work while TaskGroup drains; a stuck TaskGroup call also delays Serial's stop. This accepts sequential graceful shutdown, with the database guards handling races over the same serial record.

All 48 comparison runs across both stop variants on MySQL and PostgreSQL passed. The simplified version also passed 77 targeted test executions on JDK 8, including two shutdown scenarios repeated three times each.

@ruanwenjun ruanwenjun 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.

Please remove the unrelated change.

@michaellx1057

Copy link
Copy Markdown
Contributor Author

Thanks @ruanwenjun. I've reworked the HA handling to use two workers: one serializes role changes and Coordinator callbacks, while the other performs Registry operations. This lets Master process its own selector removal even when an earlier Registry check is blocked. Each Registry lock is acquired and released on the same thread, and version checks prevent superseded results from publishing ACTIVE.

Master still demotes on its own selector removal; Alert checks ownership first. Unresolved ownership recovery continues without requiring another REMOVE notification. Shutdown cancels pending recovery and rejects late results. The database claim protection remains in place.

There are still trade-offs: blocked Registry calls can delay recovery, and delayed watch notifications can leave a temporary overlap between nodes. Coordinator shutdown remains synchronous, so blocked DAO work can also delay completion. This does not provide cluster-wide fencing.

Local validation passed:

  • 117 relevant unit tests on JDK 8, with no failures or skips.
  • 16 isolated etcd scenarios, each repeated 3 times: 48/48 passed.
  • Real ZooKeeper, JDBC/MySQL and JDBC/PostgreSQL compatibility flows, each repeated 3 times: 9/9 passed.

These covered startup races, stale results, delayed REMOVE, recovery after 20 consecutive read failures, and shutdown interleavings. The cross-Registry runs confirmed demotion, takeover, rejection of old results and lock release. We observed no overlap between old and replacement Coordinator workers on the same Master in these runs. These runs used real Registry services and Coordinator threads with test DAOs.

@michaellx1057

Copy link
Copy Markdown
Contributor Author

Thanks @ruanwenjun. I've switched the HA queue to dolphinscheduler-eventbus. One worker serializes role changes and listener callbacks; another performs one Registry check at a time. This lets Master handle its own selector removal while an earlier Registry call is blocked.

Retries use delayed events. Master still demotes first; Alert checks ownership first. Close wakes the role worker without interrupting admitted callbacks or Registry calls. The database guards and coordinator shutdown/restart constraints remain unchanged.

I've also documented the worker/lock contract and the blocking API.

JDK 8 checks passed: 108 targeted tests, with the 40-test HA suite run 3 times. The runtime implementation also passed 42 isolated etcd runs and 9 cross-Registry runs; the PR description records the validation scope and rounds.

@michaellx1057 michaellx1057 changed the title [Fix-18644][Master] Fix HA handoff and serial command races [Fix-18644][Master] Fix serial command races and restart identity reuse Nov 12, 2026

@ruanwenjun ruanwenjun 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

Run ownership reconciliation on a single election worker. Request both coordinator workers to stop before joining either on demotion and normal Master shutdown. Add deterministic lifecycle and shutdown regression coverage.
- Reconcile ownership before publishing roles
- Stop both coordinators without waiting for blocked election calls
- Track deferred recovery across demotion and close
- Claim WAITING rows before inserting Commands in one transaction
- Reject stale discards and retry groups from fresh snapshots
- Roll back failed inserts and workflow state updates
Use the requested DAO/Mapper name and record stale WAITING snapshots in the shared launch/discard transaction helpers.
Remove the separate requestStop API and keep worker interruption and drain in close. Retain database claim/discard protection and cover close during a PRIORITY RPC and Master shutdown with pending recovery.
Serialize role changes and coordinator callbacks on one worker while keeping
Registry lock operations on a separate worker. Reject superseded results,
retain ownership recovery, and handle cancellation and listener self-close.

Verified on JDK 8 with 117 targeted tests, 48 isolated etcd runs, and
9 ZooKeeper/JDBC compatibility runs. Business DAOs in HA probes are stubbed.
Replace custom HA role scheduling with the existing EventBus. Keep one
role worker for transitions and callbacks and one Registry worker for
single ownership checks. Preserve current-result admission, bounded
ordinary retries and unresolved own-removal recovery.

Wake the role worker on close without interrupting admitted callbacks or
Registry calls. Document the concurrency contract and cover repeated
external close calls waiting for the same listener.

Validated on JDK 8 with 108 targeted tests and three runs of the 40-test
HA suite. The runtime implementation passed 42 isolated etcd runs and
9 cross-Registry runs before the final documentation/test-only cleanup.
@ruanwenjun

Copy link
Copy Markdown
Member

@SbloodyS PTAL.

@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.

+1

@ruanwenjun
ruanwenjun merged commit 2ad4d7e into apache:dev Nov 12, 2026
120 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants