Skip to content

client/resource_group: enforce one active controller per process - #11132

Open
YuhaoZhang00 wants to merge 5 commits into
tikv:masterfrom
YuhaoZhang00:single-controller-ownership
Open

client/resource_group: enforce one active controller per process#11132
YuhaoZhang00 wants to merge 5 commits into
tikv:masterfrom
YuhaoZhang00:single-controller-ownership

Conversation

@YuhaoZhang00

@YuhaoZhang00 YuhaoZhang00 commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

What problem does this PR solve?

Issue Number: close #11080

Current TiDB production code creates one ResourceGroupsController per process, and several process-global integrations (Prometheus collectors without controller identity, the enableControllerTraceLog flag, client-go's global resource-control interceptor) already assume a unique controller owner. However, pd/client did not enforce this: the public API allowed multiple controllers to be constructed and started in one process, leaving an unsupported state reachable by future callers.

What is changed and how does it work?

This PR implements the pd/client side of the contract described in #11080. The TiDB companion changes (Domain.Close release, initialization error cleanup, bootstrap handoff serialization) are implemented in pingcap/tidb#70456 (tracked by pingcap/tidb#70455). The TiDB changes are compatible with the current pd/client version and do not depend on this PR landing first; conversely, once TiDB picks up this PR through a client upgrade, the explicit release in Domain.Close becomes load-bearing for Domain replacement scenarios.

Several process-global integrations around the resource group
controller assume a unique controller owner: the resource-control
Prometheus collectors carry no controller identity (one controller's
shutdown resets the global status gauge and cleans up shared label
sets), the constructor overwrites the process-global trace-log flag,
and client-go installs the controller into a single global interceptor
pointer. The public API still allowed multiple controllers to be
constructed and started in one process, leaving this unsupported state
reachable by future callers.

Make the single-controller model an explicit lifecycle contract:

- NewResourceGroupController reserves a process-wide ownership slot
  before any side effect and fails a second acquisition with
  ErrClientResourceGroupControllerAlreadyExists; the slot is released
  on every failed initialization path.
- Stop is now idempotent, also valid on a controller that was never
  started, and releases ownership exactly once. Only the current owner
  can release the slot, so a stale controller's Stop cannot release a
  newer controller's slot. A stopped controller can no longer be
  started.
- Existing multi-controller integration tests are converted to
  sequential coverage, and new contract tests cover rejection,
  replacement, release on failed initialization, and concurrent
  acquire/stop.

Notes for reviewers:

  • The ownership slot is reserved before loadServerConfig so that a rejected second acquisition performs no network I/O and does not overwrite the process-global trace-log flag.
  • Stop no longer returns an error for a controller that was never started; it releases ownership and succeeds idempotently, so callers have a single cleanup path regardless of how far initialization progressed.
  • Canceling the context passed to Start stops the run loop but does not release ownership; Stop must be called explicitly. serviceLimitTestSuite.TearDownTest relied on context cancellation only and now calls Stop.
  • Test migration follows the plan in client/resource_group: enforce a single active ResourceGroupsController per process #11080: TestWatchWithSingleGroupByKeyspace and TestResourceGroupControllerConfigChanged are split into sequential phases; TestLoadAndWatchWithDifferentKeyspace is replaced with sequential per-keyspace coverage, deliberately dropping unsupported same-process multi-controller coexistence coverage while retaining keyspace isolation coverage.

Check List

Tests

  • Unit test
  • Integration test

Side effects

  • Breaking backward compatibility: constructing a second ResourceGroupsController in the same process now returns ErrClientResourceGroupControllerAlreadyExists until the previous one is stopped. No known production caller constructs more than one controller per process.

Release note

None.

Summary by CodeRabbit

  • New Features

    • Resource-group controllers now enforce exclusive process-wide ownership.
    • Added a clear error when a controller is already active.
    • Controllers can be safely stopped before starting, and repeated stops are supported.
  • Bug Fixes

    • Improved cleanup after initialization failures and shutdown.
    • Prevented stale controllers from affecting newer active controllers.
    • Ensured controller metrics are cleaned up reliably during shutdown.
  • Tests

    • Expanded coverage for ownership conflicts, lifecycle races, cancellation, and metric cleanup.

Several process-global integrations around the resource group
controller assume a unique controller owner: the resource-control
Prometheus collectors carry no controller identity (one controller's
shutdown resets the global status gauge and cleans up shared label
sets), the constructor overwrites the process-global trace-log flag,
and client-go installs the controller into a single global interceptor
pointer. The public API still allowed multiple controllers to be
constructed and started in one process, leaving this unsupported state
reachable by future callers.

Make the single-controller model an explicit lifecycle contract:

- NewResourceGroupController reserves a process-wide ownership slot
  before any side effect and fails a second acquisition with
  ErrClientResourceGroupControllerAlreadyExists; the slot is released
  on every failed initialization path.
- Stop is now idempotent, also valid on a controller that was never
  started, and releases ownership exactly once. Only the current owner
  can release the slot, so a stale controller's Stop cannot release a
  newer controller's slot. A stopped controller can no longer be
  started.
- Existing multi-controller integration tests are converted to
  sequential coverage, and new contract tests cover rejection,
  replacement, release on failed initialization, and concurrent
  acquire/stop.

Signed-off-by: Yuhao Zhang <yhzhang00@outlook.com>
@ti-chi-bot ti-chi-bot Bot added release-note-none Denotes a PR that doesn't merit a release note. do-not-merge/work-in-progress Indicates that a PR should not merge because it is a work in progress. dco-signoff: yes Indicates the PR's author has signed the dco. labels Aug 11, 2026
@ti-chi-bot

ti-chi-bot Bot commented Aug 11, 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 husharp 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 contribution This PR is from a community contributor. needs-ok-to-test Indicates a PR created by contributors and need ORG member send '/ok-to-test' to start testing. labels Aug 11, 2026
@ti-chi-bot

ti-chi-bot Bot commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Hi @YuhaoZhang00. Thanks for your PR.

I'm waiting for a tikv member to verify that this patch is reasonable to test. If it is, they should reply with /ok-to-test on its own line. Until that is done, I will not automatically test new commits in this PR, but the usual testing commands by org members will still work. Regular contributors should join the org to skip this step.

Once the patch is verified, the new status will be reflected by the ok-to-test label.

I understand the commands that are listed here.

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.

@coderabbitai

coderabbitai Bot commented Aug 11, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: beef3dfc-2e89-4ed5-8399-00af561291ed

📥 Commits

Reviewing files that changed from the base of the PR and between d101dcb and e8f45b1.

📒 Files selected for processing (4)
  • client/resource_group/controller/global_controller.go
  • client/resource_group/controller/ownership.go
  • tests/integrations/mcs/resourcemanager/resource_manager_test.go
  • tests/integrations/mcs/resourcemanager/service_limit_test.go
🚧 Files skipped from review as they are similar to previous changes (4)
  • tests/integrations/mcs/resourcemanager/service_limit_test.go
  • client/resource_group/controller/ownership.go
  • client/resource_group/controller/global_controller.go
  • tests/integrations/mcs/resourcemanager/resource_manager_test.go

📝 Walkthrough

Walkthrough

ResourceGroupsController now enforces process-wide ownership. Its lifecycle synchronizes construction, start, stop, metric cleanup, and ownership release. Unit and integration tests now stop controllers and run replacement scenarios sequentially.

Changes

Resource group controller ownership and lifecycle

Layer / File(s) Summary
Ownership contract and acquisition
client/errs/errno.go, client/resource_group/controller/ownership.go, client/resource_group/controller/global_controller.go, client/resource_group/controller/ownership_test.go
Adds the duplicate-controller error. The constructor reserves and binds a process-wide ownership slot. Failed initialization releases reservations. Tests cover exclusive acquisition, concurrency, stale controllers, and initialization failures.
Lifecycle synchronization and cleanup
client/resource_group/controller/global_controller.go, client/resource_group/controller/global_controller_test.go, client/resource_group/controller/ownership_test.go, client/resource_group/controller/request_source_metrics_test.go
Start rejects duplicate or stopped starts. Stop is idempotent, supports unstarted controllers, waits for active loops, cleans metrics, and releases ownership. Tests verify cancellation, races, metric cleanup, and shutdown behavior.
Integration test sequencing and teardown
tests/integrations/mcs/resourcemanager/resource_manager_test.go, tests/integrations/mcs/resourcemanager/service_limit_test.go
Integration tests wait for asynchronous metadata visibility, run controller phases sequentially, stop controllers before replacement, and add teardown cleanup.

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

Merge Risk: ⚪ Minimal · up to e8f45

The change enforces a single active controller per process with explicit cleanup and idempotent stopping; no actionable merge-blocking risk remains beyond normal checks and review.

Suggested reviewers: rleungx

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant ResourceGroupsController
  participant RunLoop
  participant controllerOwnership
  Client->>ResourceGroupsController: NewResourceGroupController()
  ResourceGroupsController->>controllerOwnership: reserve and bind ownership
  Client->>ResourceGroupsController: Start()
  ResourceGroupsController->>RunLoop: launch lifecycle loop
  Client->>ResourceGroupsController: Stop()
  ResourceGroupsController->>RunLoop: cancel and wait
  RunLoop->>ResourceGroupsController: clean process-global metrics
  ResourceGroupsController->>controllerOwnership: release ownership
Loading
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 37.50% 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
Title check ✅ Passed The title clearly summarizes the primary change: enforcing one active resource group controller per process.
Description check ✅ Passed The description covers the problem, issue, implementation, tests, compatibility impact, and release note.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ 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.

Address findings from an independent review of the ownership contract:

- Serialize Start against Stop with a lifecycle mutex. Previously a
  Stop racing a Start could observe a nil loopCancel, release the
  ownership slot, and let Start launch a run loop that no longer owns
  the slot; its eventual exit would reset the process-global status
  gauge under a replacement controller. Confirmed with the race
  detector.
- Clean the process-global metric state in Stop as well, covering
  controllers that were used but never started (or used again after
  their run loop exited), which previously left stale series behind
  for the replacement controller.
- Release the ownership reservation via defer so a panic on a
  construction path cannot leak the slot.
- Add contract tests: concurrent Start/Stop, ownership retained after
  context cancellation, rejection before any network I/O, and metric
  cleanup when stopping an unstarted controller.
- Test hygiene: add idempotent deferred Stop backstops so a mid-test
  failure does not leave the slot held and poison later tests, and a
  nil guard in the service limit suite teardown.

Signed-off-by: Yuhao Zhang <yhzhang00@outlook.com>
Follow-ups from a second independent review pass:

- Refuse a second Start of the same controller. Previously it replaced
  loopCtx/loopCancel and launched a duplicate run loop whose context
  could never be canceled, racing the first loop's unsynchronized
  loopCtx reads.
- Gate the process-global metric cleanup on current ownership, so
  stopping a controller allocated outside the supported API can no
  longer reset the status gauge series that belong to the legitimate
  owner. The ownership slot was already protected by the owner check
  in release; the metrics now get the same protection.
- Add deferred Stop backstops to the five remaining suite tests that
  stopped their controller only inline at the end of the test. With
  the ownership guard in place, a mid-test failure would otherwise
  keep the slot held and fail every later test in the suite with
  ErrClientResourceGroupControllerAlreadyExists, masking the root
  cause.

Signed-off-by: Yuhao Zhang <yhzhang00@outlook.com>
… reused

Signed-off-by: Yuhao Zhang <yhzhang00@outlook.com>
@YuhaoZhang00
YuhaoZhang00 marked this pull request as ready for review August 12, 2026 06:19
@ti-chi-bot ti-chi-bot Bot removed the do-not-merge/work-in-progress Indicates that a PR should not merge because it is a work in progress. label Aug 12, 2026

@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

🧹 Nitpick comments (1)
client/resource_group/controller/ownership_test.go (1)

85-92: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

The stale-owner case does not execute release.

c1.Stop() and c2.Stop() at Lines 88-89 are no-ops. stopOnce already fired for both controllers, so the Stop body never runs again and ownership.release is never called with a stale controller. The assertion at Lines 90-91 passes, but it does not exercise the owner-identity guard in ownership.release.

TestForeignControllerStopDoesNotDisturbOwner covers that guard. Consider adjusting the comment so a future reader does not treat these lines as the guard's coverage.

🤖 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 `@client/resource_group/controller/ownership_test.go` around lines 85 - 92,
Update the comment above the c1.Stop and c2.Stop calls to clarify that these
calls are no-ops and do not exercise the stale-owner guard in ownership.release;
identify TestForeignControllerStopDoesNotDisturbOwner as the test covering that
behavior, while preserving the existing assertions and test flow.
🤖 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/integrations/mcs/resourcemanager/resource_manager_test.go`:
- Around line 2488-2492: Update the Eventually condition around
GetActiveResourceGroup in the resource-group test to return false when meta is
nil before accessing RUSettings. Preserve the existing FillRate comparison for
non-nil metadata so transiently unloaded groups retry instead of panicking.

---

Nitpick comments:
In `@client/resource_group/controller/ownership_test.go`:
- Around line 85-92: Update the comment above the c1.Stop and c2.Stop calls to
clarify that these calls are no-ops and do not exercise the stale-owner guard in
ownership.release; identify TestForeignControllerStopDoesNotDisturbOwner as the
test covering that behavior, while preserving the existing assertions and test
flow.
🪄 Autofix

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 Plus

Run ID: 66096889-8270-408d-bb98-25a8fb626299

📥 Commits

Reviewing files that changed from the base of the PR and between 3430f76 and d101dcb.

📒 Files selected for processing (8)
  • client/errs/errno.go
  • client/resource_group/controller/global_controller.go
  • client/resource_group/controller/global_controller_test.go
  • client/resource_group/controller/ownership.go
  • client/resource_group/controller/ownership_test.go
  • client/resource_group/controller/request_source_metrics_test.go
  • tests/integrations/mcs/resourcemanager/resource_manager_test.go
  • tests/integrations/mcs/resourcemanager/service_limit_test.go

Comment on lines 2488 to 2492
group := genGroupByKeyspace(keyspace)
testutil.Eventually(re, func() bool {
meta := c.GetActiveResourceGroup(group.Name)
return meta.RUSettings.RU.Settings.FillRate == fillRate
})

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Guard meta against nil inside the Eventually condition.

GetActiveResourceGroup returns nil when the group is not loaded. Line 2491 dereferences meta with no nil check. If the group is momentarily absent, the condition panics instead of retrying, and the failure output hides the real cause.

Lines 2501-2503 already compare the same call against nil, so the nil case is expected elsewhere in this loop.

🛡️ Proposed nil guard
 	group := genGroupByKeyspace(keyspace)
 	testutil.Eventually(re, func() bool {
 		meta := c.GetActiveResourceGroup(group.Name)
-		return meta.RUSettings.RU.Settings.FillRate == fillRate
+		return meta != nil && meta.RUSettings.RU.Settings.FillRate == fillRate
 	})
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
group := genGroupByKeyspace(keyspace)
testutil.Eventually(re, func() bool {
meta := c.GetActiveResourceGroup(group.Name)
return meta.RUSettings.RU.Settings.FillRate == fillRate
})
group := genGroupByKeyspace(keyspace)
testutil.Eventually(re, func() bool {
meta := c.GetActiveResourceGroup(group.Name)
return meta != nil && meta.RUSettings.RU.Settings.FillRate == fillRate
})
🤖 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 `@tests/integrations/mcs/resourcemanager/resource_manager_test.go` around lines
2488 - 2492, Update the Eventually condition around GetActiveResourceGroup in
the resource-group test to return false when meta is nil before accessing
RUSettings. Preserve the existing FillRate comparison for non-nil metadata so
transiently unloaded groups retry instead of panicking.

Signed-off-by: Yuhao Zhang <yhzhang00@outlook.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

contribution This PR is from a community contributor. dco-signoff: yes Indicates the PR's author has signed the dco. needs-ok-to-test Indicates a PR created by contributors and need ORG member send '/ok-to-test' to start testing. release-note-none Denotes a PR that doesn't merit a release note. size/XXL Denotes a PR that changes 1000+ lines, ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

client/resource_group: enforce a single active ResourceGroupsController per process

1 participant