Skip to content

schedule: respect limits in concurrent patrol workers#10925

Open
rleungx wants to merge 9 commits into
tikv:masterfrom
rleungx:patrol-region-worker-count-master-fix
Open

schedule: respect limits in concurrent patrol workers#10925
rleungx wants to merge 9 commits into
tikv:masterfrom
rleungx:patrol-region-worker-count-master-fix

Conversation

@rleungx

@rleungx rleungx commented Jun 22, 2026

Copy link
Copy Markdown
Member

What problem does this PR solve?

Issue Number: close #10926

When schedule.patrol-region-worker-count is greater than 1, multiple patrol workers can check operator counts concurrently and generate operators before any worker adds its operator to the operator controller. This can make patrol region checking exceed schedule limits such as replica-schedule-limit.

What is changed and how does it work?

schedule: respect limits in concurrent patrol workers

This PR:

  • serializes the patrol-worker and priority-region operator admission paths with one controller mutex
  • keeps the existing checker flow and schedule-limit checks inside CheckRegion, then enqueues the generated operators while still holding the same admission mutex
  • adds a regression test for concurrent patrol workers respecting replica-schedule-limit

Check List

Tests

  • Unit test
go test ./pkg/schedule/checker -count=1
go test ./server/cluster -run '^TestPatrolRegionConcurrencyRespectReplicaLimit$' -count=1 -timeout=2m

Code changes

  • No configuration change
  • No API change
  • No persistent data change

Side effects

  • No user-facing side effects

Related changes

  • Need to cherry-pick to the release branch

Release note

Fix an issue where concurrent patrol region workers could exceed schedule limits.

@ti-chi-bot ti-chi-bot Bot added do-not-merge/needs-linked-issue dco-signoff: yes Indicates the PR's author has signed the dco. do-not-merge/release-note-label-needed Indicates that a PR should not merge because it's missing one of the release note labels. labels Jun 22, 2026
@ti-chi-bot

ti-chi-bot Bot commented Jun 22, 2026

Copy link
Copy Markdown
Contributor

[APPROVALNOTIFIER] This PR is NOT APPROVED

This pull-request has been approved by:
Once this PR has been reviewed and has the lgtm label, please assign okjiang for approval. For more information see the Code Review Process.
Please ensure that each of them provides their approval before proceeding.

The full list of commands accepted by this bot can be found here.

Details Needs approval from an approver in each of these files:

Approvers can indicate their approval by writing /approve in a comment
Approvers can cancel approval by writing /approve cancel in a comment

@ti-chi-bot ti-chi-bot Bot added the size/L Denotes a PR that changes 100-499 lines, ignoring generated files. label Jun 22, 2026
@coderabbitai

coderabbitai Bot commented Jun 22, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

The checker controller's patrol-region loop now tracks in-flight work with an atomic pending count instead of channel length, gating batch starts and worker reconfiguration on idleness. Operator admission is refactored around a new operatorCandidate/operatorLimit model and serialized under a mutex to respect schedule limits. Tests added for halt-aware admission and concurrent replica-limit behavior.

Changes

Patrol Region Concurrency Refactor

Layer / File(s) Summary
Controller metadata and limit types
pkg/schedule/checker/checker_controller.go
Controller gains an operatorMu mutex, NewController initializes workerCount from config, and new operatorLimit/operatorCandidate types are introduced with supporting imports.
PatrolRegionContext pending tracking
pkg/schedule/checker/checker_controller.go
PatrolRegionContext adds an atomic pendingCount, region submission goes through submit, isIdle/waitIdle compute idleness, and workers decrement pending count after processing each region; IsPatrolRegionChanEmpty delegates to isIdle().
Patrol loop idle gating
pkg/schedule/checker/checker_controller.go
PatrolRegions skips starting a new batch when not idle, avoids draining on scheduling halt, waits for in-flight work via waitIdle on scan completion, and updatePatrolWorkersIfNeeded only reconfigures workers when idle.
Operator candidate construction
pkg/schedule/checker/checker_controller.go
CheckRegion becomes a thin wrapper over buildOperatorCandidate, priority-region checks route through candidate admission, and per-op-kind limit helpers (replicaLimit, affinityLimit, mergeLimit) feed limit metadata.
Serialized operator admission
pkg/schedule/checker/checker_controller.go
tryAddOperators respects scheduling halt and manages the pending cache, while admitWaitingOperators locks operatorMu, applies limit/store checks, and enqueues waiting operators or defers them.
Concurrency and halt tests
pkg/schedule/checker/checker_controller_test.go, server/cluster/cluster_test.go
New tests verify admitWaitingOperators skips admission when scheduling is halted, and that concurrent patrol workers respect ReplicaScheduleLimit without exceeding it.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant PatrolRegions
  participant PatrolRegionContext
  participant WorkerGoroutine
  participant Controller

  PatrolRegions->>PatrolRegionContext: submit(region)
  PatrolRegionContext->>WorkerGoroutine: send region on regionChan
  WorkerGoroutine->>Controller: tryAddOperators(region)
  Controller->>Controller: buildOperatorCandidate(region)
  Controller->>Controller: admitWaitingOperators(candidate) under operatorMu
  Controller->>Controller: check per-op-kind limit / halt state
  alt limit reached or halted
    Controller->>Controller: defer region into pendingProcessedRegions
  else admitted
    Controller->>Controller: AddWaitingOperator(ops)
  end
  WorkerGoroutine->>PatrolRegionContext: pendingCount--
  PatrolRegions->>PatrolRegionContext: waitIdle()
Loading

Suggested reviewers: JmPotato, niubell

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 25.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The changes address #10926 by preventing concurrent patrol workers from exceeding schedule limits and by covering the regression with tests.
Out of Scope Changes check ✅ Passed The diff stays focused on patrol-worker scheduling and related tests, with no clear unrelated code changes.
Title check ✅ Passed The title clearly matches the main change: preventing concurrent patrol workers from exceeding schedule limits.
Description check ✅ Passed The description covers the problem, change summary, tests, checklist items, and release note in the expected template.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
pkg/schedule/checker/checker_controller.go (1)

490-501: 🧹 Nitpick | 🔵 Trivial | 💤 Low value

Consider adding a comment or warning for the default case.

The default returning 0 means any operator with an unhandled OpKind would be silently blocked (since count >= 0 is always true). While current code paths only use handled kinds, this could cause subtle bugs if new checker types are added.

A comment explaining this defensive behavior would help maintainers:

