Skip to content

client/resource_group: add RC paging pre-charge with PredictedReadBytes hint#10611

Merged
ti-chi-bot[bot] merged 52 commits into
tikv:masterfrom
YuhaoZhang00:feat/rc-paging-precharge
Jul 8, 2026
Merged

client/resource_group: add RC paging pre-charge with PredictedReadBytes hint#10611
ti-chi-bot[bot] merged 52 commits into
tikv:masterfrom
YuhaoZhang00:feat/rc-paging-precharge

Conversation

@YuhaoZhang00

@YuhaoZhang00 YuhaoZhang00 commented Apr 21, 2026

Copy link
Copy Markdown
Contributor

What problem does this PR solve?

Issue Number: close #10612

Under RC (resource control), paging coprocessor requests are today billed only at response settlement. A misbehaving resource group can therefore burst large reads before its token bucket reacts, because no RU is reserved up front. This PR introduces a pre-charge mechanism driven by a caller-supplied read-bytes prediction so the token bucket is pressured before the KV RPC is sent, while keeping the final bill accurate at settlement.

What is changed and how does it work?

The main logic is in client/resource_group/controller, with matching resource-manager server-side metrics/metering handling.

  1. Pre-charge on request - BeforeKVRequest reads an optional PredictedReadBytes() provider from RequestInfo. The hint is used only when the request is a read coprocessor request (IsCop() == true and IsWrite() == false). When the hint is positive, KVCalculator adds ReadBytesCost * predicted to delta.RRU, and the existing token acquisition path reserves that many tokens before the KV RPC is sent. Writes, non-coprocessor reads, and coprocessor reads without a positive hint keep the existing settlement-only behavior.

  2. Refund on settlement - AfterKVRequest settles the pre-charge by subtracting the predicted basis and adding ReadBytesCost * actual. The resulting token delta can be negative when the estimate was too high, so Limiter.RefundTokens is used as the inverse of RemoveTokens. Reported consumption strips the synthetic pre-charge so pre-charged reads still report the same actual request consumption as before: request-side read base cost, then response-side actual read bytes and CPU.

  3. Consumption reporting - The synthetic paging pre-charge/refund delta is used only for local token accounting. It is stripped from ConsumptionSinceLastRequest, so the resource-manager server still receives the same actual consumption as before. Empty consumption deltas are still reported as empty Consumption messages, preserving the existing metric activity refresh behavior; nil consumption is ignored defensively on the server side.

  4. Observability and metering safeguards - five per-resource-group paging metrics are added, grouped by sampling unit. Server-side metering now also clamps negative RU values to zero before converting them to unsigned metering values, so malformed or defensive negative RU input cannot become an extremely large unsigned value.

    Count:

    • cop_read_precharge_total - read coprocessor RPCs that carried a positive PredictedReadBytes hint and triggered request-side read-byte pre-charge
    • cop_read_no_precharge_total - read coprocessor RPCs that reached the controller without a positive PredictedReadBytes hint, so request-side read-byte pre-charge was skipped

    Bytes:

    • paging_precharge_bytes_total - sum of predicted hints used as the pre-charge basis
    • paging_actual_bytes_total - actual bytes read by pre-charged RPCs
    • paging_prediction_residual_bytes - distribution of signed actual - predicted for pre-charged RPCs

Pre-charge is strictly opt-in: a client must expose PredictedReadBytes() and pass a non-zero value on a read coprocessor request. Passing 0, omitting the optional provider, or using any write/non-coprocessor request preserves the existing settlement-only path. The wire protocol is unchanged.

Related PRs

Check List

Tests

  • Unit test
    • TestPredictedReadBytesPreCharge / TestPredictedReadBytesRequiresCopRead / TestRequestInfoPagingProvidersAreOptional / TestRequestInfoMissingPredictionProviderReturnsZeroHint / TestNoPreChargeWithoutPredictedReadBytes - request-side pre-charge gating
    • TestPagingPreChargeTokenRefund / TestPagingPreChargeNoRefundWhenActualExceedsEstimate / TestOnResponseImplPagingRefund / TestOnResponseWaitPrechargedPositiveSettlementDebitsImmediately / TestPagingPreChargeRefundOnFailedRead - response-side settlement and refund
    • TestPagingPrechargeDoesNotEnterReportedRequestConsumption / TestPagingPrechargeRefundDoesNotEnterReportedResponseConsumption / TestReportedConsumptionStripsPagingPrecharge / TestReportedConsumptionRestoresPagingActualUsage - reported consumption excludes synthetic pre-charge
    • TestDeletePagingLabelsResetsSeries / TestCopReadNoPrechargeGatedByIsCop / TestCopReadNoPrechargeCounterObservedOnRequest / TestPagingPrechargeNotObservedOnThrottle - paging metric behavior
    • TestUpdateDeltaConsumptionReportsEmptyConsumption / TestRecordConsumptionKeepsRecordForEmptyConsumption / TestRecordConsumptionIgnoresNilConsumption / TestRUCollectorUsesConsumption / TestNewRUValueClampsNegativeValues - consumption reporting, server metrics, and metering handling
    • TestFailedWriteDoesNotUsePagingRefundPath / TestFailedWriteOnResponseDoesNotUsePagingRefundPath - split boundary for the failed-write accounting fix
    • TestRefundTokens - limiter primitive

Side effects

  • Increased code complexity (optional paging hint provider + coprocessor gating + refund path).
  • A paging over-estimate can refund local limiter tokens through RefundTokens; this affects only the local token bucket and does not enter reported consumption.
  • Negative RU values passed into metering are clamped to zero before unsigned conversion.

Release note

None.

Summary by CodeRabbit

  • New Features

    • Added paging-aware request accounting, including pre-charge, settlement, and token refund handling for estimated reads.
    • Expanded metrics to show paging pre-charge counts, actual bytes, and prediction accuracy.
  • Bug Fixes

    • Improved RU and token accounting so reported consumption reflects actual usage more accurately.
    • Prevented nil consumption records from affecting metering.
    • Clamped negative RU values to zero for safer metering output.
  • Tests

    • Added broad coverage for paging behavior, limiter refunds, metric cleanup, and consumption reporting.

Introduce an optional predictedReadBytesProvider interface on RequestInfo.
When a caller (e.g. TiDB maintaining a per-logical-scan EMA across paging
RPCs) supplies a non-zero PredictedReadBytes, BeforeKVRequest pre-charges
ReadBytesCost * predictedReadBytes RRU so that concurrent workers are
throttled at BeforeKVRequest rather than all hitting AfterKVRequest
settlement at once. AfterKVRequest then subtracts the same basis so the
net (pre-charge + settlement) equals baseCost + actualCost.

Without a hint the request is not pre-charged and is billed in
AfterKVRequest by actual read bytes only - this keeps the RU-billing
pre-charge decoupled from the protocol-level paging byte cap.

The hint is added as an optional interface (not a method on RequestInfo)
so existing RequestInfo implementations compile unchanged; they simply
skip pre-charge.

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

When a request carries a PredictedReadBytes hint, BeforeKVRequest consumes
tokens up-front as a pre-charge. If the actual read bytes come back smaller
than the estimate, the delta represents tokens that were reserved but never
consumed. Previously AfterKVRequest computed a negative delta but called
RemoveTokens unconditionally, which further debited the limiter instead of
giving tokens back.

This commit adds Limiter.RefundTokens as the inverse of RemoveTokens and
wires the response-side paths (onResponseImpl, onResponseWaitImpl) to call
it whenever the settlement delta is negative, so over-estimated pre-charges
are released back to the group's token bucket.

Signed-off-by: Yuhao Zhang <yhzhang00@outlook.com>
Add per-resource-group Prometheus metrics so operators can observe how the
paging pre-charge path behaves in production and judge EMA prediction
accuracy:

  - paging_precharge_total / paging_precharge_bytes_total: count and byte
    volume of RPCs that arrived with a PredictedReadBytes hint > 0 and
    were pre-charged at BeforeKVRequest.
  - paging_actual_bytes_total: actual read bytes reported by pre-charged
    RPCs, to compute an over/under-charge ratio against the pre-charge
    volume.
  - paging_prediction_residual_bytes: histogram of (actual - predicted)
    bytes for pre-charged RPCs; shows EMA prediction accuracy directly.
  - paging_nonprecharge_total / paging_nonprecharge_actual_bytes_total:
    count and byte volume of read RPCs that implemented the predicted
    hint interface but reported 0 (e.g. EMA cold-start) and therefore
    ran without pre-charge. Paired with paging_precharge_total this
    yields the cold/ready RPC split from the PD client side.

Labels are cached per resource group in groupMetricsCollection to keep
the hot path out of WithLabelValues. Only RequestInfo implementations
that opt into the PredictedReadBytes interface contribute to these
series; existing callers are unaffected.

Signed-off-by: Yuhao Zhang <yhzhang00@outlook.com>
@ti-chi-bot ti-chi-bot Bot added do-not-merge/needs-linked-issue release-note-none Denotes a PR that doesn't merit a release note. dco-signoff: yes Indicates the PR's author has signed the dco. 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 Apr 21, 2026
@ti-chi-bot

ti-chi-bot Bot commented Apr 21, 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.

@ti-chi-bot ti-chi-bot Bot added the size/XL Denotes a PR that changes 500-999 lines, ignoring generated files. label Apr 21, 2026
@coderabbitai

coderabbitai Bot commented Apr 21, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Adds an opt-in RC paging pre-charge mechanism: a PredictedReadBytes/IsCop hint on RequestInfo, pre-charge/settlement logic in KVCalculator, a Limiter.RefundTokens primitive, new paging Prometheus metrics, and group controller wiring for request/response accounting. Also adds nil-consumption guards and RU-value clamping on the server metering path.

Changes

Client-side RC paging pre-charge feature

