Risk: Excessive log generation may cause disk full.
Workaround: Restart the problematic CDC server.
What did you do?
Run a TiCDC new-architecture failover/scale test with 3 CDC captures, 4
MySQL-sink changefeeds, and continuous DML. During a maintainer handoff, scale
the CDC cluster out so a new empty capture joins while the old maintainer is
being removed.
The failure was observed with this sequence:
- A new capture (
97382844-bec6-4a89-bef1-7187d5e84578) joined the cluster.
- The coordinator started moving
default/p0-scale-04 from capture
63658e62-2196-4bb1-b580-a121c869d1f8 to the new capture.
- The old maintainer received the remove request, entered removing mode, and
its operator controller became quiescing.
- The old maintainer's still-running balance scheduler selected a dispatcher
move to the new capture.
This was reproduced during the second scale-out of a 55-minute chaos run. The
test included ordinary non-race binaries only.
What did you expect to see?
The old maintainer should finish its handoff without scheduling ordinary move
operators. If an operator admission attempt is rejected, the balance round
must make progress or return; it must not retry the same unchanged candidate
in a tight loop.
What did you see instead?
The following log sequence shows the new capture, maintainer handoff, and the
first rejected dispatcher move (timestamps are CST):
[2026/08/21 17:46:00.628 +08:00] [INFO] [operator_controller.go:537] ["add operator to running queue"] [role=coordinator] [operator="move maintainer operator: default/p0-scale-04, origin:63658e62-2196-4bb1-b580-a121c869d1f8, dest:97382844-bec6-4a89-bef1-7187d5e84578"]
[2026/08/21 17:46:00.652 +08:00] [INFO] [maintainer_manager_maintainers.go:355] ["received remove maintainer request"] [changefeedID=default/p0-scale-04]
[2026/08/21 17:46:01.252 +08:00] [INFO] [maintainer.go:585] ["changefeed maintainer closed"] [changefeedID=default/p0-scale-04] [checkpointTs=468531428390338831] [removed=true]
[2026/08/21 17:46:01.269 +08:00] [INFO] [operator_controller.go:237] ["add operator failed, controller is quiescing"] [role=maintainer] [changefeedID=default/p0-scale-04] [dispatcherID=155117822891070323075970390249676308648] [operator="move dispatcher operator: 155117822891070323075970390249676308648, origin:63658e62-2196-4bb1-b580-a121c869d1f8, dest:97382844-bec6-4a89-bef1-7187d5e84578"]
The final line was then emitted continuously for about 21 minutes, until CDC
8300 was restarted. The last archived occurrence was at
2026/08/21 18:07:22.193 +08:00.
During that interval CDC 8300 produced 166 rotated log files totaling 48.6
GiB. A representative 300 MiB file contained 800,467 lines and covered only
8.738 seconds (17:46:05.540 through 17:46:14.278), approximately 91,600
lines/s and 34.3 MiB/s. The process did not recover after workload stop,
changefeed catch-up, or checksum; restarting CDC 8300 stopped the loop.
There was no observed data-plane corruption: all 4 changefeeds caught up,
there was no checkpoint regression or duplicate active maintainer metric, and
checksum passed for all 64 tables. The impact was a control-plane livelock plus
high CPU, log, and disk consumption.
Root cause analysis
This is a non-progress loop spanning the maintainer lifecycle and generic
balance helper:
onRemoveMaintainer
sets removing=true and calls EnterRemovingMode.
EnterRemovingMode
quiesces operator admission, but it does not stop the ordinary scheduler task
handles. Those handles are canceled only by
Controller.Stop.
- The balance scheduler's
doMove
forwards the boolean returned by AddOperator.
AddOperator
correctly rejects the move while quiescing, logs at INFO, and returns false.
The move operator is never admitted.
- In
pkg/scheduler.Balance,
target load, victim load, victim task list, and movedSize are updated only
when move(...) returns true. On false, the exact unchanged target and
victim are reinserted into their heaps and the unbounded for loop
immediately selects the same pair again.
Cancellation cannot stop this loop after Execute has entered it:
TaskHandle.Cancel
only sets the cancellation state, and the thread pool checks it only before
calling
task.Execute().
It cannot preempt an Execute call already spinning inside Balance.
The direct liveness defect is therefore the missing false-return progress/exit
rule in Balance. Removing mode leaving normal schedulers runnable makes this
path reachable, and the unthrottled INFO message amplifies it into a log storm.
Related: #5403 introduced the quiescing admission gate and protects against
unsafe control-plane work during handoff, but the scheduler/rejection
interaction above remains unbounded.
Suggested fix and regression coverage
- Make
Balance bounded when the move callback returns false (for example,
return from the current round or track attempted candidates so an unchanged
pair cannot be selected forever).
- Stop ordinary scheduler task handles when a maintainer enters removing mode,
while preserving only the DDL-trigger close path required by the handoff.
- Rate-limit or downgrade repeated quiescing rejection logs as defense in
depth.
- Add a unit test where an always-false move callback must return promptly with
zero moves.
- Add a lifecycle test for removing/quiescing maintainer plus node scale-out;
it must neither admit ordinary moves nor leave a spinning scheduler task.
Versions of the cluster
Upstream TiDB:
Release Version: v8.5.5
Git Commit Hash: 1fa258b833ff113883beeba40bc130be7ce66610
Upstream TiKV:
Release Version: 8.5.5
Git Commit Hash: a0234aad114258b60e0ec23c17486c79845299be
TiCDC:
Release Version: v8.5.4-release
Git Commit Hash: 799216ba3cddd4611b48d4790f1554193349f858
Source branch: upstream/release-8.5
Failpoint Build: false
Kernel Type: Classic
Risk: Excessive log generation may cause disk full.
Workaround: Restart the problematic CDC server.
What did you do?
Run a TiCDC new-architecture failover/scale test with 3 CDC captures, 4
MySQL-sink changefeeds, and continuous DML. During a maintainer handoff, scale
the CDC cluster out so a new empty capture joins while the old maintainer is
being removed.
The failure was observed with this sequence:
97382844-bec6-4a89-bef1-7187d5e84578) joined the cluster.default/p0-scale-04from capture63658e62-2196-4bb1-b580-a121c869d1f8to the new capture.its operator controller became quiescing.
move to the new capture.
This was reproduced during the second scale-out of a 55-minute chaos run. The
test included ordinary non-race binaries only.
What did you expect to see?
The old maintainer should finish its handoff without scheduling ordinary move
operators. If an operator admission attempt is rejected, the balance round
must make progress or return; it must not retry the same unchanged candidate
in a tight loop.
What did you see instead?
The following log sequence shows the new capture, maintainer handoff, and the
first rejected dispatcher move (timestamps are CST):
The final line was then emitted continuously for about 21 minutes, until CDC
8300 was restarted. The last archived occurrence was at
2026/08/21 18:07:22.193 +08:00.During that interval CDC 8300 produced 166 rotated log files totaling 48.6
GiB. A representative 300 MiB file contained 800,467 lines and covered only
8.738 seconds (
17:46:05.540through17:46:14.278), approximately 91,600lines/s and 34.3 MiB/s. The process did not recover after workload stop,
changefeed catch-up, or checksum; restarting CDC 8300 stopped the loop.
There was no observed data-plane corruption: all 4 changefeeds caught up,
there was no checkpoint regression or duplicate active maintainer metric, and
checksum passed for all 64 tables. The impact was a control-plane livelock plus
high CPU, log, and disk consumption.
Root cause analysis
This is a non-progress loop spanning the maintainer lifecycle and generic
balance helper:
onRemoveMaintainersets
removing=trueand callsEnterRemovingMode.EnterRemovingModequiesces operator admission, but it does not stop the ordinary scheduler task
handles. Those handles are canceled only by
Controller.Stop.doMoveforwards the boolean returned by
AddOperator.AddOperatorcorrectly rejects the move while quiescing, logs at INFO, and returns false.
The move operator is never admitted.
pkg/scheduler.Balance,target load, victim load, victim task list, and
movedSizeare updated onlywhen
move(...)returns true. On false, the exact unchanged target andvictim are reinserted into their heaps and the unbounded
forloopimmediately selects the same pair again.
Cancellation cannot stop this loop after
Executehas entered it:TaskHandle.Cancelonly sets the cancellation state, and the thread pool checks it only before
calling
task.Execute().It cannot preempt an
Executecall already spinning insideBalance.The direct liveness defect is therefore the missing false-return progress/exit
rule in
Balance. Removing mode leaving normal schedulers runnable makes thispath reachable, and the unthrottled INFO message amplifies it into a log storm.
Related: #5403 introduced the quiescing admission gate and protects against
unsafe control-plane work during handoff, but the scheduler/rejection
interaction above remains unbounded.
Suggested fix and regression coverage
Balancebounded when the move callback returns false (for example,return from the current round or track attempted candidates so an unchanged
pair cannot be selected forever).
while preserving only the DDL-trigger close path required by the handoff.
depth.
zero moves.
it must neither admit ordinary moves nor leave a spinning scheduler task.
Versions of the cluster
Upstream TiDB:
Upstream TiKV:
TiCDC: