Files
doris/common/cpp/token_bucket_rate_limiter.cpp
bobhan1 c81ad5b840 [improvement](cloud) Add dry-run mode for BE-to-MS RPC rate limiting (#66977)
### What problem does this PR solve?

Issue Number: N/A

Related PR: #66969, #66940

Problem Summary: BE-to-MetaService host-level and table-level rate
limiting needs production-safe observability before thresholds are
enforced. This PR adds dry-run switches for both paths. Dry-run keeps
limit evaluation, would-wait metrics, per-table QPS collection, and
table-level MS_BUSY state transitions active, but does not delay
requests.

| Scope | New config | Default | Existing enforcement config | Purpose |
| --- | --- | --- | --- | --- |
| Host-level | `enable_ms_rpc_host_level_rate_limit_dry_run` | `true` |
`enable_ms_rpc_host_level_rate_limit` (default `false`) | Evaluate each
configured per-RPC token bucket, record would-wait observations, and log
would-throttle decisions without sleeping. |
| Table-level | `enable_ms_backpressure_handling_dry_run` | `true` |
`enable_ms_backpressure_handling` (default `false`) | Keep per-table QPS
collection and the MS_BUSY adaptive-throttling state machine active, but
do not sleep on a throttle decision. |

For each scope, the dry-run and enforcement configs interact as follows:

- When both configs are `false`, rate-limit evaluation for that scope is
disabled.
- When only the enforcement config is `true`, the limiter is enforced
and the request sleeps for the calculated wait time.
- When the dry-run config is `true`, dry-run takes precedence regardless
of the enforcement config: the limiter state, metrics, and logs are
updated, but the request does not sleep.

Both host-level and table-level dry-run paths reserve against the same
limiter state used by enforcement, matching the FE dry-run model:
dry-run suppresses sleeping but preserves reservation state. When a
table-level downgrade restores a previous QPS limit, it resets queued
reservations so stale wait debt does not carry into the relaxed limit;
the final downgrade removes the limiter.

The table-level coordinator uses 64-bit counters with overflow-free
saturation at each timing threshold. Its MS_BUSY downgrade timer is
reset to inactive when there is no pending upgrade history, while the
upgrade cooldown counter is preserved and stops at the configured
threshold so runtime cooldown changes retain the existing timing
semantics.

### Signals after rate limiting is triggered

Throttle-decision logs use `INFO` level and are suppressed independently
per RPC type to at most one log per second. They are emitted only when
the limiter calculates a positive wait.

Host-level dry-run logs explicitly report the estimated wait:

```text
[ms-throttle] host-level rate limiter dry run would throttle MS RPC request, rpc=get rowset, estimated_wait_ns=1250000, qps_limit=320
```

With enforcement enabled and dry-run disabled, the corresponding log
reports the actual sleep:

```text
[ms-throttle] host-level rate limiter throttled MS RPC request, rpc=get rowset, sleep_ns=1250000, qps_limit=320
```

Table-level dry-run and enforcement use the same trigger log so
operators can search one stable pattern:

```text
[ms-throttle] table-level rate limiter triggered for MS RPC request, rpc=commit_rowset, table_id=10001, wait_us=8200, current_qps=146.7, qps_limit=100
```

For table-level dry-run, `wait_us` is the estimated wait and the request
is not delayed. With enforcement enabled and dry-run disabled, the
request sleeps for that wait. MS_BUSY-driven table-level state
transitions also emit `INFO` logs, for example:

```text
[ms-throttle] received MS_BUSY, triggering upgrade
[ms-throttle] upgrade: rpc=commit_rowset, table_id=10001, current_qps=200, old_limit=0, new_limit=150
[ms-throttle] downgrade: rpc=commit_rowset, table_id=10001, removed limit
```

The same events are observable through bvars: host-level per-RPC latency
recorders based on `host_level_ms_rpc_rate_limit_sleep`, table-level
`ms_rpc_backpressure_throttle_wait_<rpc>` latency recorders, and
`ms_rpc_backpressure_ms_busy_*`, `ms_rpc_backpressure_upgrade_*`, and
`ms_rpc_backpressure_downgrade_*` counters/windows.

### Release note

Add dry-run observation for BE-to-MetaService host-level and table-level
RPC rate limiting without delaying requests. Both dry-run switches are
enabled by default and take precedence over enforcement. Dry-run and
enforcement share limiter reservation state, table-level downgrade
resets queued reservations when restoring a previous QPS limit, and
long-running table-level coordinator timers no longer overflow.

### Check List (For Author)

- Test
    - [ ] Regression test
    - [x] Unit Test
- `./run-be-ut.sh --run
--filter='HostLevelMSRpcRateLimitersTest.*:StrictQpsLimiterTest.*:TableRpcThrottlerTest.*:MSBackpressureHandlerTest.*:RpcThrottleStateMachineTest.*:RpcThrottleIntegrationTest.*'
-j100` (56 tests passed)
- `./run-be-ut.sh --run
--filter='TokenBucketRateLimiterTest.*:S3RateLimiterManagerTest.*:S3RateLimiterMetricsTest.*'
-j100` (11 tests passed)
- `./run-be-ut.sh --run
--filter='MSBackpressureHandlerTest.*:RpcThrottleStateMachineTest.*:RpcThrottleCoordinatorTest.*:RpcThrottleIntegrationTest.*'
-j100` (44 tests passed)
        - `./build.sh --be -j100`
        - `build-support/check-format.sh`
        - `build-support/check-build-hygiene.sh`
    - [ ] Manual test (add detailed scripts or steps below)
    - [ ] No need to test or manual test. Explain why:
- [ ] This is a refactor/code format and no logic has been changed.
        - [ ] Previous test can cover this change.
        - [ ] No code files have been changed.
        - [ ] Other reason

- Behavior changed:
    - [ ] No.
- [x] Yes. Dry-run observation for both limiter levels is enabled by
default and does not delay MetaService RPCs; dry-run and enforcement
share reservation state; table-level downgrade resets queued
reservations; coordinator timing counters saturate without overflowing;
actual and dry-run decisions emit per-RPC rate-limited `INFO` logs.

- Does this need documentation?
    - [x] No.
    - [ ] Yes.

### Check List (For Reviewer who merge this PR)

- [ ] Confirm the release note
- [ ] Confirm test cases
- [ ] Confirm document
- [ ] Add branch pick label
2026-08-28 10:14:12 +08:00

292 lines
11 KiB
C++

// Licensed to the Apache Software Foundation (ASF) under one
// or more contributor license agreements. See the NOTICE file
// distributed with this work for additional information
// regarding copyright ownership. The ASF licenses this file
// to you under the Apache License, Version 2.0 (the
// "License"); you may not use this file except in compliance
// with the License. You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied. See the License for the
// specific language governing permissions and limitations
// under the License.
#include "token_bucket_rate_limiter.h"
#include <bthread/bthread.h>
#include <glog/logging.h> // IWYU pragma: export
#include <atomic>
#include <chrono>
#include <mutex>
#include <thread>
#if defined(__APPLE__)
#include <ctime>
#endif
namespace doris {
// Just 10^6.
static constexpr auto NS = 1000000000UL;
bvar::Adder<int64_t> s3_get_rate_limit_sleep_ns("s3_get_rate_limit_sleep_ns");
bvar::Adder<int64_t> s3_get_rate_limit_sleep_count("s3_get_rate_limit_sleep_count");
bvar::Adder<int64_t> s3_get_rate_limit_rejected_count("s3_get_rate_limit_rejected_count");
bvar::Adder<int64_t> s3_put_rate_limit_sleep_ns("s3_put_rate_limit_sleep_ns");
bvar::Adder<int64_t> s3_put_rate_limit_sleep_count("s3_put_rate_limit_sleep_count");
bvar::Adder<int64_t> s3_put_rate_limit_rejected_count("s3_put_rate_limit_rejected_count");
static std::atomic<int64_t> s3_get_rate_limit_sleep_log_count {0};
static std::atomic<int64_t> s3_get_rate_limit_rejected_log_count {0};
static std::atomic<int64_t> s3_put_rate_limit_sleep_log_count {0};
static std::atomic<int64_t> s3_put_rate_limit_rejected_log_count {0};
class TokenBucketRateLimiter::SimpleSpinLock {
public:
SimpleSpinLock() = default;
~SimpleSpinLock() = default;
void lock() {
int spin_count = 0;
static constexpr int MAX_SPIN_COUNT = 50;
while (_flag.test_and_set(std::memory_order_acq_rel)) {
spin_count++;
if (spin_count >= MAX_SPIN_COUNT) {
LOG(WARNING) << "Warning: Excessive spinning detected while acquiring lock. Spin "
"count: "
<< spin_count;
spin_count = 0;
}
// Spin until we acquire the lock
}
}
void unlock() { _flag.clear(std::memory_order_release); }
private:
std::atomic_flag _flag = ATOMIC_FLAG_INIT;
};
TokenBucketRateLimiter::TokenBucketRateLimiter(size_t max_speed, size_t max_burst, size_t limit)
: _max_speed(max_speed),
_max_burst(max_burst),
_limit(limit),
_mutex(std::make_unique<TokenBucketRateLimiter::SimpleSpinLock>()),
_remain_tokens(max_burst) {}
TokenBucketRateLimiter::~TokenBucketRateLimiter() = default;
TokenBucketRateLimiterHolder::~TokenBucketRateLimiterHolder() = default;
std::pair<size_t, double> TokenBucketRateLimiter::_update_remain_token(long now, size_t amount) {
// Values obtained under lock to be checked after release
size_t count_value;
double tokens_value;
{
std::lock_guard<SimpleSpinLock> lock(*_mutex);
now = (now < _prev_ns_count) ? _prev_ns_count : now;
if (_max_speed) {
double delta_seconds =
_prev_ns_count ? static_cast<double>(now - _prev_ns_count) / NS : 0;
_remain_tokens = std::min<double>(_remain_tokens + _max_speed * delta_seconds - amount,
_max_burst);
}
_count += amount;
count_value = _count;
if (_limit && count_value > _limit) {
// Keep rejection side-effect free. Roll back before releasing the lock so
// concurrent callers cannot observe debt from a request that will not run.
_count -= amount;
if (_max_speed) {
_remain_tokens = std::min<double>(_remain_tokens + amount, _max_burst);
}
}
tokens_value = _remain_tokens;
_prev_ns_count = now;
}
return {count_value, tokens_value};
}
int64_t TokenBucketRateLimiter::add(size_t amount) {
int64_t sleep_time_ns = reserve(amount);
if (sleep_time_ns > 0) {
bthread_usleep(sleep_time_ns / 1000);
}
return sleep_time_ns;
}
int64_t TokenBucketRateLimiter::reserve(size_t amount) {
// Values obtained under lock to be checked after release
auto duration = std::chrono::steady_clock::now().time_since_epoch();
auto time_nano_count = std::chrono::duration_cast<std::chrono::nanoseconds>(duration).count();
auto [count_value, tokens_value] = _update_remain_token(time_nano_count, amount);
if (_limit && count_value > _limit) {
// CK would throw exception
return -1;
}
// Wait unless there is positive amount of remain_tokens - throttling
int64_t sleep_time_ns = 0;
if (_max_speed && tokens_value < 0) {
sleep_time_ns = static_cast<int64_t>(-tokens_value / _max_speed * NS);
}
return sleep_time_ns;
}
void TokenBucketRateLimiter::refund(size_t amount) {
std::lock_guard<SimpleSpinLock> lock(*_mutex);
if (_max_speed) {
_remain_tokens = std::min<double>(_remain_tokens + amount, _max_burst);
}
_count = (_count >= amount) ? _count - amount : 0;
}
TokenBucketRateLimiterHolder::TokenBucketRateLimiterHolder(size_t max_speed, size_t max_burst,
size_t limit,
std::function<void(int64_t)> metric_func)
: rate_limiter(std::make_shared<TokenBucketRateLimiter>(max_speed, max_burst, limit)),
_enabled(max_speed > 0 || limit > 0),
metric_func(std::move(metric_func)) {}
int64_t TokenBucketRateLimiterHolder::add(size_t amount) {
return add_with_config(amount).sleep_duration;
}
TokenBucketRateLimiterResult TokenBucketRateLimiterHolder::add_with_config(size_t amount) {
return _consume_with_config(amount, true);
}
TokenBucketRateLimiterResult TokenBucketRateLimiterHolder::reserve_with_config(size_t amount) {
return _consume_with_config(amount, false);
}
TokenBucketRateLimiterResult TokenBucketRateLimiterHolder::_consume_with_config(size_t amount,
bool wait) {
// Snapshot the current limiter and consume outside the read lock. The waiting
// path may sleep for a long time when throttled, and holding the read lock across
// the sleep would block reset() (dynamic config update) for the whole duration.
std::shared_ptr<TokenBucketRateLimiter> limiter;
{
std::shared_lock read {rate_limiter_rw_lock};
limiter = rate_limiter;
}
TokenBucketRateLimiterResult result = {.sleep_duration = wait ? limiter->add(amount)
: limiter->reserve(amount),
.max_speed = limiter->get_max_speed(),
.max_burst = limiter->get_max_burst(),
.limit = limiter->get_limit()};
metric_func(result.sleep_duration);
return result;
}
std::shared_ptr<TokenBucketRateLimiter> TokenBucketRateLimiterHolder::charge(size_t amount) {
std::shared_ptr<TokenBucketRateLimiter> limiter;
{
std::shared_lock read {rate_limiter_rw_lock};
limiter = rate_limiter;
}
int64_t sleep_duration = limiter->add(amount);
metric_func(sleep_duration);
return sleep_duration < 0 ? nullptr : limiter;
}
int TokenBucketRateLimiterHolder::reset(size_t max_speed, size_t max_burst, size_t limit) {
auto new_rate_limiter = std::make_shared<TokenBucketRateLimiter>(max_speed, max_burst, limit);
{
std::unique_lock write {rate_limiter_rw_lock};
rate_limiter = std::move(new_rate_limiter);
_enabled.store(max_speed > 0 || limit > 0, std::memory_order_release);
}
return 0;
}
size_t TokenBucketRateLimiterHolder::get_max_speed() const {
std::shared_lock read {rate_limiter_rw_lock};
return rate_limiter->get_max_speed();
}
size_t TokenBucketRateLimiterHolder::get_max_burst() const {
std::shared_lock read {rate_limiter_rw_lock};
return rate_limiter->get_max_burst();
}
size_t TokenBucketRateLimiterHolder::get_limit() const {
std::shared_lock read {rate_limiter_rw_lock};
return rate_limiter->get_limit();
}
std::string to_string(S3RateLimitType type) {
switch (type) {
case S3RateLimitType::GET:
return "get";
case S3RateLimitType::PUT:
return "put";
default:
return std::to_string(static_cast<size_t>(type));
}
}
S3RateLimitType string_to_s3_rate_limit_type(std::string_view value) {
if (value == "get") {
return S3RateLimitType::GET;
} else if (value == "put") {
return S3RateLimitType::PUT;
}
return S3RateLimitType::UNKNOWN;
}
std::function<void(int64_t)> s3_rate_limiter_metric_func(S3RateLimitType type) {
switch (type) {
case S3RateLimitType::GET:
return metric_func_factory(s3_get_rate_limit_sleep_ns, s3_get_rate_limit_sleep_count,
&s3_get_rate_limit_rejected_count);
case S3RateLimitType::PUT:
return metric_func_factory(s3_put_rate_limit_sleep_ns, s3_put_rate_limit_sleep_count,
&s3_put_rate_limit_rejected_count);
default:
return [](int64_t) {};
}
}
int64_t apply_s3_rate_limit(S3RateLimitType type, S3RateLimiterHolder* rate_limiter,
int64_t log_interval) {
auto result = rate_limiter->add_with_config(1);
auto sleep_duration = result.sleep_duration;
if (log_interval <= 0 || sleep_duration == 0) {
return sleep_duration;
}
auto is_get = type == S3RateLimitType::GET;
auto* sleep_log_count =
is_get ? &s3_get_rate_limit_sleep_log_count : &s3_put_rate_limit_sleep_log_count;
auto* rejected_log_count =
is_get ? &s3_get_rate_limit_rejected_log_count : &s3_put_rate_limit_rejected_log_count;
if (sleep_duration > 0) {
int64_t count = sleep_log_count->fetch_add(1, std::memory_order_relaxed) + 1;
if (count == 1 || count % log_interval == 0) {
LOG(INFO) << "S3 " << to_string(type) << " request is throttled by local rate limiter"
<< ", sleep_ms=" << sleep_duration / 1000000 << ", sleep_count=" << count
<< ", token_per_second=" << result.max_speed
<< ", bucket_tokens=" << result.max_burst << ", token_limit=" << result.limit;
}
} else {
int64_t count = rejected_log_count->fetch_add(1, std::memory_order_relaxed) + 1;
if (count == 1 || count % log_interval == 0) {
LOG(WARNING) << "S3 " << to_string(type) << " request is rejected by local rate limiter"
<< ", rejected_count=" << count
<< ", token_per_second=" << result.max_speed
<< ", bucket_tokens=" << result.max_burst
<< ", token_limit=" << result.limit;
}
}
return sleep_duration;
}
} // namespace doris