Skip to main content

polars_io/cloud/
http_rate_limit.rs

1// Additive Increase / Multiplicative Decrease (AIMD) adaptive rate-limiter at
2// the HttpService with JIT-pricing.
3//
4// Components:
5// - Rate-limiter: responsible for config-based rate-limiting, including adapting
6//   to the observed success rate.
7// - Pacer (lock-free hot path): responsible for pacing the requests on `admit()`.
8//   The pacer will deny requests that have an estimated wait that is too far out.
9//   Holds an AtomicU64 f64 bits representation of the learned rate.
10// - PacerSignal (lock-free warm path): collects metrics from the pacer and holds
11//   time window boundaries to protect the cold path Mutex.
12// - AimdState (cold path): actuator, adapts the rate based on the observed
13//   HTTP success or failure rate. Authoritative for the atomic (learned) rate,
14//   shared real-time lock-free with the pacer and inflight concurrency controller.
15//
16// Integration points:
17// - The inflight concurrency controller adjusts its concurrency based on the
18//   learned rate. The `RateCell` contains the shared information.
19// - The object_store is responsible for retries, acting as a shock absorber
20//   for when the pipeline pushes above the rate-limit.
21// - The `InitPolicy` facilitates persistent learning when an object_store
22//   is rebuilt on error.
23//
24// TODO:
25// - Rate-limit is one per object_store per verb. AWS S3 scaled out its
26//   rate-limit per-prefix when hot. This can be used to refine the rate-limiter.
27// - The bridge between rate-limiter and inflight concurrency controller allows
28//   for a more reliable RTT_min signal than the clamped one currently in use,
29//   since it sits below the retry layer.
30
31use std::collections::VecDeque;
32use std::fmt;
33use std::str::FromStr;
34use std::sync::atomic::Ordering::Relaxed;
35use std::sync::atomic::{AtomicU32, AtomicU64};
36use std::sync::{Arc, LazyLock, Mutex};
37use std::time::{Duration, Instant};
38
39use async_trait::async_trait;
40use chrono::Utc;
41use object_store::ClientOptions;
42use object_store::client::{
43    HttpClient, HttpConnector, HttpError, HttpErrorKind, HttpRequest, HttpResponse, HttpService,
44};
45use polars_core::runtime::ASYNC;
46use tokio::sync::oneshot;
47
48use super::token_bucket::TokenBucket;
49use crate::cloud::{CloudDirectionalRateLimitConfig, CloudRateLimitConfig};
50
51// For tracing purposes.
52static CONTROLLER_ID: AtomicU32 = AtomicU32::new(0);
53
54// Verbose.
55static LOG_RATE_LIMIT: LazyLock<bool> =
56    LazyLock::new(|| std::env::var("POLARS_LOG_RATE_LIMIT").is_ok());
57
58// Request/s rate init and boundaries.
59const DEFAULT_INIT_RATE: f64 = 1000.0;
60const DEFAULT_FLOOR_RATE: f64 = 10.0;
61const DEFAULT_CEILING_RATE: f64 = 50_000.0;
62
63// Increase / decrease parameters.
64// Cold-start multiplicative increase, per tick.
65const FAST_RAMP_FACTOR: f64 = 2.0;
66// Additive step increase (relative to max), per tick.
67const PROBE_FRACTION: f64 = 0.1;
68const PROBE_MIN: f64 = 5.0;
69// Multiplicative decrease (on cut), with signal
70const BETA: f64 = 0.7;
71// Multiplicative decrease (on cut), no signal available (e.g. cold start)
72const BETA_NO_SIGNAL: f64 = 0.5;
73
74// Timing parameters.
75// Target queue depth as communicated to the concurrency controller.
76const DEFAULT_RATE_HORIZON_MS: u64 = 200;
77// Maximum queue depth, or admission denial ceiling: fail-fast rather than park
78// when the estimated wait exceeds this.
79// Note. Under nominal conditions the concurrency-driven population cap
80// sets depth below this value. Kicks in as buffer on rate collapse.
81// TODO: Dynamically size based on CloudRetryConfig and Init/Floor values.
82// Indicative sizing: to avoid object store erroring out, the rate-limiter must have
83// sufficient capacity to handle a drop-off from Init to Floor. This comes down
84// (MAX_WAIT * FLOOR) >= (INIT * HORIZON). In addition, the combined system
85// must be able to absorb a retry-storm, the size of which is unknown.
86const DEFAULT_RATE_MAX_WAIT_MS: u64 = 10_000;
87// Settle frequency, where AIMD updates its state based on the observed traffic.
88const TICK_INTERVAL: Duration = Duration::from_secs(1);
89// Period during which additional cuts will be suppressed.
90const REFRACTORY: Duration = Duration::from_secs(1);
91// Period after last cut where growth beyond last_max is deferred.
92const PROBE_QUIET_PERIOD: Duration = Duration::from_secs(2);
93
94// Load and freshness parameters.
95// Utilization bound for growth.
96const SATURATION_THRESHOLD: f64 = 0.8;
97// EWMA weight of fresh goodput sample.
98const SUCCESS_SMOOTH: f64 = 0.8;
99// Decay multiplier towards init on idle, per tick.
100const IDLE_DECAY: f64 = 0.9;
101
102// Wake-latency tick. Bounds how long a parked waiter oversleeps past its
103// arithmetic due-time; does NOT affect pacing accuracy.
104pub(crate) const HTTP_RATE_LIMIT_WAKE_TICK: Duration = Duration::from_millis(1);
105
106// Used for sharing values between concurrency controller and rate-limiter.
107// Captures the f64 value in bits.
108pub type RateCell = Arc<AtomicU64>;
109
110/// What happens to the learned rate state when a store inits. Important
111/// when an object store rebuilds on error.
112#[derive(Debug, Clone, Copy, PartialEq, Eq)]
113#[allow(unused)]
114pub(crate) enum InitPolicy {
115    /// Reset to init values: a rebuild is a deliberate clean slate.
116    SetToInit,
117    /// Reset to floor values: a rebuild falls back to a conservative start.
118    SetToFloor,
119    /// Leave the rate cells untouched: inherit the learned rate, if any.
120    Inherit,
121}
122
123impl InitPolicy {
124    fn target_rate(self, config: &DirectionalRateLimitConfig) -> Option<f64> {
125        match self {
126            Self::SetToInit => Some(config.init_rate),
127            Self::SetToFloor => Some(config.floor_rate),
128            Self::Inherit => None,
129        }
130    }
131}
132
133/// Rate-limit parameters.
134#[derive(Debug, Clone)]
135pub(crate) struct DirectionalRateLimitConfig {
136    // HTTP requests per second (rps).
137    pub(crate) init_rate: f64,
138    pub(crate) floor_rate: f64,
139    pub(crate) ceiling_rate: f64,
140    pub(crate) horizon: Duration,
141    pub(crate) max_wait: Duration,
142    pub(crate) init_policy: InitPolicy,
143}
144
145/// Rate-limit parameters.
146#[derive(Debug, Clone)]
147pub(crate) struct RateLimitConfig {
148    pub(crate) read: DirectionalRateLimitConfig,
149    pub(crate) write: DirectionalRateLimitConfig,
150}
151
152impl From<CloudRateLimitConfig> for RateLimitConfig {
153    fn from(value: CloudRateLimitConfig) -> Self {
154        fn to_rate_limit_config(
155            config: &CloudDirectionalRateLimitConfig,
156        ) -> DirectionalRateLimitConfig {
157            DirectionalRateLimitConfig {
158                init_rate: config.init_rate.map_or(DEFAULTS.init_rate, |r| r as f64),
159                floor_rate: config.floor_rate.map_or(DEFAULTS.floor_rate, |r| r as f64),
160                ceiling_rate: config
161                    .ceiling_rate
162                    .map_or(DEFAULTS.ceiling_rate, |r| r as f64),
163                horizon: DEFAULTS.horizon,
164                max_wait: DEFAULTS.max_wait,
165                init_policy: DEFAULTS.init_policy,
166            }
167        }
168
169        let read_config = to_rate_limit_config(&value.read);
170        let write_config = to_rate_limit_config(&value.write);
171
172        return RateLimitConfig {
173            read: read_config,
174            write: write_config,
175        };
176
177        static DEFAULTS: LazyLock<DirectionalRateLimitConfig> =
178            LazyLock::new(|| DirectionalRateLimitConfig {
179                init_rate: parse_env_var(DEFAULT_INIT_RATE, "POLARS_CLOUD_INIT_RATE"),
180                floor_rate: {
181                    let floor_rate = parse_env_var(DEFAULT_FLOOR_RATE, "POLARS_CLOUD_FLOOR_RATE");
182                    assert!(floor_rate > 0.0);
183                    floor_rate
184                },
185                ceiling_rate: parse_env_var(DEFAULT_CEILING_RATE, "POLARS_CLOUD_CEILING_RATE"),
186                horizon: Duration::from_millis(parse_env_var(
187                    DEFAULT_RATE_HORIZON_MS,
188                    "POLARS_CLOUD_RATE_HORIZON_MS",
189                )),
190                max_wait: Duration::from_millis(parse_env_var(
191                    DEFAULT_RATE_MAX_WAIT_MS,
192                    "POLARS_CLOUD_RATE_MAX_WAIT_MS",
193                )),
194                init_policy: InitPolicy::Inherit,
195            });
196
197        fn parse_env_var<T: FromStr>(default: T, name: &'static str) -> T {
198            std::env::var(name).map_or(default, |x| {
199                x.parse::<T>()
200                    .ok()
201                    .unwrap_or_else(|| panic!("invalid value for {name}: {x}"))
202            })
203        }
204    }
205}
206
207/// Read-only view of the pacing budget including the learned rate-limit signal
208/// for internal consumers (e.g., ConcurrencyController).
209/// The cells contains learned rates (f64 bits in an AtomicU64), and are updated
210/// by the AIMD loop only.
211/// Valid for the process lifetime: object store rebuilds can rotate behind the
212/// cells rather than replacing them.
213#[derive(Debug, Clone)]
214pub struct PacingBudget {
215    rate_bits: RateCell,
216    horizon: Duration,
217}
218
219impl PacingBudget {
220    pub fn rate(&self) -> f64 {
221        f64::from_bits(self.rate_bits.load(Relaxed))
222    }
223
224    pub fn horizon(&self) -> Duration {
225        self.horizon
226    }
227
228    /// Helper so downstream doesn't have to re-implement the formula
229    pub fn request_budget(&self, bdp: f64) -> f64 {
230        bdp.min(self.rate() * self.horizon().as_secs_f64())
231    }
232}
233// Persist rate-limit state when rebuilding an object_store with InitPolicy::Inherit.
234#[derive(Debug, Clone)]
235pub(crate) struct RateState {
236    pub rate_bits: RateCell,
237    pub max_bits: RateCell, // NaN represents None
238}
239
240impl RateState {
241    pub fn new(init_rate: f64) -> Self {
242        Self {
243            rate_bits: Arc::new(AtomicU64::new(init_rate.to_bits())),
244            max_bits: Arc::new(AtomicU64::new(f64::NAN.to_bits())),
245        }
246    }
247
248    #[inline]
249    pub fn get_rate(&self) -> f64 {
250        f64::from_bits(self.rate_bits.load(Relaxed))
251    }
252
253    #[inline]
254    pub fn set_rate(&self, rate: f64) {
255        self.rate_bits.store(rate.to_bits(), Relaxed);
256    }
257
258    #[inline]
259    pub fn get_last_max(&self) -> Option<f64> {
260        // NaN means no value.
261        let value = f64::from_bits(self.max_bits.load(Relaxed));
262        (!value.is_nan()).then_some(value)
263    }
264
265    #[inline]
266    pub fn set_last_max(&self, max: Option<f64>) {
267        let bits = max.unwrap_or(f64::NAN).to_bits();
268        self.max_bits.store(bits, Relaxed);
269    }
270
271    pub fn reset(&self, init_rate: f64) {
272        self.set_rate(init_rate);
273        self.set_last_max(None);
274    }
275}
276
277/// Builder-owned. State that may survive object store rebuilds.
278#[derive(Debug)]
279struct RateLimitState {
280    read_state: RateState,
281    write_state: RateState,
282}
283
284impl RateLimitState {
285    pub(crate) fn new(config: &RateLimitConfig) -> Self {
286        Self {
287            read_state: RateState::new(config.read.init_rate),
288            write_state: RateState::new(config.write.init_rate),
289        }
290    }
291
292    /// Initialize the cell values from prior state on object store rebuild.
293    pub(crate) fn apply_init_policy(&self, config: &RateLimitConfig) {
294        fn apply_one(state: &RateState, config: &DirectionalRateLimitConfig) {
295            if let Some(rate) = config.init_policy.target_rate(config) {
296                state.reset(rate);
297            }
298        }
299
300        apply_one(&self.read_state, &config.read);
301        apply_one(&self.write_state, &config.write);
302    }
303}
304
305#[derive(Debug)]
306pub(crate) struct RateLimiter {
307    pub(crate) config: RateLimitConfig,
308    state: RateLimitState,
309}
310
311impl RateLimiter {
312    pub(crate) fn new(config: RateLimitConfig) -> Self {
313        let state = RateLimitState::new(&config);
314        Self { config, state }
315    }
316
317    /// Initialize the cell values. Called at every store (re)build, before
318    /// constructing the new PacedHttpConnector.
319    pub(crate) fn apply_init_policy(&self) {
320        self.state.apply_init_policy(&self.config);
321    }
322
323    // Read-only view of pacing budget for 'read' based on learned rate.
324    // Targeted at upstream consumers.
325    pub(crate) fn read_budget(&self) -> PacingBudget {
326        PacingBudget {
327            rate_bits: Arc::clone(&self.state.read_state.rate_bits),
328            horizon: self.config.read.horizon,
329        }
330    }
331
332    // Read-only view of pacing budget for 'write' based on learned rate.
333    // Targeted at upstream consumers.
334    #[allow(unused)]
335    pub(crate) fn write_budget(&self) -> PacingBudget {
336        PacingBudget {
337            rate_bits: Arc::clone(&self.state.write_state.rate_bits),
338            horizon: self.config.write.horizon,
339        }
340    }
341}
342
343#[derive(Debug, Copy, Clone)]
344enum Regime {
345    // No knowledge about ceiling or cut. Fast-ramp.
346    Search,
347    // Recover from a congestion event.
348    Recover { until: Instant, anchor: f64 },
349    // Track towards a learned ceiling and probe above.
350    Track { anchor: f64 },
351}
352
353// Adaptive Increase Multiplicative Decrease (AIMD) state.
354// Note: the initial fast_ramp is multiplicative, not additive.
355// Cold path: state gets updated every tick interval.
356#[derive(Debug)]
357struct AimdState {
358    // How to drive the rate based on what has been observed.
359    regime: Regime,
360
361    // Source of truth for the learned request rates in requests per second (rps).
362    // Represented as f64 in bits and shared as Atomic cells.
363    shared: RateState,
364    // Exponentially weighted moving average (EWMA) of the success rate.
365    success_rate: Option<f64>,
366    // Start of window, where window is the interval between 2 ticks.
367    window_start: Option<Instant>,
368    // Last cut time.
369    last_cut_time: Option<Instant>,
370
371    // Pacer statistics at last tick.
372    prev_admitted: u64,
373    prev_denied: u64,
374    prev_resp_succeeded: u64,
375    prev_resp_throttled: u64,
376}
377
378impl AimdState {
379    #[inline]
380    pub fn rate(&self) -> f64 {
381        self.shared.get_rate()
382    }
383
384    #[inline]
385    pub fn set_rate(&mut self, rate: f64) {
386        self.shared.set_rate(rate);
387    }
388
389    #[inline]
390    pub fn last_max(&self) -> Option<f64> {
391        self.shared.get_last_max()
392    }
393
394    #[inline]
395    pub fn set_last_max(&mut self, max: Option<f64>) {
396        self.shared.set_last_max(max);
397    }
398
399    /// The success_rate is the observed 'goodput' signal.
400    fn update_success_rate(&mut self, successes: u64, elapsed_s: f64) {
401        // Too short to be statistically meaningful (also guards div-by-zero).
402        if elapsed_s < 0.5 * TICK_INTERVAL.as_secs_f64() {
403            return;
404        }
405
406        if successes == 0 {
407            return;
408        }
409
410        let success_rate = successes as f64 / elapsed_s;
411        self.success_rate = Some(match self.success_rate {
412            None => success_rate,
413            Some(rate) => SUCCESS_SMOOTH * success_rate + (1.0 - SUCCESS_SMOOTH) * rate,
414        });
415    }
416}
417
418// Lock-free metrics and time window filter to guard the Mutex, warm path.
419#[derive(Debug)]
420pub(crate) struct PacerSignal {
421    // Fast-path view of window end.
422    window_end_ns: AtomicU64,
423    // Advisory refractory pre-check (0 = never).
424    last_cut_ns: AtomicU64,
425    // Cumulative HTTP response counters, since epoch.
426    resp_succeeded: AtomicU64,
427    resp_throttled: AtomicU64,
428}
429
430#[derive(Debug)]
431pub(crate) struct AdaptiveRateController {
432    epoch: Instant,
433    // Hot path, lock-free atomics.
434    //  TBD - do we need an Arc?
435    pacer: Arc<Pacer>,
436    // Warm signal path, lock-free atomics.
437    signal: PacerSignal,
438    // Cold path, change state and rate.
439    state: Mutex<AimdState>,
440    label: &'static str,
441    id: u32,
442    config: DirectionalRateLimitConfig,
443}
444
445impl AdaptiveRateController {
446    fn new(
447        label: &'static str,
448        id: u32,
449        shared: RateState,
450        config: DirectionalRateLimitConfig,
451    ) -> Self {
452        let epoch = Instant::now();
453
454        let regime = match shared.get_last_max() {
455            Some(anchor) => Regime::Track { anchor },
456            None => Regime::Search,
457        };
458
459        let token_bucket = Arc::new(TokenBucket::new(shared.rate_bits.clone()));
460        let pacer = Pacer::start(token_bucket, config.max_wait);
461
462        let signal = PacerSignal {
463            window_end_ns: AtomicU64::new(u64::MAX),
464            last_cut_ns: AtomicU64::new(0),
465            resp_succeeded: AtomicU64::new(0),
466            resp_throttled: AtomicU64::new(0),
467        };
468
469        let state = Mutex::new(AimdState {
470            regime,
471            shared,
472            success_rate: None,
473            window_start: None,
474            last_cut_time: None,
475            prev_admitted: 0,
476            prev_denied: 0,
477            prev_resp_succeeded: 0,
478            prev_resp_throttled: 0,
479        });
480
481        Self {
482            epoch,
483            pacer,
484            signal,
485            state,
486            label,
487            id,
488            config,
489        }
490    }
491
492    #[inline]
493    fn now_ns(&self) -> u64 {
494        self.epoch.elapsed().as_nanos() as u64
495    }
496
497    #[inline]
498    fn mark_first_traffic(&self) {
499        // The window origin is FIRST TRAFFIC, not construction: the pipeline
500        // takes an unknown time to spin up, and a window that spans the idle
501        // prologue measures emptiness (4 successes / 533ms at cold start).
502        if self.signal.window_end_ns.load(Relaxed) == u64::MAX {
503            let now_ns = self.now_ns();
504            self.signal
505                .window_end_ns
506                .store(now_ns + TICK_INTERVAL.as_nanos() as u64, Relaxed);
507            self.state.lock().unwrap().window_start = Some(Instant::now());
508        }
509    }
510
511    fn on_congestion(&self) {
512        self.signal.resp_throttled.fetch_add(1, Relaxed);
513
514        self.mark_first_traffic();
515        self.maybe_settle();
516
517        // Lock-free fast-path (1): advisory refractory pre-check, avoid Mutex storm.
518        let now_ns = self.now_ns();
519        let last = self.signal.last_cut_ns.load(Relaxed);
520        if last != 0 && now_ns.saturating_sub(last) < REFRACTORY.as_nanos() as u64 {
521            return;
522        }
523
524        // Locking fast-path (2): check the authoritative `last_cut` in AimdState.
525        let now = Instant::now();
526        let mut state = self.state.lock().unwrap();
527        if state
528            .last_cut_time
529            .is_some_and(|t| now.duration_since(t) < REFRACTORY)
530        {
531            return;
532        }
533
534        // Calculate anchor rate, which is our (conservative) estimate for the unknown rate-limit enforced
535        // by the back-end.
536        let (anchor, beta) = match state.success_rate {
537            None => (state.rate(), BETA_NO_SIGNAL),
538            Some(success_rate) => (
539                state.rate().min(success_rate.max(self.config.floor_rate)),
540                BETA,
541            ),
542        };
543
544        // Activate new rate and update state.
545        state.set_rate((anchor * beta).max(self.config.floor_rate));
546        state.last_cut_time = Some(now);
547        self.signal.last_cut_ns.store(now_ns, Relaxed);
548
549        // Unconditionally move into Recover on every cut.
550        state.regime = Regime::Recover {
551            until: now.checked_add(PROBE_QUIET_PERIOD).unwrap(),
552            anchor,
553        };
554
555        // Log.
556        if *LOG_RATE_LIMIT {
557            eprintln!(
558                "[http rate_limit #{}_{} {}] ..cut (anchored): rate: {:.1}, success_rate: {:.1}, last_max: {:.1}",
559                self.id,
560                self.label,
561                Utc::now(),
562                state.rate(),
563                state.success_rate.unwrap_or_default(),
564                state.last_max().unwrap_or_default(),
565            );
566        }
567    }
568
569    fn on_success(&self) {
570        self.signal.resp_succeeded.fetch_add(1, Relaxed);
571
572        self.mark_first_traffic();
573        self.maybe_settle();
574    }
575
576    fn on_other(&self) {
577        self.mark_first_traffic();
578        self.maybe_settle();
579    }
580
581    #[inline]
582    fn maybe_settle(&self) {
583        // Lock-free fast path when settlement is not due.
584
585        if self.now_ns() >= self.signal.window_end_ns.load(Relaxed) {
586            self.settle_lazy_tick();
587        }
588    }
589
590    // Settle the rate-limiter after every tick interval, on the first success response.
591    fn settle_lazy_tick(&self) {
592        let mut state = self.state.lock().unwrap();
593        let now = Instant::now();
594        let now_ns = self.now_ns();
595
596        // Another success event may have settled while we waited on the lock.
597        let elapsed = now.duration_since(state.window_start.unwrap());
598        let ticks = (elapsed.as_secs_f64() / TICK_INTERVAL.as_secs_f64()).floor();
599        if ticks < 1.0 {
600            return;
601        }
602
603        // Update AIMD stats.
604        let admitted = self.pacer.admitted();
605        let win_admitted = admitted.saturating_sub(state.prev_admitted);
606        state.prev_admitted = admitted;
607
608        let denied = self.pacer.denied.load(Relaxed);
609        let win_denied = denied.saturating_sub(state.prev_denied);
610        state.prev_denied = denied;
611
612        let succeeded = self.signal.resp_succeeded.load(Relaxed);
613        let win_succeeded = succeeded.saturating_sub(state.prev_resp_succeeded);
614        state.prev_resp_succeeded = succeeded;
615
616        let throttled = self.signal.resp_throttled.load(Relaxed);
617        let win_throttled = throttled.saturating_sub(state.prev_resp_throttled);
618        state.prev_resp_throttled = throttled;
619
620        // Growth conditions.
621        let elapsed_s = elapsed.as_secs_f64();
622        let queued = self.pacer.queue_depth() > 0;
623        let util_bound = win_admitted as f64 >= SATURATION_THRESHOLD * state.rate() * elapsed_s;
624
625        // Update signal, if any.
626        state.update_success_rate(win_succeeded, elapsed_s);
627
628        // Goodput is a always a lower bound on capacity, so an observation may always
629        // increase. It may only decrease when the system was pressure-tested (i.e., demand saturated).
630        if let Some(observed) = state.success_rate {
631            let pressure_tested =
632                win_admitted as f64 / elapsed_s >= state.last_max().unwrap_or(0.0);
633            let new_max = match state.last_max() {
634                Some(prev) if !pressure_tested => prev.max(observed),
635                _ => observed,
636            };
637            state.set_last_max(Some(new_max.max(self.config.floor_rate)));
638        }
639
640        // Evaluate and apply rate rate and regime changes.
641        let verdict = {
642            let rate = state.rate();
643            let init_rate = self.config.init_rate;
644            let floor_rate = self.config.floor_rate;
645            let ceiling_rate = self.config.ceiling_rate;
646
647            if let Regime::Recover { until, anchor } = state.regime
648                && now > until
649            {
650                state.regime = Regime::Track { anchor }
651            };
652
653            if win_admitted + win_denied == 0 {
654                // No demand - decay toward seed only if we are currently above it.
655                if rate > init_rate {
656                    let rate = init_rate + (state.rate() - init_rate) * IDLE_DECAY.powf(ticks);
657                    state.set_rate(rate.clamp(floor_rate, ceiling_rate));
658                }
659                // Note: we leave last_max as-is which is not always right.
660                "decay (idle)"
661            } else {
662                match state.regime {
663                    Regime::Search => {
664                        // Cold start -> aggressive exponential growth, if earned
665                        // Note. One may expect `.powf(ticks)`; this is deliberately omitted.
666                        if util_bound && win_throttled == 0 {
667                            state.set_rate((rate * FAST_RAMP_FACTOR).min(ceiling_rate));
668                            "increase (fast_ramp)"
669                        } else {
670                            "hold (app-limited)"
671                        }
672                    },
673                    Regime::Recover { .. } => {
674                        // No-op, rate changes were handled in `on_congestion`
675                        "hold (recover)"
676                    },
677                    Regime::Track { anchor } => {
678                        // Below anchor + backlogged/saturated -> fast reclaim up to our known-good ceiling
679                        if rate < anchor && util_bound {
680                            state.set_rate((rate * FAST_RAMP_FACTOR).min(anchor).min(ceiling_rate));
681                            "increase (reclaim)"
682                        } else
683                        // Below anchor but not hitting capacity bounds -> wait for more traffic
684                        if rate < anchor {
685                            "hold (app-limited)"
686                        } else
687                        // At or above anchor + saturated + past quiet period -> cautious additive probing
688                        if util_bound {
689                            let delta = (PROBE_FRACTION * anchor).max(PROBE_MIN);
690                            state.set_rate((rate + delta).min(ceiling_rate));
691                            "increase (probe)"
692                        } else
693                        // At/above anchor and backlog is draining out.
694                        if queued {
695                            "hold (draining)"
696                        } else {
697                            // Fallback for unutilized capacity above the anchor.
698                            "hold (app-limited)"
699                        }
700                    },
701                }
702            }
703        };
704
705        // Update state.
706        state.window_start = Some(now);
707        self.signal
708            .window_end_ns
709            .store(now_ns + TICK_INTERVAL.as_nanos() as u64, Relaxed);
710
711        // Logging.
712        if *LOG_RATE_LIMIT {
713            eprintln!(
714                "[http rate_limit #{}_{} {}] {}: rate: {:.1}, success_rate_ewma: {:.1}, last_max: {:.1}, \
715                    elapsed: {:.1}s, win_admit: {}, win_deny: {}, win_success: {}, win_throttle: {},  q_depth: {}",
716                self.id,
717                self.label,
718                Utc::now(),
719                verdict,
720                state.rate(),
721                state.success_rate.unwrap_or_default(),
722                state.last_max().unwrap_or_default(),
723                elapsed_s,
724                win_admitted,
725                win_denied,
726                win_succeeded,
727                win_throttled,
728                self.pacer.queue_depth(),
729            );
730        }
731    }
732}
733
734/// Grouped rate-limit controller as used by a logical object_store instance.
735#[derive(Debug)]
736pub(crate) struct RateController {
737    // GET, HEAD
738    pub read: AdaptiveRateController,
739    // PUT, POST, DELETE
740    pub write: AdaptiveRateController,
741}
742
743impl RateController {
744    pub(crate) fn new(rate_limiter: &RateLimiter) -> Arc<Self> {
745        // NOTE: A rebuilt object_store can share one rate state.
746        // Acceptable transient situation.
747        let id = CONTROLLER_ID.fetch_add(1, Relaxed);
748        Arc::new(Self {
749            read: AdaptiveRateController::new(
750                "read",
751                id,
752                rate_limiter.state.read_state.clone(),
753                rate_limiter.config.read.clone(),
754            ),
755            write: AdaptiveRateController::new(
756                "write",
757                id,
758                rate_limiter.state.write_state.clone(),
759                rate_limiter.config.write.clone(),
760            ),
761        })
762    }
763
764    fn class(&self, req: &HttpRequest) -> &AdaptiveRateController {
765        match req.method().as_str() {
766            "GET" | "HEAD" => &self.read,
767            _ => &self.write,
768        }
769    }
770}
771
772#[derive(Debug)]
773struct PacerBusy {
774    est_wait_ms: u64,
775}
776impl fmt::Display for PacerBusy {
777    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
778        write!(
779            f,
780            "internal pacer denied: estimated wait {}ms exceeds bound",
781            self.est_wait_ms
782        )
783    }
784}
785impl std::error::Error for PacerBusy {}
786
787// PACER INCL WAITER_QUEUE
788
789// The pacer is responsible for admissin/denial into the `TokenBucket` annex
790// `WaiterQueue`. Once admitted, it paces the requests to the prescribed rate.
791
792#[derive(Debug, Default)]
793struct WaiterQueue {
794    parked: Mutex<VecDeque<oneshot::Sender<()>>>,
795    /// Lock-free mirror of parked.len(): read by the fast path (anti-barge),
796    /// the population coupling, and telemetry.
797    depth: AtomicU64,
798}
799
800impl WaiterQueue {
801    fn park(&self) -> oneshot::Receiver<()> {
802        let (tx, rx) = oneshot::channel();
803        let mut q = self.parked.lock().unwrap();
804        q.push_back(tx);
805        self.depth.store(q.len() as u64, Relaxed);
806        rx
807    }
808
809    /// Pop the head and deliver a grant. Returns false when the queue is
810    /// empty. A dead (cancelled) head consumes no grant: we skip it and keep
811    /// the token for the next live waiter.
812    fn grant_one(&self) -> bool {
813        let mut q = self.parked.lock().unwrap();
814        while let Some(tx) = q.pop_front() {
815            self.depth.store(q.len() as u64, Relaxed);
816            if tx.send(()).is_ok() {
817                return true; // grant delivered
818            }
819            // Cancelled waiter: token still in hand, try the next.
820        }
821        false
822    }
823
824    fn depth(&self) -> u64 {
825        self.depth.load(Relaxed)
826    }
827}
828
829/// Lock-free hot path enforcing the rate-limit by pacing requests through the token bucket.
830#[derive(Debug)]
831pub struct Pacer {
832    bucket: Arc<TokenBucket>,
833    queue: Arc<WaiterQueue>,
834    max_wait: Duration,
835    // Decided to admit.
836    admitted: AtomicU64,
837    // Decided not to park.
838    denied: AtomicU64,
839}
840
841impl Pacer {
842    /// Construct and spawn the wake tick. The tick is per-pacer.
843    pub fn start(bucket: Arc<TokenBucket>, max_wait: Duration) -> Arc<Self> {
844        let pacer = Arc::new(Self {
845            bucket,
846            queue: Arc::new(WaiterQueue::default()),
847            max_wait,
848            admitted: AtomicU64::new(0),
849            denied: AtomicU64::new(0),
850        });
851        Self::spawn_wake_tick(Arc::downgrade(&pacer));
852        pacer
853    }
854
855    /// Admit a request to the pacer, which may get queued internally. Returns a
856    /// PacerBusy Error when the estimated wait exceeds the wait bound.
857    pub async fn admit(&self) -> Result<(), HttpError> {
858        // Anti-barge plus fast path.
859        if self.queue.depth() == 0 && self.bucket.try_acquire().is_ok() {
860            self.admitted.fetch_add(1, Relaxed);
861            return Ok(());
862        }
863
864        // JIT rejection: price the line, before parking.
865        let est_wait =
866            Duration::from_secs_f64((self.queue.depth() + 1) as f64 / self.bucket.rate());
867        if est_wait > self.max_wait {
868            self.denied.fetch_add(1, Relaxed);
869            return Err(HttpError::new(
870                HttpErrorKind::Timeout, // retryable by object_store
871                PacerBusy {
872                    est_wait_ms: est_wait.as_millis() as u64,
873                },
874            ));
875        }
876
877        // Park. Wake when granted.
878        let rx = self.queue.park();
879
880        // Wait. On Err, allow through unpaced when WaiterQueue/Pacer is torn down.
881        let _ = rx.await;
882        self.admitted.fetch_add(1, Relaxed);
883        Ok(())
884    }
885
886    pub fn queue_depth(&self) -> u64 {
887        self.queue.depth()
888    }
889
890    pub fn admitted(&self) -> u64 {
891        self.admitted.load(Relaxed)
892    }
893
894    pub fn bucket(&self) -> &Arc<TokenBucket> {
895        &self.bucket
896    }
897
898    /// The single timer in the design. Each tick: while waiters exist AND the
899    /// bucket yields a token, hand grants to the FIFO head. Weak handle so a
900    /// dropped pacer (store rebuild) tears its tick down with it.
901    fn spawn_wake_tick(gate: std::sync::Weak<Self>) {
902        ASYNC.spawn(async move {
903            let mut tick = tokio::time::interval(HTTP_RATE_LIMIT_WAKE_TICK);
904            tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
905            loop {
906                tick.tick().await;
907                let Some(g) = gate.upgrade() else { return };
908
909                while g.queue.depth() > 0 {
910                    if g.bucket.try_acquire().is_err() {
911                        break;
912                    }
913                    if !g.queue.grant_one() {
914                        // Queue drained between the depth check and the grant
915                        // (or all-cancelled): one token over-taken.
916                        break;
917                    }
918                }
919            }
920        });
921    }
922}
923
924// OBJECT_STORE HTTP_SERVICE MIDDLEWARE
925
926#[derive(Debug)]
927pub(crate) struct PacedHttpConnector {
928    inner: Box<dyn HttpConnector>,
929    controller: Arc<RateController>,
930}
931
932impl PacedHttpConnector {
933    pub(crate) fn new(inner: Box<dyn HttpConnector>, rate_limiter: &RateLimiter) -> Self {
934        // The HTTP Connector may re-use its learned request rate.
935        rate_limiter.apply_init_policy();
936
937        Self {
938            inner,
939            controller: RateController::new(rate_limiter),
940        }
941    }
942}
943
944impl HttpConnector for PacedHttpConnector {
945    fn connect(&self, options: &ClientOptions) -> object_store::Result<HttpClient> {
946        let client = self.inner.connect(options)?;
947        Ok(HttpClient::new(PacedHttpService {
948            inner: client,
949            controller: Arc::clone(&self.controller),
950        }))
951    }
952}
953
954#[derive(Debug)]
955pub(crate) struct PacedHttpService {
956    inner: HttpClient,
957    controller: Arc<RateController>,
958}
959
960#[async_trait]
961impl HttpService for PacedHttpService {
962    async fn call(&self, req: HttpRequest) -> Result<HttpResponse, HttpError> {
963        let verb_pacer = self.controller.class(&req);
964
965        // Enforce pacing: on admission, wait; on denied, raise an error.
966        // Requests get denied when the estimated wait is too high (aka 'shed').
967        // The object_store is responsible for retry.
968        verb_pacer.pacer.admit().await?;
969
970        let response = self.inner.execute(req).await;
971        match &response {
972            Ok(r) if r.status().as_u16() == 429 || r.status().as_u16() == 503 => {
973                verb_pacer.on_congestion()
974            },
975            Ok(r) if r.status().is_success() => verb_pacer.on_success(),
976            _ => verb_pacer.on_other(),
977        }
978
979        response
980    }
981}