Layer / File(s) Summary
Paging hint contract and calculators
client/resource_group/controller/model.go, model_test.go, testutil.go
RequestInfo supports optional PredictedReadBytes/IsCop hints; KVCalculator.BeforeKVRequest/AfterKVRequest pre-charge and settle paging RU; helpers compute reported deltas excluding the paging basis.
Limiter refund primitive
client/resource_group/controller/limiter.go, limiter_test.go
Adds Limiter.RefundTokens, which replenishes tokens without notifying waiters, no-ops for unlimited limiters, and is tested across burst configurations.
Paging metrics collectors
client/resource_group/controller/metrics/metrics.go
Adds counters/histogram for cop-read pre-charge, paging bytes, and prediction residual, wired into initMetrics/InitAndRegisterMetrics.
Group controller wiring
client/resource_group/controller/group_controller.go, global_controller.go
Wires pre-charge, settlement, refund, and metric deletion into onRequestWaitImpl/onResponseImpl/onResponseWaitImpl and resource group cleanup.
Group controller test suite
client/resource_group/controller/group_controller_test.go, client/go.mod
Adds extensive tests for pre-charge/settlement invariants, refund logic, COP gating, throttling, failed requests, and metric cleanup; adds prometheus/client_model as a direct dependency.

Server-side metering nil-safety and RU clamping

Layer / File(s) Summary
Nil-consumption guards and RU clamping
pkg/mcs/resourcemanager/server/metrics.go, metrics_test.go, manager_test.go, metering_test.go, pkg/metering/utils.go, utils_test.go
recordConsumption/counterMetrics.add return early on nil consumption; NewRUValue clamps negative inputs to zero; new tests validate these paths and dispatch/aggregation correctness.

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

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant GroupController
  participant Limiter
  participant Metrics

  Client->>GroupController: onRequestWaitImpl(info)
  GroupController->>GroupController: pagingReadEstimate(info)
  GroupController->>Limiter: acquireTokens(precharge RU)
  GroupController->>Metrics: observePagingRequest(estimate)
  Client->>GroupController: onResponseWaitImpl/onResponseImpl(req, resp)
  GroupController->>GroupController: settle delta via BeforeKVRequest/AfterKVRequest
  alt settlement negative (over-estimate)
    GroupController->>Limiter: RefundTokens(amount)
  else settlement positive
    GroupController->>Limiter: acquireTokens/RemoveTokens
  end
  GroupController->>Metrics: observePagingResponse(actual, residual)
Loading

Suggested labels: type/development

Suggested reviewers: JmPotato, rleungx, nolouch

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Out of Scope Changes check ⚠️ Warning The PR also includes unrelated server-side metering clamp changes and tests in pkg/mcs/resourcemanager/server and pkg/metering/utils. Split the server metering clamp changes into a separate PR, or add a linked issue that explicitly covers them.
Docstring Coverage ⚠️ Warning Docstring coverage is 20.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title is concise and accurately summarizes the main change: RC paging pre-charge using PredictedReadBytes.
Linked Issues check ✅ Passed The changes implement the predicted-read pre-charge/refund flow, paging observability, and failed-write payback behavior required by the linked issues.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Warning

Tools execution failed with the following error:

Failed to run tools: 13 INTERNAL: Received RST_STREAM with code 2 (Internal server error)


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.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
client/resource_group/controller/group_controller.go (1)

602-626: ⚠️ Potential issue | 🟡 Minor

Pre-charge counter increments even when the request is then throttled.

observePagingPrecharge runs before acquireTokens. If acquireTokens fails with ErrClientResourceGroupThrottled, mu.consumption is rolled back via sub(...), but PagingPrechargeCounter / PagingPrechargeBytesCounter are already incremented, and onResponseImpl/onResponseWaitImpl will never run for this request to balance it with a PagingActualBytesCounter entry. Dashboards comparing actual-vs-precharge bytes (the documented use of these metrics) will show a chronic deficit proportional to throttling rate.

Consider moving the observation to after a successful acquire (or skipping it on the throttled-rollback path), or alternatively add a separate PagingPrechargeThrottledCounter so the denominator stays honest.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@client/resource_group/controller/group_controller.go` around lines 602 - 626,
The pre-charge metrics are recorded by observePagingPrecharge before calling
acquireTokens, causing unbalanced precharge counts when acquireTokens fails and
consumption is rolled back (sub on gc.mu.consumption); update the logic in
group_controller.go so that observePagingPrecharge is only called after a
successful acquireTokens (i.e., move the observePagingPrecharge call to after
acquireTokens returns without error), or alternatively decrement or record a
separate PagingPrechargeThrottledCounter in the error branch that handles
errs.ErrClientResourceGroupThrottled (the branch that calls sub and returns the
error) to keep PagingPrechargeCounter/PagingPrechargeBytesCounter consistent
with actual completed requests.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@client/resource_group/controller/group_controller.go`:
- Around line 602-604: The metric observations (calls to
gc.metrics.observePagingPrecharge / observePagingPrechargeBytes /
observePagingActualBytes / observePagingPredictionResidual) should only run for
read requests to match the pre-charge behavior in
BeforeKVRequest/AfterKVRequest; update the branches that call
estimatedReadBytes(info) (notably in onResponseImpl and onResponseWaitImpl and
the other occurrences mentioned) to first check that the request is a read
(e.g., !req.IsWrite()) before observing any paging metrics, so pre-charge and
metric recording are gated symmetrically with the RU accounting in model.go.