📝 Suggested documentation
 func (c *Controller) getOperatorLimit(kind operator.OpKind) uint64 {
 	switch kind {
 	case operator.OpReplica:
 		return c.conf.GetReplicaScheduleLimit()
 	case operator.OpMerge:
 		return c.conf.GetMergeScheduleLimit()
 	case operator.OpAffinity:
 		return c.conf.GetAffinityScheduleLimit()
 	default:
+		// Unknown kinds return 0, which blocks all such operators.
+		// This is defensive - all callers should use known kinds with limits.
 		return 0
 	}
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pkg/schedule/checker/checker_controller.go` around lines 490 - 501, Add a
comment to the default case in the getOperatorLimit function that explains why
returning 0 is intentional defensive behavior. The comment should clarify that
returning 0 will silently block any operator with an unhandled OpKind, and note
that this behavior should be considered when adding new operator types in the
future to prevent subtle bugs.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@pkg/schedule/checker/checker_controller.go`:
- Around line 490-501: Add a comment to the default case in the getOperatorLimit
function that explains why returning 0 is intentional defensive behavior. The
comment should clarify that returning 0 will silently block any operator with an
unhandled OpKind, and note that this behavior should be considered when adding
new operator types in the future to prevent subtle bugs.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: e463cd2e-aa38-450e-b0e1-52dd1b762a36

📥 Commits

Reviewing files that changed from the base of the PR and between 4ae1408 and 7f5eeae.

📒 Files selected for processing (2)
  • pkg/schedule/checker/checker_controller.go
  • server/cluster/cluster_test.go

@ti-chi-bot ti-chi-bot Bot added release-note Denotes a PR that will be considered when it comes time to generate release notes. do-not-merge/needs-triage-completed and removed do-not-merge/needs-linked-issue do-not-merge/release-note-label-needed Indicates that a PR should not merge because it's missing one of the release note labels. labels Jun 22, 2026
@rleungx

rleungx commented Jun 22, 2026

Copy link
Copy Markdown
Member Author

/retest

Signed-off-by: Ryan Leung <rleungx@gmail.com>
@rleungx rleungx force-pushed the patrol-region-worker-count-master-fix branch from 7f5eeae to e48894e Compare June 23, 2026 04:36
@ti-chi-bot ti-chi-bot Bot added size/XL Denotes a PR that changes 500-999 lines, ignoring generated files. and removed size/L Denotes a PR that changes 100-499 lines, ignoring generated files. labels Jun 23, 2026
Signed-off-by: Ryan Leung <rleungx@gmail.com>
@rleungx rleungx force-pushed the patrol-region-worker-count-master-fix branch from 13907a2 to 16c8144 Compare July 6, 2026 09:16
rleungx added 2 commits July 6, 2026 17:52
Signed-off-by: Ryan Leung <rleungx@gmail.com>
Signed-off-by: Ryan Leung <rleungx@gmail.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@tests/server/apiv2/handlers/affinity_test.go`:
- Around line 142-157: The waitForAffinityGroups helper is using require-based
assertions inside the testutil.Eventually callback, which can abort retries on
transient failures. Update the polling logic in waitForAffinityGroups to use
EventuallyWithT or move the ReadGetJSON/response checks outside the retry
closure so failures are handled without stopping the retry loop. Keep the fix
localized to waitForAffinityGroups and its testutil.Eventually usage.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: afcb1072-a6af-4db5-92a8-482c5ade043b

📥 Commits

Reviewing files that changed from the base of the PR and between 16c8144 and 71fe19b.

📒 Files selected for processing (1)
  • tests/server/apiv2/handlers/affinity_test.go

Comment thread tests/server/apiv2/handlers/affinity_test.go Outdated
@rleungx

rleungx commented Jul 6, 2026

Copy link
Copy Markdown
Member Author

/test pull-unit-test-next-gen-3

@rleungx

rleungx commented Jul 6, 2026

Copy link
Copy Markdown
Member Author

/test pull-unit-test-next-gen-2

@rleungx

rleungx commented Jul 6, 2026

Copy link
Copy Markdown
Member Author

/test pull-integration-realcluster-test

@codecov

codecov Bot commented Jul 6, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 84.00000% with 20 lines in your changes missing coverage. Please review.
✅ Project coverage is 79.17%. Comparing base (4ae1408) to head (c8040fa).
⚠️ Report is 39 commits behind head on master.

Additional details and impacted files
@@            Coverage Diff             @@
##           master   #10925      +/-   ##
==========================================
+ Coverage   79.15%   79.17%   +0.02%     
==========================================
  Files         540      541       +1     
  Lines       74801    75753     +952     
==========================================
+ Hits        59212    59981     +769     
- Misses      11405    11521     +116     
- Partials     4184     4251      +67     
Flag Coverage Δ
unittests 79.17% <84.00%> (+0.02%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Signed-off-by: Ryan Leung <rleungx@gmail.com>
if !c.patrolRegionContext.isIdle() {
continue
}
c.metrics.patrolRegionChannelSize.Set(float64(len(c.patrolRegionContext.regionChan)))

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.

This metric now effectively only records 0. The code checks isIdle() before setting patrolRegionChannelSize, so by the time we reach this line there should be no queued or in-flight patrol work, and len(regionChan) no longer reflects patrol backlog.

Could we either sample before the idle check, or change the metric to report the pending/in-flight count instead? phaseWaitForChannel also no longer gets observed after this change, so it should either be restored or removed to avoid misleading metrics.

return &result
}

func waitForAffinityGroups(re *require.Assertions, url string, groupIDs ...string) {

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.

Could we drop the changes in this file, or rebase and keep the current master version? This PR now has a content conflict with latest master in tests/server/apiv2/handlers/affinity_test.go, and master already contains an equivalent tryGetAffinityGroups / waitForAffinityGroups polling helper.

The affinity test stabilization is also unrelated to the patrol-worker schedule-limit fix, so keeping it in this PR adds conflict surface and review noise.

Signed-off-by: Ryan Leung <rleungx@gmail.com>
@rleungx rleungx requested a review from lhy1024 July 9, 2026 07:00
})
c.updateTickerIfNeeded(ticker)
pendingRegionCount := c.patrolRegionContext.pendingRegionCount()
c.metrics.patrolRegionChannelSize.Set(float64(pendingRegionCount))

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.

This gauge now reports the pending patrol work count (queued plus in-flight regions), not the raw channel length anymore. Could we update the metric help/comment in metrics.go to match the new semantics? Keeping the existing metric name for compatibility seems fine, but the current help text still says "Size of patrol region channel", which is no longer accurate.

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

the rest LGTM

Signed-off-by: Ryan Leung <rleungx@gmail.com>
@rleungx rleungx requested a review from lhy1024 July 9, 2026 09:13
}

func (l operatorLimit) reached(opController *operator.Controller) bool {
return l.getLimit != nil && opController.OperatorCount(l.kind) >= l.getLimit()

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.

I think the schedule-limit recheck is still incomplete for merge / affinity-merge candidates.

operatorLimit.reached() only checks OperatorCount(kind) >= limit before admission, but a candidate can contain multiple operators. Both MergeChecker.Check() and affinity merge return a pair, and AddWaitingOperator() can admit both operators together. For example, with merge-schedule-limit = 3 and current OperatorCount(OpMerge) = 2, this check still passes, then the next merge pair is admitted and the final count becomes 4.

This seems within this PR's stated scope because the PR says it re-checks replica, merge, and affinity schedule limits immediately before AddWaitingOperator.

Could we either account for the candidate size when checking the limit, e.g. reject current + candidateCount > limit, or clarify that merge / affinity-merge limits may still exceed the configured limit by one pair? It would also be good to add a regression test for this boundary case.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Addressed in 8e7ec80 with candidate-count-aware capacity checks for both merge and affinity candidates, plus the odd-limit merge regression test.

Signed-off-by: Ryan Leung <rleungx@gmail.com>
@ti-chi-bot ti-chi-bot Bot added size/M Denotes a PR that changes 30-99 lines, ignoring generated files. and removed size/XL Denotes a PR that changes 500-999 lines, ignoring generated files. labels Jul 9, 2026
@rleungx rleungx requested a review from lhy1024 July 9, 2026 10:25
c.RemovePendingProcessedRegion(id)
return
}
c.operatorMu.Lock()

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.

The new coarse-grained operatorMu does fix the stale-count race between patrol workers, but I think the merge / affinity-merge boundary case is still not covered.

CheckRegion() still only checks OperatorCount(OpMerge) < GetMergeScheduleLimit() before returning a merge pair. Since a merge candidate contains two operators, merge-schedule-limit = 3 and current OperatorCount(OpMerge) = 2 can still admit another pair and end up with 4 merge operators.

I verified this on the latest head with the following focused test, which fails because the final merge count becomes 4:

package checker

import (
	"context"
	"testing"

	"github.com/stretchr/testify/require"

	"github.com/tikv/pd/pkg/mock/mockcluster"
	"github.com/tikv/pd/pkg/mock/mockconfig"
	"github.com/tikv/pd/pkg/schedule/hbstream"
	"github.com/tikv/pd/pkg/schedule/operator"
)

func TestTryAddOperatorsRejectsMergePairPastLimit(t *testing.T) {
	re := require.New(t)
	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()

	opt := mockconfig.NewTestOptions()
	tc := mockcluster.NewCluster(ctx, opt)
	tc.SetMergeScheduleLimit(3)
	tc.SetSplitMergeInterval(0)
	for storeID := uint64(1); storeID <= 3; storeID++ {
		tc.AddRegionStore(storeID, 0)
	}
	tc.AddLeaderRegionWithRange(1, "", "a", 1, 2, 3)
	tc.AddLeaderRegionWithRange(2, "a", "b", 1, 2, 3)
	tc.AddLeaderRegionWithRange(3, "b", "c", 1, 2, 3)
	tc.AddLeaderRegionWithRange(4, "c", "d", 1, 2, 3)

	stream := hbstream.NewTestHeartbeatStreams(ctx, tc, false)
	defer stream.Close()
	oc := operator.NewController(ctx, tc.GetBasicCluster(), tc.GetSharedConfig(), stream)
	controller := NewController(ctx, tc, tc.GetCheckerConfig(), oc)

	controller.tryAddOperators(tc.GetRegion(1))
	re.Equal(uint64(2), oc.OperatorCount(operator.OpMerge))

	controller.tryAddOperators(tc.GetRegion(3))
	re.LessOrEqual(oc.OperatorCount(operator.OpMerge), uint64(3))
}

Could we either account for the candidate size when checking merge / affinity limits, or explicitly document that these limits may be exceeded by one pair?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in 8e7ec80. Merge and affinity candidates now check their full candidate count against the remaining schedule-limit capacity (candidateCount <= limit - current) before admission, so a pair is rejected when only one slot remains. I added the provided merge boundary regression plus focused capacity cases. Verified with the full checker package, the existing patrol replica-limit regression, focused race tests, and make static.

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.

I want to clarify my earlier suggestion here. The concern about merge candidates being different from single-operator candidates is still valid, but suggesting a strict current + candidateCount <= limit check was too strict if we want to preserve the existing merge-pair semantics.

The original issue this PR is fixing is the stale-count race between concurrent patrol workers. It is not necessarily trying to change the existing single-threaded behavior where one merge schedule produces two operators. Existing behavior/tests seem to allow one merge pair to be created when merge-schedule-limit = 1, and existing TestPatrolRegionConcurrency uses mergeScheduleLimit = 15 and waits for at least 15 operators.

With the current len(ops)-based capacity check, an odd merge limit can stop below the configured limit. For example, with merge-schedule-limit = 15, the merge checker can stop at 14 because the next merge pair has 2 operators. I verified this on the latest head by running:

make gotest GOTEST_ARGS='./server/cluster -run TestPatrolRegionConcurrency -count=1 -timeout=2m'

and TestPatrolRegionConcurrency fails because the len(oc.GetOperators()) >= mergeScheduleLimit condition is never satisfied.

Could we preserve the existing merge-pair semantics while still serializing admission to avoid the concurrent stale-count overshoot? In other words, the admission lock should prevent multiple workers from racing on the same old count, but we probably should not make odd merge limits reject a whole pair before reaching the configured limit.

Signed-off-by: Ryan Leung <rleungx@gmail.com>
@ti-chi-bot ti-chi-bot Bot added size/L Denotes a PR that changes 100-499 lines, ignoring generated files. and removed size/M Denotes a PR that changes 30-99 lines, ignoring generated files. labels Jul 10, 2026
@ti-chi-bot

ti-chi-bot Bot commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

@rleungx: The following tests failed, say /retest to rerun all failed tests or /retest-required to rerun all mandatory failed tests:

Test name Commit Details Required Rerun command
pull-unit-test-next-gen-2 8e7ec80 link true /test pull-unit-test-next-gen-2
pull-unit-test-next-gen-1 8e7ec80 link true /test pull-unit-test-next-gen-1

Full PR test history. Your PR dashboard.

Details

Instructions for interacting with me using PR comments are available here. If you have questions or suggestions related to my behavior, please file an issue against the kubernetes-sigs/prow repository. I understand the commands that are listed here.

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

Labels

dco-signoff: yes Indicates the PR's author has signed the dco. release-note Denotes a PR that will be considered when it comes time to generate release notes. size/L Denotes a PR that changes 100-499 lines, ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

patrol-region-worker-count can exceed schedule limits

2 participants