---

Outside diff comments:
In `@client/resource_group/controller/group_controller.go`:
- Around line 602-626: The pre-charge metrics are recorded by
observePagingPrecharge before calling acquireTokens, causing unbalanced
precharge counts when acquireTokens fails and consumption is rolled back (sub on
gc.mu.consumption); update the logic in group_controller.go so that
observePagingPrecharge is only called after a successful acquireTokens (i.e.,
move the observePagingPrecharge call to after acquireTokens returns without
error), or alternatively decrement or record a separate
PagingPrechargeThrottledCounter in the error branch that handles
errs.ErrClientResourceGroupThrottled (the branch that calls sub and returns the
error) to keep PagingPrechargeCounter/PagingPrechargeBytesCounter consistent
with actual completed requests.
🪄 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: f857a943-c822-4ef7-939c-79712b2a1f70

📥 Commits

Reviewing files that changed from the base of the PR and between e430bd0 and 6ebc843.

📒 Files selected for processing (7)
  • client/resource_group/controller/group_controller.go
  • client/resource_group/controller/group_controller_test.go
  • client/resource_group/controller/limiter.go
  • client/resource_group/controller/limiter_test.go
  • client/resource_group/controller/metrics/metrics.go
  • client/resource_group/controller/model.go
  • client/resource_group/controller/testutil.go

Comment thread client/resource_group/controller/group_controller.go Outdated
YuhaoZhang00 added a commit to YuhaoZhang00/tidb that referenced this pull request Apr 22, 2026
Temporarily replace github.com/tikv/client-go/v2 and github.com/tikv/pd/client
with the corresponding forks carrying the PredictedReadBytes hint and
controller-side pre-charge logic so CI can build this PR before the two
upstream PRs land:

  - github.com/YuhaoZhang00/client-go/v2  (tikv/client-go#1947)
  - github.com/YuhaoZhang00/pd/client     (tikv/pd#10611)

Will be reverted and replaced with a normal require bump once both PRs
merge and tag new releases.

Signed-off-by: Yuhao Zhang <yhzhang00@outlook.com>
YuhaoZhang00 added a commit to YuhaoZhang00/tidb that referenced this pull request Apr 22, 2026
Temporarily replace github.com/tikv/client-go/v2 and github.com/tikv/pd/client
with the corresponding forks carrying the PredictedReadBytes hint and
controller-side pre-charge logic so CI can build this PR before the two
upstream PRs land:

  - github.com/YuhaoZhang00/client-go/v2  (tikv/client-go#1947)
  - github.com/YuhaoZhang00/pd/client     (tikv/pd#10611)

Will be reverted and replaced with a normal require bump once both PRs
merge and tag new releases.

Signed-off-by: Yuhao Zhang <yhzhang00@outlook.com>
Signed-off-by: Yuhao Zhang <yhzhang00@outlook.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

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

328-340: LGTM on the refund primitive; consider symmetry note.

Implementation mirrors RemoveTokens with the opposite sign and skips maybeNotify (refund can't make us low), which is the right call. Since tokens can temporarily exceed burst, the cap is applied implicitly on the next getTokens() call — that's fine but worth a one-line comment so future readers don't mistake it for a leak of the burst cap.

📝 Optional doc tweak
 // RefundTokens adds tokens back to the limiter.
 //
-// No burst cap is applied here
+// No burst cap is applied here; any overshoot is clamped to burst on the
+// next getTokens() call. Does not call maybeNotify since adding tokens
+// cannot put the limiter into the low-token state.
 func (lim *Limiter) RefundTokens(now time.Time, amount float64) {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@client/resource_group/controller/limiter.go` around lines 328 - 340, The
RefundTokens implementation mirrors RemoveTokens but omits maybeNotify and
allows lim.tokens to temporarily exceed lim.burst because the cap is enforced
later in getTokens; add a single-line comment inside RefundTokens (near the
lim.tokens = tokens + amount line) stating that exceeding burst is intentional
and will be capped by getTokens on the next access, and note that maybeNotify is
intentionally not called for refunds to clarify intent for future readers
(reference symbols: RefundTokens, RemoveTokens, maybeNotify, getTokens, burst).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@client/resource_group/controller/group_controller.go`:
- Around line 689-710: The change skips calling acquireTokens and recording
successfulRequestDuration when getRUValueFromConsumption(delta) returns exactly
0; to preserve prior metrics behavior, add a v == 0 branch after the existing
if/else-if so that when v == 0 you call
gc.metrics.successfulRequestDuration.Observe(0) (and leave waitDuration
unchanged), referencing the existing symbols getRUValueFromConsumption,
acquireTokens, and gc.metrics.successfulRequestDuration.Observe to locate where
to add the single-line observation.

---

Nitpick comments:
In `@client/resource_group/controller/limiter.go`:
- Around line 328-340: The RefundTokens implementation mirrors RemoveTokens but
omits maybeNotify and allows lim.tokens to temporarily exceed lim.burst because
the cap is enforced later in getTokens; add a single-line comment inside
RefundTokens (near the lim.tokens = tokens + amount line) stating that exceeding
burst is intentional and will be capped by getTokens on the next access, and
note that maybeNotify is intentionally not called for refunds to clarify intent
for future readers (reference symbols: RefundTokens, RemoveTokens, maybeNotify,
getTokens, burst).
🪄 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: 4f54c1ea-7b65-4175-ba34-2b2aaf771bc9

📥 Commits

Reviewing files that changed from the base of the PR and between 6ebc843 and 6e95720.

📒 Files selected for processing (4)
  • client/resource_group/controller/group_controller.go
  • client/resource_group/controller/limiter.go
  • client/resource_group/controller/metrics/metrics.go
  • client/resource_group/controller/model.go
🚧 Files skipped from review as they are similar to previous changes (1)
  • client/resource_group/controller/metrics/metrics.go

Comment thread client/resource_group/controller/group_controller.go
Comment thread client/resource_group/controller/model.go
if bytesForEst := estimatedReadBytes(req); bytesForEst > 0 {
gc.metrics.observePagingActual(bytesForEst, resp.ReadBytes())
} else if !req.IsWrite() {
if _, ok := req.(predictedReadBytesProvider); ok {

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.

Looking at the code, all requests implement this interface, meaning they will all enter this branch under any path.

Given that, what is the purpose of this non-precharge metric?

The previous +/-4MB range clipped large first-page responses on cold
copIterators (predicted=0 -> residual == actual, commonly several MB)
and workload shifts where EMA sits above the current actual. Extending
both ends by two factor-4 steps keeps the same near-zero resolution
while making P95/P99 readable up to the TiKV paging cap.

Signed-off-by: Yuhao Zhang <yhzhang00@outlook.com>
EMA is a TiDB-side implementation detail; PD's metric Help text should
describe what the metric observes in terms of the hint contract.
paging_nonprecharge_* also fires when hint is absent entirely (not just
when the predictor produced 0), so reword to say so.

Signed-off-by: Yuhao Zhang <yhzhang00@outlook.com>
@ti-chi-bot ti-chi-bot Bot added size/XXL Denotes a PR that changes 1000+ lines, ignoring generated files. and removed size/XL Denotes a PR that changes 500-999 lines, ignoring generated files. labels Apr 22, 2026
@YuhaoZhang00 YuhaoZhang00 force-pushed the feat/rc-paging-precharge branch from a8777a9 to 1764549 Compare April 22, 2026 07:26
@ti-chi-bot ti-chi-bot Bot added size/XL Denotes a PR that changes 500-999 lines, ignoring generated files. and removed size/XXL Denotes a PR that changes 1000+ lines, ignoring generated files. labels Apr 22, 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.

♻️ Duplicate comments (1)
client/resource_group/controller/group_controller.go (1)

601-627: ⚠️ Potential issue | 🟡 Minor

Align paging metrics with successful read pre-charges.

Line 601 records pre-charge metrics before token acquisition can fail, so throttled/canceled requests inflate paging_precharge_* without a matching settlement sample. Also, settlement/precharge observations key off estimatedReadBytes > 0 without the read guard used by BeforeKVRequest, so a write request implementing the optional predictor could corrupt paging metrics.

🛡️ Proposed metric-gating adjustment
 	for _, calc := range gc.calculators {
 		calc.BeforeKVRequest(delta, info)
 	}
-	if bytesForEst := estimatedReadBytes(info); bytesForEst > 0 {
-		gc.metrics.observePagingPrecharge(bytesForEst, getRUValueFromConsumption(delta))
-	}
+	bytesForEst := estimatedReadBytes(info)
+	prechargeRU := getRUValueFromConsumption(delta)
 
 	gc.mu.Lock()
 	add(gc.mu.consumption, delta)
 	gc.mu.Unlock()
@@
 		gc.metrics.successfulRequestDuration.Observe(d.Seconds())
 		waitDuration += d
 	}
+	if bytesForEst > 0 && !info.IsWrite() {
+		gc.metrics.observePagingPrecharge(bytesForEst, prechargeRU)
+	}
-	if bytesForEst := estimatedReadBytes(req); bytesForEst > 0 {
-		gc.metrics.observePagingActual(bytesForEst, resp.ReadBytes(),
-			getRUValueFromConsumption(count), getRUValueFromConsumption(delta))
-	} else if !req.IsWrite() {
-		if _, ok := req.(predictedReadBytesProvider); ok {
-			gc.metrics.observePagingNonprecharge(resp.ReadBytes())
+	if !req.IsWrite() {
+		if bytesForEst := estimatedReadBytes(req); bytesForEst > 0 {
+			gc.metrics.observePagingActual(bytesForEst, resp.ReadBytes(),
+				getRUValueFromConsumption(count), getRUValueFromConsumption(delta))
+		} else if _, ok := req.(predictedReadBytesProvider); ok {
+			gc.metrics.observePagingNonprecharge(resp.ReadBytes())
 		}
 	}

Apply the same response-side shape in onResponseWaitImpl.

Also applies to: 661-667, 702-708

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@client/resource_group/controller/group_controller.go` around lines 601 - 627,
The paging pre-charge metrics are recorded too early and without the same
read-only guard used elsewhere: move the gc.metrics.observePagingPrecharge(...)
call so it runs only after acquireTokens(...) succeeds (i.e., after the error
check where d and err are handled) and gate it with the same read-only check
used by BeforeKVRequest (use estimatedReadBytes(info) > 0 combined with the
request read-predictor guard) so write requests or failed token acquisitions do
not increment paging_precharge_*; make the identical adjustment in
onResponseWaitImpl for the other occurrences referenced (around the 661-667 and
702-708 regions) to keep precharge and settlement metrics symmetric.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@client/resource_group/controller/group_controller.go`:
- Around line 601-627: The paging pre-charge metrics are recorded too early and
without the same read-only guard used elsewhere: move the
gc.metrics.observePagingPrecharge(...) call so it runs only after
acquireTokens(...) succeeds (i.e., after the error check where d and err are
handled) and gate it with the same read-only check used by BeforeKVRequest (use
estimatedReadBytes(info) > 0 combined with the request read-predictor guard) so
write requests or failed token acquisitions do not increment paging_precharge_*;
make the identical adjustment in onResponseWaitImpl for the other occurrences
referenced (around the 661-667 and 702-708 regions) to keep precharge and
settlement metrics symmetric.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 08e17f51-e08f-428b-bb0d-1e6f02ab316b

📥 Commits

Reviewing files that changed from the base of the PR and between 1764549 and a8777a9e370b2d5bec550444d2c9501b5893b182.

📒 Files selected for processing (2)
  • client/resource_group/controller/group_controller.go
  • client/resource_group/controller/metrics/metrics.go
🚧 Files skipped from review as they are similar to previous changes (1)
  • client/resource_group/controller/metrics/metrics.go

@YuhaoZhang00

Copy link
Copy Markdown
Contributor Author

/retest

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

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

Did another pass after the SettlementSinceLastRequest removal — the wire-protocol concern is fully resolved, and I verified the reported consumption for pre-charged reads now matches master exactly (request = base cost, response = actual + CPU; the pre-charge never reaches billing/stats). Remaining items, in priority order:

Blocker — go.sum is not tidy. The go.mod files of the root, tests/integrations, and tools modules are untouched, yet each of their go.sum files still carries ~800 added lines of stale checksums left over from the fork-replace era. A module whose go.mod didn't change should have zero go.sum diff, so make tidy will prune all of them and the CI clean-diff check is likely to fail as-is. Please restore the three go.sum files and re-run go mod tidy in each module. (Promoting github.com/prometheus/client_model to a direct dependency in client/go.mod is fine — the new tests use dto.Metric directly.)

PR description drift. The description still says PredictedReadBytes is "a required method on the interface" (it is an optional type assertion now, together with the IsCop() gate which isn't mentioned at all), still scopes the change to client/resource_group/controller (server-side files are touched), and the test list references TestPagingPreChargeZeroDelta, which no longer exists under that name. Please refresh it to match the current implementation.

The inline comments below cover settlement-era leftovers that can be reverted now, plus a few behavior changes that deserve either a split or an explicit call-out.

return ticker
}

func resourceGroupPushCollectors() []prometheus.Collector {

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.

With the refund counters gone in the latest revision, this helper returns exactly the same three collectors the pusher used to chain directly, and TestResourceGroupPushCollectors now just asserts the function body against itself. Suggest reverting both back to the original chained .Collector(...) calls to keep the diff minimal.

consumptionRecordMap map[consumptionRecordKey]time.Time
// Records the latest activity time for metric series cleanup. This is
// independent from metering/actual consumption.
metricsActivityRecordMap map[metricsActivityRecordKey]time.Time

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 consumptionRecordMapmetricsActivityRecordMap rename (~60 lines across manager/metrics/tests) was motivated by the earlier design where settlement-only reports also refreshed activity. With the settlement stream removed, every record here is once again driven by actual consumption, so the original name is accurate again. Suggest reverting the rename to shrink the diff.

ruLabelType = tiflashTypeLabel
}
consumption := consumptionInfo.Consumption
if isZeroConsumption(consumption) {

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 early return changes cleanup behavior for idle groups: previously an all-zero consumption report still refreshed the activity record, keeping the group's metric series alive indefinitely; now an idle-but-connected group's series get deleted after metricsCleanupTimeout and recreated on the next activity (a counter reset, which PromQL rate() tolerates). Probably fine — but is this intended? If kept, please call it out in the PR description. Same question for the guard in counterMetrics.add (L425).

}

func (rm *ruMetering) add(consumption *consumptionItem) {
if consumption == nil || consumption.Consumption == nil {

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 guard is unreachable: the only call path is backgroundMetricsFlushruCollector.Collectrm.add, and the flush loop already skips items with consumptionInfo.Consumption == nil before calling Collect. Suggest dropping it — TestRUCollectorUsesConsumption still works as a plain behavior test.

func reportedRequestConsumption(calculators []ResourceCalculator, req RequestInfo, tokenDelta *rmpb.Consumption) *rmpb.Consumption {
reported := cloneConsumption(tokenDelta)
adjustPagingPrechargeRRU(calculators, reported, req, -1)
sub(reported, writeReservationConsumption(calculators, req))

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.

Stripping the write reservation from the reported request consumption (and re-adding it at response time) is severable from paging pre-charge — the feature itself only needs the adjustPagingPrechargeRRU strip/restore pair. This line changes when WRU is reported for all writes (success: request time → response time), and it also fixes a real pre-existing quirk: a failed write straddling a report boundary used to over-count WRU, because the monotonic > guards in updateDeltaConsumption never subtract the payback. That fix is legitimate, but bundling it here widens the blast radius of an already-XXL PR. Could it move to a separate PR, or at least be called out explicitly in the description as an independent behavior change? If minimizing this PR is preferred, keeping the write path as-is would also let writeReservationConsumption/writeRefundConsumption (~40 lines) drop out.

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.

delta.TiflashRUV2 = now.TiflashRUV2 - last.TiflashRUV2
last.TiflashRUV2 = now.TiflashRUV2
}
if isZeroConsumption(delta) {

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.

Returning nil for an all-zero delta means new clients stop sending empty periodic reports. Old servers handle a nil ConsumptionSinceLastRequest fine (the flush loop already skips it), but combined with the server-side zero guard this is what changes the idle-group metric cleanup semantics — see my comment in metrics.go. Intended? Worth a line in the PR description either way.

})
delta := (new - counter.avgRUPerSecLastRU) / deltaDuration.Seconds()
ruDelta := new - counter.avgRUPerSecLastRU
if ruDelta < 0 {

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 clamp also affects non-paging flows, and in the current revision I'm not sure any steady-state path still produces a negative delta: pre-charge refunds no longer enter gc.mu.consumption (the reported deltas are all non-negative), and the failed-write payback no longer decreases it either. The remaining source seems to be the transient add-then-sub window in onRequestWaitImpl's failure path, which pre-dates this PR. Is the clamp load-bearing for pre-charge, or defensive? A short code comment naming the path that produces negative deltas would help future readers.

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

@JmPotato JmPotato 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 — once the two inline comments below are addressed, this is good to merge from my side.

counter := gc.run.requestUnitTokens
if v := getRUValueFromConsumption(delta); v > 0 {
counter.limiter.RemoveTokens(time.Now(), v)
} else if v < 0 {

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 branch catches more than the paging over-estimate the comment describes. A failed write's settlement delta is readBytesCost − writeCost, which is usually negative, so failed writes now refund their reserved write tokens to the local limiter through this path. On master, acquireTokens only acts on v > 0 and this payback was silently dropped, so this is a real behavior change for non-paging traffic: the net local debit for a failed write becomes just the read-bytes cost instead of the full write reservation. I believe the new behavior is the more correct one, but please: (1) update this comment (and the twin branch in onResponseWaitImpl) to say it covers both paging over-estimates and failed-write paybacks; (2) add a limiter-level assertion for the failed-write refund — TestFailedWriteReservationDoesNotEnterReportedConsumption only checks the reporting side; (3) document this together with the write-reservation reporting shift (WRU moving from request time to response time) in the PR description, which currently only describes the read path.

}
}

func writeReservationConsumption(calculators []ResourceCalculator, req RequestInfo) *rmpb.Consumption {

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.

Hot-path allocations: reportedRequestConsumption/reportedResponseConsumption run for every KV request, and this helper allocates the empty &rmpb.Consumption{} before the IsWrite() check, so pure-read workloads pay for it on both the request and response sides (same in writeRefundConsumption), on top of the unconditional cloneConsumption. add/sub are already nil-safe, so returning nil for non-writes removes most of it — roughly 4 extra allocations per read request back to master's level. Optionally, when the request is neither a write nor carries a paging hint, the clone can be skipped entirely and tokenDelta returned as-is (neither value is mutated afterwards), but the nil-for-non-writes fix alone is the important part.

Signed-off-by: Yuhao Zhang <yhzhang00@outlook.com>
Signed-off-by: Yuhao Zhang <yhzhang00@outlook.com>
Signed-off-by: Yuhao Zhang <yhzhang00@outlook.com>
Signed-off-by: Yuhao Zhang <yhzhang00@outlook.com>
@YuhaoZhang00 YuhaoZhang00 requested a review from JmPotato July 8, 2026 06:55

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
client/resource_group/controller/group_controller.go (1)

453-459: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Close the previous cancelCh before overwriting it.

Line 455 stops the old timer, but Line 459 replaces cancelCh without waking any goroutine already waiting on the old channel. Use resetCounterNotifyLocked and close the returned channel after unlocking.

Proposed fix
 		counter.notify.mu.Lock()
-		if counter.notify.setupNotificationTimer != nil {
-			counter.notify.setupNotificationTimer.Stop()
-		}
+		cancelCh := resetCounterNotifyLocked(counter)
 		counter.notify.setupNotificationTimer = time.NewTimer(timerDuration)
 		counter.notify.setupNotificationCh = counter.notify.setupNotificationTimer.C
 		counter.notify.cancelCh = make(chan struct{})
 		counter.notify.setupNotificationThreshold = 1
 		counter.notify.mu.Unlock()
+		if cancelCh != nil {
+			close(cancelCh)
+		}

As per coding guidelines, “Prevent goroutine leaks: pair with cancellation; consider errgroup.”

🤖 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/group_controller.go` around lines 453 - 459,
Close the existing cancelCh before assigning a new one in the notification reset
path so any goroutine waiting on the old channel is unblocked; update the logic
around resetCounterNotifyLocked/counter.notify.setupNotificationTimer to stop
the old timer, capture the previous cancelCh, overwrite the fields, then close
the old channel after releasing the lock to avoid leaking waiters.

Source: Coding guidelines

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

Outside diff comments:
In `@client/resource_group/controller/group_controller.go`:
- Around line 453-459: Close the existing cancelCh before assigning a new one in
the notification reset path so any goroutine waiting on the old channel is
unblocked; update the logic around
resetCounterNotifyLocked/counter.notify.setupNotificationTimer to stop the old
timer, capture the previous cancelCh, overwrite the fields, then close the old
channel after releasing the lock to avoid leaking waiters.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: e4ff3969-5934-42cb-af0a-c2d84c8b5c44

📥 Commits

Reviewing files that changed from the base of the PR and between 1764549 and 871e082.

📒 Files selected for processing (16)
  • client/go.mod
  • client/resource_group/controller/global_controller.go
  • client/resource_group/controller/group_controller.go
  • client/resource_group/controller/group_controller_test.go
  • client/resource_group/controller/limiter.go
  • client/resource_group/controller/limiter_test.go
  • client/resource_group/controller/metrics/metrics.go
  • client/resource_group/controller/model.go
  • client/resource_group/controller/model_test.go
  • client/resource_group/controller/testutil.go
  • pkg/mcs/resourcemanager/server/manager_test.go
  • pkg/mcs/resourcemanager/server/metering_test.go
  • pkg/mcs/resourcemanager/server/metrics.go
  • pkg/mcs/resourcemanager/server/metrics_test.go
  • pkg/metering/utils.go
  • pkg/metering/utils_test.go
✅ Files skipped from review due to trivial changes (1)
  • pkg/metering/utils_test.go
🚧 Files skipped from review as they are similar to previous changes (3)
  • client/resource_group/controller/limiter_test.go
  • client/resource_group/controller/limiter.go
  • client/resource_group/controller/testutil.go

JmPotato
JmPotato approved these changes Jul 8, 2026
@ti-chi-bot ti-chi-bot Bot added the lgtm label Jul 8, 2026
@ti-chi-bot

ti-chi-bot Bot commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

[APPROVALNOTIFIER] This PR is APPROVED

This pull-request has been approved by: JmPotato, rleungx

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

The pull request process is described 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 removed the needs-1-more-lgtm Indicates a PR needs 1 more LGTM. label Jul 8, 2026
@ti-chi-bot

ti-chi-bot Bot commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

[LGTM Timeline notifier]

Timeline:

  • 2026-06-23 09:40:06.137856648 +0000 UTC m=+26889.801082162: ☑️ agreed by rleungx.
  • 2026-07-08 07:21:16.635660422 +0000 UTC m=+179862.671755478: ☑️ agreed by JmPotato.

@JmPotato

JmPotato commented Jul 8, 2026

Copy link
Copy Markdown
Member

/unhold
/retest

@ti-chi-bot ti-chi-bot Bot removed the do-not-merge/hold Indicates that a PR should not merge because someone has issued a /hold command. label Jul 8, 2026
@JmPotato

JmPotato commented Jul 8, 2026

Copy link
Copy Markdown
Member

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

@ti-chi-bot ti-chi-bot Bot merged commit 4e05b9d into tikv:master Jul 8, 2026
39 of 43 checks passed
@ti-chi-bot

ti-chi-bot Bot commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

@YuhaoZhang00: The following test 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-integration-realcluster-test 871e082 link unknown /test pull-integration-realcluster-test

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.

JmPotato pushed a commit to JmPotato/pd that referenced this pull request Jul 15, 2026
…es hint (tikv#10611)

close tikv#10612, closes tikv#10985\n\nSigned-off-by: Yuhao Zhang <yhzhang00@outlook.com>

(cherry picked from commit 4e05b9d)
Signed-off-by: JmPotato <github@ipotato.me>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

approved contribution This PR is from a community contributor. dco-signoff: yes Indicates the PR's author has signed the dco. lgtm ok-to-test Indicates a PR is ready to be tested. 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

3 participants