Skip to main content

lance_io/object_store/
throttle.rs

1// SPDX-License-Identifier: Apache-2.0
2// SPDX-FileCopyrightText: Copyright The Lance Authors
3
4//! AIMD-controlled token bucket rate limiter for ObjectStore operations.
5//!
6//! Wraps any [`object_store::ObjectStore`] with per-category token buckets
7//! whose fill rates are dynamically adjusted by AIMD controllers. When cloud
8//! stores return HTTP 429/503, the fill rate decreases multiplicatively. During
9//! sustained success windows, it increases additively.
10//!
11//! Operations are split into four independent categories — **read**, **write**,
12//! **delete**, **list** — each with its own AIMD controller and token bucket.
13//! This prevents a burst of reads from starving writes, and vice versa.
14//!
15//! # Example
16//!
17//! ```ignore
18//! use lance_io::object_store::throttle::{AimdThrottleConfig, AimdThrottledStore};
19//!
20//! let throttled = AimdThrottledStore::new(target, AimdThrottleConfig::default()).unwrap();
21//! ```
22
23use std::collections::HashMap;
24use std::fmt::{Debug, Display, Formatter};
25use std::ops::Range;
26use std::sync::Arc;
27
28use async_trait::async_trait;
29use bytes::Bytes;
30use futures::StreamExt;
31use futures::stream::BoxStream;
32use lance_core::utils::aimd::{AimdConfig, AimdController, RequestOutcome};
33use lance_core::utils::tracing::TRACE_OBJECT_STORE_THROTTLE;
34#[cfg(test)]
35use object_store::ObjectStoreExt;
36#[cfg(any(feature = "aws", feature = "azure", feature = "gcp"))]
37use object_store::client::{
38    ClientOptions, HttpClient, HttpConnector, HttpError, HttpErrorKind, HttpRequest, HttpResponse,
39    HttpResponseBody, HttpService,
40};
41use object_store::path::Path;
42use object_store::{
43    CopyOptions, GetOptions, GetResult, ListResult, MultipartUpload, ObjectMeta, ObjectStore,
44    PutMultipartOptions, PutOptions, PutPayload, PutResult, RenameOptions, Result as OSResult,
45    UploadPart,
46};
47use rand::Rng;
48use tokio::sync::Mutex;
49use tracing::{debug, warn};
50
51use object_store::list::{PaginatedListOptions, PaginatedListResult, PaginatedListStore};
52
53/// Check whether an `object_store::Error` represents a throttle response
54/// (HTTP 429 / 503) from a cloud object store.
55///
56/// Regrettably, this information is not fully exposed by the `object_store` crate.
57/// There is no generic mechanism for a custom object store to return a throttle error.
58///
59/// However, the builtin object stores all use RetryError when retries are configured and
60/// throttle errors are returned.  Sadly, RetryError is not a public type, so we have to
61/// infer it from the error message.  This is potentially dangerous because these errors
62/// often include the URI itself and that URI could have any characters in it (e.g. if we
63/// look for 429 then we might match a 429 in a UUID).These error messages currently look like:
64///
65/// ", after ... retries, max_retries: ..., retry_timeout: ..."
66///
67/// So, as a crude heuristic, which should work for the builtin object stores, but won't
68/// work for custom object stores, we simply look for the string "retries, max_retries"
69/// in the error message.
70pub fn is_throttle_error(err: &object_store::Error) -> bool {
71    // Only Generic errors can carry throttle responses
72    if let object_store::Error::Generic { source, .. } = err {
73        let message = source.to_string();
74        let lowercase = message.to_ascii_lowercase();
75        lowercase.contains("retries, max_retries")
76            || lowercase.contains("serverbusy")
77            || lowercase.contains("server busy")
78            || lowercase.contains("egress is over the account limit")
79            || lowercase.contains("http 429")
80            || lowercase.contains("status code: 429")
81            || lowercase.contains("429 too many requests")
82            || lowercase.contains("too many requests")
83            || lowercase.contains("slowdown")
84            || lowercase.contains("please reduce your request rate")
85            || lowercase.contains("rate limit")
86            || lowercase.contains("throttling")
87            || lowercase.contains("throttled")
88    } else {
89        false
90    }
91}
92
93/// Configuration for the AIMD-throttled ObjectStore wrapper.
94///
95/// Each operation category (read, write, delete, list) has its own AIMD config.
96/// Use [`with_aimd`](AimdThrottleConfig::with_aimd) to set all categories at
97/// once, or per-category methods like [`with_read_aimd`](AimdThrottleConfig::with_read_aimd)
98/// for fine-grained control.
99#[derive(Debug, Clone)]
100pub struct AimdThrottleConfig {
101    /// AIMD configuration for read operations (get, get_opts, get_range, get_ranges, head).
102    pub read: AimdConfig,
103    /// AIMD configuration for write operations (put, put_opts, put_multipart, copy, rename, etc.).
104    pub write: AimdConfig,
105    /// AIMD configuration for delete operations.
106    pub delete: AimdConfig,
107    /// AIMD configuration for list operations.
108    pub list: AimdConfig,
109    /// Maximum tokens that can accumulate for bursts (shared across all categories).
110    pub burst_capacity: u32,
111    /// Maximum number of retries for throttle errors within the AIMD layer.
112    pub max_retries: usize,
113    /// Minimum backoff in milliseconds between retry attempts.
114    pub min_backoff_ms: u64,
115    /// Maximum backoff in milliseconds between retry attempts.
116    pub max_backoff_ms: u64,
117}
118
119impl Default for AimdThrottleConfig {
120    fn default() -> Self {
121        let aimd = AimdConfig::default();
122        Self {
123            read: aimd.clone(),
124            write: aimd.clone(),
125            delete: aimd.clone(),
126            list: aimd,
127            burst_capacity: 100,
128            max_retries: 3,
129            min_backoff_ms: 100,
130            max_backoff_ms: 300,
131        }
132    }
133}
134
135impl AimdThrottleConfig {
136    /// Set the AIMD configuration for all four operation categories at once.
137    pub fn with_aimd(self, aimd: AimdConfig) -> Self {
138        Self {
139            read: aimd.clone(),
140            write: aimd.clone(),
141            delete: aimd.clone(),
142            list: aimd,
143            ..self
144        }
145    }
146
147    /// Set the AIMD configuration for read operations.
148    pub fn with_read_aimd(self, aimd: AimdConfig) -> Self {
149        Self { read: aimd, ..self }
150    }
151
152    /// Set the AIMD configuration for write operations.
153    pub fn with_write_aimd(self, aimd: AimdConfig) -> Self {
154        Self {
155            write: aimd,
156            ..self
157        }
158    }
159
160    /// Set the AIMD configuration for delete operations.
161    pub fn with_delete_aimd(self, aimd: AimdConfig) -> Self {
162        Self {
163            delete: aimd,
164            ..self
165        }
166    }
167
168    /// Set the AIMD configuration for list operations.
169    pub fn with_list_aimd(self, aimd: AimdConfig) -> Self {
170        Self { list: aimd, ..self }
171    }
172
173    /// Returns `true` when the AIMD throttle layer should be bypassed entirely.
174    pub fn is_disabled(&self) -> bool {
175        self.max_retries == 0
176    }
177
178    pub fn with_burst_capacity(self, burst_capacity: u32) -> Self {
179        Self {
180            burst_capacity,
181            ..self
182        }
183    }
184
185    /// Build an `AimdThrottleConfig` from storage options and environment variables.
186    ///
187    /// Storage options take precedence over environment variables, which take
188    /// precedence over defaults. A single AIMD config is applied to all four
189    /// operation categories (read/write/delete/list).
190    ///
191    /// | Setting              | Storage Option Key               | Env Var                          | Default |
192    /// |----------------------|----------------------------------|----------------------------------|---------|
193    /// | Initial rate         | `lance_aimd_initial_rate`        | `LANCE_AIMD_INITIAL_RATE`        | 2000    |
194    /// | Min rate             | `lance_aimd_min_rate`            | `LANCE_AIMD_MIN_RATE`            | 1       |
195    /// | Max rate             | `lance_aimd_max_rate`            | `LANCE_AIMD_MAX_RATE`            | 5000    |
196    /// | Decrease factor      | `lance_aimd_decrease_factor`     | `LANCE_AIMD_DECREASE_FACTOR`     | 0.5     |
197    /// | Additive increment   | `lance_aimd_additive_increment`  | `LANCE_AIMD_ADDITIVE_INCREMENT`  | 300     |
198    /// | Burst capacity       | `lance_aimd_burst_capacity`      | `LANCE_AIMD_BURST_CAPACITY`      | 100     |
199    /// | Max retries          | `lance_aimd_max_retries`         | `LANCE_AIMD_MAX_RETRIES`         | 3       |
200    /// | Min backoff ms       | `lance_aimd_min_backoff_ms`      | `LANCE_AIMD_MIN_BACKOFF_MS`      | 100     |
201    /// | Max backoff ms       | `lance_aimd_max_backoff_ms`      | `LANCE_AIMD_MAX_BACKOFF_MS`      | 300     |
202    pub fn from_storage_options(
203        storage_options: Option<&HashMap<String, String>>,
204    ) -> lance_core::Result<Self> {
205        fn resolve_f64(
206            key: &str,
207            storage_options: Option<&HashMap<String, String>>,
208            default: f64,
209        ) -> lance_core::Result<f64> {
210            let env_key = key.to_ascii_uppercase();
211            if let Some(val) = storage_options.and_then(|opts| opts.get(key)) {
212                val.parse::<f64>().map_err(|_| {
213                    lance_core::Error::invalid_input(format!(
214                        "Invalid value for storage option '{key}': '{val}'"
215                    ))
216                })
217            } else if let Ok(val) = std::env::var(&env_key) {
218                val.parse::<f64>().map_err(|_| {
219                    lance_core::Error::invalid_input(format!(
220                        "Invalid value for env var '{env_key}': '{val}'"
221                    ))
222                })
223            } else {
224                Ok(default)
225            }
226        }
227
228        fn resolve_u32(
229            key: &str,
230            storage_options: Option<&HashMap<String, String>>,
231            default: u32,
232        ) -> lance_core::Result<u32> {
233            let env_key = key.to_ascii_uppercase();
234            if let Some(val) = storage_options.and_then(|opts| opts.get(key)) {
235                val.parse::<u32>().map_err(|_| {
236                    lance_core::Error::invalid_input(format!(
237                        "Invalid value for storage option '{key}': '{val}'"
238                    ))
239                })
240            } else if let Ok(val) = std::env::var(&env_key) {
241                val.parse::<u32>().map_err(|_| {
242                    lance_core::Error::invalid_input(format!(
243                        "Invalid value for env var '{env_key}': '{val}'"
244                    ))
245                })
246            } else {
247                Ok(default)
248            }
249        }
250
251        fn resolve_usize(
252            key: &str,
253            storage_options: Option<&HashMap<String, String>>,
254            default: usize,
255        ) -> lance_core::Result<usize> {
256            let env_key = key.to_ascii_uppercase();
257            if let Some(val) = storage_options.and_then(|opts| opts.get(key)) {
258                val.parse::<usize>().map_err(|_| {
259                    lance_core::Error::invalid_input(format!(
260                        "Invalid value for storage option '{key}': '{val}'"
261                    ))
262                })
263            } else if let Ok(val) = std::env::var(&env_key) {
264                val.parse::<usize>().map_err(|_| {
265                    lance_core::Error::invalid_input(format!(
266                        "Invalid value for env var '{env_key}': '{val}'"
267                    ))
268                })
269            } else {
270                Ok(default)
271            }
272        }
273
274        fn resolve_u64(
275            key: &str,
276            storage_options: Option<&HashMap<String, String>>,
277            default: u64,
278        ) -> lance_core::Result<u64> {
279            let env_key = key.to_ascii_uppercase();
280            if let Some(val) = storage_options.and_then(|opts| opts.get(key)) {
281                val.parse::<u64>().map_err(|_| {
282                    lance_core::Error::invalid_input(format!(
283                        "Invalid value for storage option '{key}': '{val}'"
284                    ))
285                })
286            } else if let Ok(val) = std::env::var(&env_key) {
287                val.parse::<u64>().map_err(|_| {
288                    lance_core::Error::invalid_input(format!(
289                        "Invalid value for env var '{env_key}': '{val}'"
290                    ))
291                })
292            } else {
293                Ok(default)
294            }
295        }
296
297        let initial_rate = resolve_f64("lance_aimd_initial_rate", storage_options, 2000.0)?;
298        let min_rate = resolve_f64("lance_aimd_min_rate", storage_options, 1.0)?;
299        let max_rate = resolve_f64("lance_aimd_max_rate", storage_options, 5000.0)?;
300        let decrease_factor = resolve_f64("lance_aimd_decrease_factor", storage_options, 0.5)?;
301        let additive_increment =
302            resolve_f64("lance_aimd_additive_increment", storage_options, 300.0)?;
303        let burst_capacity = resolve_u32("lance_aimd_burst_capacity", storage_options, 100)?;
304        let max_retries = resolve_usize("lance_aimd_max_retries", storage_options, 3)?;
305        let min_backoff_ms = resolve_u64("lance_aimd_min_backoff_ms", storage_options, 100)?;
306        let max_backoff_ms = resolve_u64("lance_aimd_max_backoff_ms", storage_options, 300)?;
307
308        let aimd = AimdConfig::default()
309            .with_initial_rate(initial_rate)
310            .with_min_rate(min_rate)
311            .with_max_rate(max_rate)
312            .with_decrease_factor(decrease_factor)
313            .with_additive_increment(additive_increment);
314
315        Ok(Self {
316            max_retries,
317            min_backoff_ms,
318            max_backoff_ms,
319            ..Self::default()
320                .with_aimd(aimd)
321                .with_burst_capacity(burst_capacity)
322        })
323    }
324}
325
326struct TokenBucketState {
327    tokens: f64,
328    last_refill: tokio::time::Instant,
329    rate: f64,
330}
331
332/// Per-category throttle state: an AIMD controller paired with a token bucket.
333struct OperationThrottle {
334    controller: AimdController,
335    bucket: Mutex<TokenBucketState>,
336    burst_capacity: f64,
337    max_retries: usize,
338    min_backoff_ms: u64,
339    max_backoff_ms: u64,
340}
341
342impl OperationThrottle {
343    fn new(
344        aimd_config: AimdConfig,
345        burst_capacity: f64,
346        max_retries: usize,
347        min_backoff_ms: u64,
348        max_backoff_ms: u64,
349    ) -> lance_core::Result<Self> {
350        let initial_rate = aimd_config.initial_rate;
351        let controller = AimdController::new(aimd_config)?;
352        Ok(Self {
353            controller,
354            bucket: Mutex::new(TokenBucketState {
355                tokens: burst_capacity,
356                last_refill: tokio::time::Instant::now(),
357                rate: initial_rate,
358            }),
359            burst_capacity,
360            max_retries,
361            min_backoff_ms,
362            max_backoff_ms,
363        })
364    }
365
366    /// Acquire a token from the bucket, sleeping if none are available.
367    ///
368    /// Each caller reserves a token immediately (allowing `tokens` to go
369    /// negative) so that concurrent waiters queue behind each other instead
370    /// of all waking at the same instant (thundering herd).
371    async fn acquire_token(&self) {
372        let sleep_duration = {
373            let mut bucket = self.bucket.lock().await;
374            let now = tokio::time::Instant::now();
375            let elapsed = now.duration_since(bucket.last_refill).as_secs_f64();
376            bucket.tokens = (bucket.tokens + elapsed * bucket.rate).min(self.burst_capacity);
377            bucket.last_refill = now;
378
379            // Reserve a token (may go negative to queue behind other waiters)
380            bucket.tokens -= 1.0;
381
382            if bucket.tokens >= 0.0 {
383                // Had a token available, no need to sleep
384                return;
385            }
386
387            // Sleep proportional to our position in the queue
388            std::time::Duration::from_secs_f64(-bucket.tokens / bucket.rate)
389        };
390
391        tokio::time::sleep(sleep_duration).await;
392    }
393
394    /// Update the bucket's fill rate from the controller.
395    async fn update_bucket_rate(&self, new_rate: f64) {
396        let mut bucket = self.bucket.lock().await;
397        bucket.rate = new_rate;
398    }
399
400    /// Classify a result and feed it back to the AIMD controller without
401    /// acquiring a token. Uses `try_lock` for the bucket update so that if the
402    /// bucket lock is contended the rate update is deferred to the next
403    /// `throttled()` call.
404    fn observe_outcome<T>(&self, result: &OSResult<T>) {
405        let outcome = match result {
406            Ok(_) => RequestOutcome::Success,
407            Err(err) if is_throttle_error(err) => {
408                debug!(
409                    target: TRACE_OBJECT_STORE_THROTTLE,
410                    error = %err,
411                    "Throttle error detected in stream"
412                );
413                RequestOutcome::Throttled
414            }
415            Err(_) => RequestOutcome::Success,
416        };
417        let error = result
418            .as_ref()
419            .err()
420            .map(|error| error as &dyn std::fmt::Display);
421        let new_rate = self.record_outcome(outcome, error);
422        if let Ok(mut bucket) = self.bucket.try_lock() {
423            bucket.rate = new_rate;
424        }
425    }
426
427    fn record_outcome(
428        &self,
429        outcome: RequestOutcome,
430        error: Option<&dyn std::fmt::Display>,
431    ) -> f64 {
432        let prev_rate = self.controller.current_rate();
433        let new_rate = self.controller.record_outcome(outcome);
434        if new_rate < prev_rate {
435            if let Some(error) = error {
436                warn!(
437                    target: TRACE_OBJECT_STORE_THROTTLE,
438                    previous_rate = format!("{prev_rate:.1}"),
439                    new_rate = format!("{new_rate:.1}"),
440                    error = %error,
441                    "AIMD throttle: rate reduced due to throttle errors"
442                );
443            } else {
444                warn!(
445                    target: TRACE_OBJECT_STORE_THROTTLE,
446                    previous_rate = format!("{prev_rate:.1}"),
447                    new_rate = format!("{new_rate:.1}"),
448                    "AIMD throttle: rate reduced due to throttle errors"
449                );
450            }
451        }
452        new_rate
453    }
454
455    /// Execute an operation with throttling: acquire token, run, classify result.
456    /// On throttle errors, retries up to `max_retries` times with a random
457    /// backoff between `min_backoff_ms` and `max_backoff_ms` between attempts.
458    async fn throttled<T, F, Fut>(&self, f: F) -> OSResult<T>
459    where
460        F: Fn() -> Fut,
461        Fut: std::future::Future<Output = OSResult<T>>,
462    {
463        for attempt in 0..=self.max_retries {
464            self.acquire_token().await;
465            let result = f().await;
466            let outcome = match &result {
467                Ok(_) => RequestOutcome::Success,
468                Err(err) if is_throttle_error(err) => {
469                    debug!(
470                        target: TRACE_OBJECT_STORE_THROTTLE,
471                        error = %err,
472                        "Throttle error detected"
473                    );
474                    RequestOutcome::Throttled
475                }
476                Err(_) => RequestOutcome::Success, // Non-throttle errors don't indicate capacity problems
477            };
478            let error = result
479                .as_ref()
480                .err()
481                .map(|error| error as &dyn std::fmt::Display);
482            let new_rate = self.record_outcome(outcome, error);
483            self.update_bucket_rate(new_rate).await;
484
485            match &result {
486                Err(err) if is_throttle_error(err) && attempt < self.max_retries => {
487                    let backoff_ms =
488                        rand::rng().random_range(self.min_backoff_ms..=self.max_backoff_ms);
489                    debug!(
490                        target: TRACE_OBJECT_STORE_THROTTLE,
491                        attempt = attempt + 1,
492                        max_retries = self.max_retries,
493                        backoff_ms,
494                        error = %err,
495                        "Retrying after throttle error"
496                    );
497                    tokio::time::sleep(std::time::Duration::from_millis(backoff_ms)).await;
498                    continue;
499                }
500                _ => return result,
501            }
502        }
503        unreachable!()
504    }
505}
506
507impl Debug for OperationThrottle {
508    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
509        f.debug_struct("OperationThrottle")
510            .field("controller", &self.controller)
511            .field("burst_capacity", &self.burst_capacity)
512            .finish()
513    }
514}
515
516#[derive(Clone)]
517pub(crate) struct AimdThrottleState {
518    read: Arc<OperationThrottle>,
519    write: Arc<OperationThrottle>,
520    delete: Arc<OperationThrottle>,
521    list: Arc<OperationThrottle>,
522}
523
524impl AimdThrottleState {
525    pub(crate) fn new(config: AimdThrottleConfig) -> lance_core::Result<Self> {
526        let burst_capacity = config.burst_capacity as f64;
527        let max_retries = config.max_retries;
528        let min_backoff_ms = config.min_backoff_ms;
529        let max_backoff_ms = config.max_backoff_ms;
530        Ok(Self {
531            read: Arc::new(OperationThrottle::new(
532                config.read,
533                burst_capacity,
534                max_retries,
535                min_backoff_ms,
536                max_backoff_ms,
537            )?),
538            write: Arc::new(OperationThrottle::new(
539                config.write,
540                burst_capacity,
541                max_retries,
542                min_backoff_ms,
543                max_backoff_ms,
544            )?),
545            delete: Arc::new(OperationThrottle::new(
546                config.delete,
547                burst_capacity,
548                max_retries,
549                min_backoff_ms,
550                max_backoff_ms,
551            )?),
552            list: Arc::new(OperationThrottle::new(
553                config.list,
554                burst_capacity,
555                max_retries,
556                min_backoff_ms,
557                max_backoff_ms,
558            )?),
559        })
560    }
561}
562
563#[cfg(any(feature = "aws", feature = "azure", feature = "gcp"))]
564#[derive(Debug)]
565pub(crate) struct AimdMultipartUploadConnector<C> {
566    inner: C,
567    write: Option<Arc<OperationThrottle>>,
568}
569
570#[cfg(any(feature = "aws", feature = "azure", feature = "gcp"))]
571impl<C> AimdMultipartUploadConnector<C> {
572    fn new(inner: C, state: Option<&AimdThrottleState>) -> Self {
573        Self {
574            inner,
575            write: state.map(|state| Arc::clone(&state.write)),
576        }
577    }
578}
579
580#[cfg(all(
581    any(feature = "aws", feature = "azure", feature = "gcp"),
582    feature = "metrics"
583))]
584pub(crate) fn cloud_http_connector(
585    state: Option<&AimdThrottleState>,
586    metrics_base: String,
587) -> AimdMultipartUploadConnector<crate::object_store::metrics::MeteringHttpConnector> {
588    AimdMultipartUploadConnector::new(
589        crate::object_store::metrics::MeteringHttpConnector::new(metrics_base),
590        state,
591    )
592}
593
594#[cfg(all(
595    any(feature = "aws", feature = "azure", feature = "gcp"),
596    not(feature = "metrics")
597))]
598pub(crate) fn cloud_http_connector(
599    state: Option<&AimdThrottleState>,
600    _metrics_base: String,
601) -> AimdMultipartUploadConnector<object_store::client::ReqwestConnector> {
602    AimdMultipartUploadConnector::new(object_store::client::ReqwestConnector::default(), state)
603}
604
605#[cfg(any(feature = "aws", feature = "azure", feature = "gcp"))]
606impl<C: HttpConnector> HttpConnector for AimdMultipartUploadConnector<C> {
607    fn connect(&self, options: &ClientOptions) -> object_store::Result<HttpClient> {
608        Ok(HttpClient::new(AimdMultipartUploadService {
609            inner: self.inner.connect(options)?,
610            write: self.write.clone(),
611        }))
612    }
613}
614
615#[cfg(any(feature = "aws", feature = "azure", feature = "gcp"))]
616#[derive(Debug)]
617struct AimdMultipartUploadService {
618    inner: HttpClient,
619    write: Option<Arc<OperationThrottle>>,
620}
621
622#[cfg(any(feature = "aws", feature = "azure", feature = "gcp"))]
623fn is_multipart_part_request(request: &HttpRequest) -> bool {
624    if request.method() != ::http::Method::PUT {
625        return false;
626    }
627    request.uri().query().is_some_and(|query| {
628        url::form_urlencoded::parse(query.as_bytes()).any(|(key, value)| {
629            key.eq_ignore_ascii_case("partNumber")
630                || (key.eq_ignore_ascii_case("comp") && value.eq_ignore_ascii_case("block"))
631        })
632    })
633}
634
635#[cfg(any(feature = "aws", feature = "azure", feature = "gcp"))]
636fn is_retryable_http_error(error: &HttpError) -> bool {
637    matches!(
638        error.kind(),
639        HttpErrorKind::Connect
640            | HttpErrorKind::Request
641            | HttpErrorKind::Timeout
642            | HttpErrorKind::Interrupted
643    )
644}
645
646#[cfg(any(feature = "aws", feature = "azure", feature = "gcp"))]
647#[async_trait]
648impl HttpService for AimdMultipartUploadService {
649    async fn call(&self, request: HttpRequest) -> Result<HttpResponse, HttpError> {
650        let Some(write) = self.write.as_ref() else {
651            return self.inner.execute(request).await;
652        };
653        if !is_multipart_part_request(&request) {
654            return self.inner.execute(request).await;
655        }
656
657        for attempt in 0..=write.max_retries {
658            write.acquire_token().await;
659            let mut result = self.inner.execute(request.clone()).await;
660            let mut is_retryable = result.as_ref().err().is_some_and(is_retryable_http_error);
661            let mut is_throttle = false;
662            let mut response_status = None;
663
664            if let Ok(response) = result {
665                let status = response.status();
666                response_status = Some(status);
667                is_retryable = status == ::http::StatusCode::REQUEST_TIMEOUT
668                    || status == ::http::StatusCode::TOO_MANY_REQUESTS
669                    || status.is_server_error();
670                is_throttle = status == ::http::StatusCode::TOO_MANY_REQUESTS
671                    || status == ::http::StatusCode::SERVICE_UNAVAILABLE;
672
673                let (parts, body) = response.into_parts();
674                result = match body.bytes().await {
675                    Ok(bytes) => {
676                        let body = String::from_utf8_lossy(&bytes).to_ascii_lowercase();
677                        let is_throttle_body = body.contains("requesttimeout")
678                            || body.contains("slowdown")
679                            || body.contains("serverbusy")
680                            || body.contains("throttl");
681                        is_retryable |= is_throttle_body;
682                        is_throttle |= is_throttle_body;
683                        Ok(HttpResponse::from_parts(
684                            parts,
685                            HttpResponseBody::from(bytes),
686                        ))
687                    }
688                    Err(error) => {
689                        is_retryable = is_retryable_http_error(&error);
690                        Err(error)
691                    }
692                };
693            }
694
695            let detail = response_status
696                .filter(|status| !status.is_success())
697                .map(|status| format!("HTTP status {status}"));
698            let error = result
699                .as_ref()
700                .err()
701                .map(|error| error as &dyn std::fmt::Display)
702                .or_else(|| {
703                    detail
704                        .as_ref()
705                        .map(|detail| detail as &dyn std::fmt::Display)
706                });
707            let outcome = if is_throttle {
708                RequestOutcome::Throttled
709            } else {
710                RequestOutcome::Success
711            };
712            let new_rate = write.record_outcome(outcome, error);
713            write.update_bucket_rate(new_rate).await;
714
715            if is_retryable && attempt < write.max_retries {
716                let backoff_ms =
717                    rand::rng().random_range(write.min_backoff_ms..=write.max_backoff_ms);
718                debug!(
719                    target: TRACE_OBJECT_STORE_THROTTLE,
720                    attempt = attempt + 1,
721                    max_retries = write.max_retries,
722                    backoff_ms,
723                    "Retrying multipart upload part after retryable HTTP response"
724                );
725                tokio::time::sleep(std::time::Duration::from_millis(backoff_ms)).await;
726                continue;
727            }
728            return result;
729        }
730        unreachable!()
731    }
732}
733
734/// A [`MultipartUpload`] wrapper that applies the write AIMD controller.
735struct ThrottledMultipartUpload {
736    target: Box<dyn MultipartUpload>,
737    write: Arc<OperationThrottle>,
738    parts_throttled_at_http: bool,
739}
740
741impl Debug for ThrottledMultipartUpload {
742    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
743        f.debug_struct("ThrottledMultipartUpload").finish()
744    }
745}
746
747#[async_trait]
748impl MultipartUpload for ThrottledMultipartUpload {
749    fn put_part(&mut self, data: PutPayload) -> UploadPart {
750        // Call put_part synchronously to preserve part ordering regardless
751        // of which futures are awaited first.
752        let fut = self.target.put_part(data);
753        if self.parts_throttled_at_http {
754            return fut;
755        }
756        let write = Arc::clone(&self.write);
757        Box::pin(async move {
758            write.acquire_token().await;
759            let result = fut.await;
760            write.observe_outcome(&result);
761            result
762        })
763    }
764
765    async fn complete(&mut self) -> OSResult<PutResult> {
766        let target = &mut self.target;
767        for attempt in 0..=self.write.max_retries {
768            self.write.acquire_token().await;
769            let result = target.complete().await;
770            self.write.observe_outcome(&result);
771
772            match &result {
773                Err(err) if is_throttle_error(err) && attempt < self.write.max_retries => {
774                    let backoff_ms = rand::rng()
775                        .random_range(self.write.min_backoff_ms..=self.write.max_backoff_ms);
776                    tokio::time::sleep(std::time::Duration::from_millis(backoff_ms)).await;
777                    continue;
778                }
779                _ => return result,
780            }
781        }
782        unreachable!()
783    }
784
785    async fn abort(&mut self) -> OSResult<()> {
786        let target = &mut self.target;
787        for attempt in 0..=self.write.max_retries {
788            self.write.acquire_token().await;
789            let result = target.abort().await;
790            self.write.observe_outcome(&result);
791
792            match &result {
793                Err(err) if is_throttle_error(err) && attempt < self.write.max_retries => {
794                    let backoff_ms = rand::rng()
795                        .random_range(self.write.min_backoff_ms..=self.write.max_backoff_ms);
796                    tokio::time::sleep(std::time::Duration::from_millis(backoff_ms)).await;
797                    continue;
798                }
799                _ => return result,
800            }
801        }
802        unreachable!()
803    }
804}
805
806/// An ObjectStore wrapper that rate-limits operations using per-category token
807/// buckets whose fill rates are controlled by AIMD algorithms.
808///
809/// Operations are split into four independent categories:
810/// - **read**: `get`, `get_opts`, `get_range`, `get_ranges`, `head`
811/// - **write**: `put`, `put_opts`, `put_multipart`, `put_multipart_opts`, `copy`, `copy_if_not_exists`, `rename`, `rename_if_not_exists`
812/// - **delete**: `delete`
813/// - **list**: `list`, `list_with_offset`, `list_with_delimiter`
814///
815/// Streaming list operations acquire a token before starting the underlying list stream.
816/// Streaming operations also observe each yielded item and feed the result back to the
817/// AIMD controller so it can adjust the rate for other operations in the same category.
818///
819/// This is not perfect but probably as close as we can get without moving the throttle into
820/// the object_store crate itself.
821pub struct AimdThrottledStore {
822    target: Arc<dyn ObjectStore>,
823    read: Arc<OperationThrottle>,
824    write: Arc<OperationThrottle>,
825    delete: Arc<OperationThrottle>,
826    list: Arc<OperationThrottle>,
827    multipart_parts_throttled_at_http: bool,
828}
829
830impl Debug for AimdThrottledStore {
831    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
832        f.debug_struct("AimdThrottledStore")
833            .field("target", &self.target)
834            .field("read", &self.read)
835            .field("write", &self.write)
836            .field("delete", &self.delete)
837            .field("list", &self.list)
838            .field(
839                "multipart_parts_throttled_at_http",
840                &self.multipart_parts_throttled_at_http,
841            )
842            .finish()
843    }
844}
845
846impl Display for AimdThrottledStore {
847    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
848        write!(f, "AimdThrottledStore({})", self.target)
849    }
850}
851
852impl AimdThrottledStore {
853    pub fn new(
854        target: Arc<dyn ObjectStore>,
855        config: AimdThrottleConfig,
856    ) -> lance_core::Result<Self> {
857        Ok(Self::new_with_state(
858            target,
859            AimdThrottleState::new(config)?,
860            false,
861        ))
862    }
863
864    pub(crate) fn new_with_state(
865        target: Arc<dyn ObjectStore>,
866        state: AimdThrottleState,
867        multipart_parts_throttled_at_http: bool,
868    ) -> Self {
869        Self {
870            target,
871            read: state.read,
872            write: state.write,
873            delete: state.delete,
874            list: state.list,
875            multipart_parts_throttled_at_http,
876        }
877    }
878
879    /// Put a paginated lister on the same list budget as this store.
880    pub fn wrap_paginated(
881        &self,
882        inner: Arc<dyn PaginatedListStore>,
883    ) -> Arc<dyn PaginatedListStore> {
884        Arc::new(ThrottledListStore {
885            inner,
886            throttle: self.list.clone(),
887        })
888    }
889}
890
891/// A store paired with the paginated lister that shares its rate limits.
892#[cfg(any(feature = "aws", feature = "azure", feature = "gcp"))]
893type StoreWithLister = (Arc<dyn ObjectStore>, Option<Arc<dyn PaginatedListStore>>);
894
895/// Apply AIMD throttling to a store and to the lister that shares its list budget.
896///
897/// [`crate::object_store::ObjectStore::read_dir_page`] goes to the lister rather than
898/// through the store, so both have to be wrapped for list requests to be counted once
899/// against one rate.
900#[cfg(any(feature = "aws", feature = "azure", feature = "gcp"))]
901pub(crate) fn with_throttling(
902    state: Option<AimdThrottleState>,
903    multipart_parts_throttled_at_http: bool,
904    store: Arc<dyn ObjectStore>,
905    lister: Option<Arc<dyn PaginatedListStore>>,
906) -> StoreWithLister {
907    let Some(state) = state else {
908        return (store, lister);
909    };
910    let store = Arc::new(AimdThrottledStore::new_with_state(
911        store,
912        state,
913        multipart_parts_throttled_at_http,
914    ));
915    let lister = lister.map(|lister| store.wrap_paginated(lister));
916    (store, lister)
917}
918
919/// A [`PaginatedListStore`] whose requests draw on a store's list token bucket.
920struct ThrottledListStore {
921    inner: Arc<dyn PaginatedListStore>,
922    throttle: Arc<OperationThrottle>,
923}
924
925// Throttling only adds waiting, so every semantic of the store it wraps has to reach the
926// listing unchanged; the lint keeps a method added to the trait from silently falling back to
927// its default here.
928#[async_trait]
929#[deny(clippy::missing_trait_methods)]
930impl PaginatedListStore for ThrottledListStore {
931    async fn list_paginated(
932        &self,
933        prefix: Option<&str>,
934        opts: PaginatedListOptions,
935    ) -> OSResult<PaginatedListResult> {
936        self.throttle
937            .throttled(|| self.inner.list_paginated(prefix, opts.clone()))
938            .await
939    }
940}
941
942#[async_trait]
943#[deny(clippy::missing_trait_methods)]
944impl ObjectStore for AimdThrottledStore {
945    async fn put_opts(
946        &self,
947        location: &Path,
948        bytes: PutPayload,
949        opts: PutOptions,
950    ) -> OSResult<PutResult> {
951        self.write
952            .throttled(|| self.target.put_opts(location, bytes.clone(), opts.clone()))
953            .await
954    }
955
956    async fn put_multipart_opts(
957        &self,
958        location: &Path,
959        opts: PutMultipartOptions,
960    ) -> OSResult<Box<dyn MultipartUpload>> {
961        let target = self
962            .write
963            .throttled(|| self.target.put_multipart_opts(location, opts.clone()))
964            .await?;
965        Ok(Box::new(ThrottledMultipartUpload {
966            target,
967            write: Arc::clone(&self.write),
968            parts_throttled_at_http: self.multipart_parts_throttled_at_http,
969        }))
970    }
971
972    async fn get_opts(&self, location: &Path, options: GetOptions) -> OSResult<GetResult> {
973        self.read
974            .throttled(|| self.target.get_opts(location, options.clone()))
975            .await
976    }
977
978    async fn get_ranges(&self, location: &Path, ranges: &[Range<u64>]) -> OSResult<Vec<Bytes>> {
979        self.read
980            .throttled(|| self.target.get_ranges(location, ranges))
981            .await
982    }
983
984    fn delete_stream(
985        &self,
986        locations: BoxStream<'static, OSResult<Path>>,
987    ) -> BoxStream<'static, OSResult<Path>> {
988        let delete = Arc::clone(&self.delete);
989        self.target
990            .delete_stream(locations)
991            .map(move |item| {
992                delete.observe_outcome(&item);
993                item
994            })
995            .boxed()
996    }
997
998    fn list(&self, prefix: Option<&Path>) -> BoxStream<'static, OSResult<ObjectMeta>> {
999        let throttle = Arc::clone(&self.list);
1000        let throttle_for_start = Arc::clone(&throttle);
1001        let target = Arc::clone(&self.target);
1002        let prefix = prefix.cloned();
1003        futures::stream::once(async move {
1004            throttle_for_start.acquire_token().await;
1005            target.list(prefix.as_ref())
1006        })
1007        .flatten()
1008        .map(move |item| {
1009            throttle.observe_outcome(&item);
1010            item
1011        })
1012        .boxed()
1013    }
1014
1015    fn list_with_offset(
1016        &self,
1017        prefix: Option<&Path>,
1018        offset: &Path,
1019    ) -> BoxStream<'static, OSResult<ObjectMeta>> {
1020        let throttle = Arc::clone(&self.list);
1021        let throttle_for_start = Arc::clone(&throttle);
1022        let target = Arc::clone(&self.target);
1023        let prefix = prefix.cloned();
1024        let offset = offset.clone();
1025        futures::stream::once(async move {
1026            throttle_for_start.acquire_token().await;
1027            target.list_with_offset(prefix.as_ref(), &offset)
1028        })
1029        .flatten()
1030        .map(move |item| {
1031            throttle.observe_outcome(&item);
1032            item
1033        })
1034        .boxed()
1035    }
1036
1037    async fn list_with_delimiter(&self, prefix: Option<&Path>) -> OSResult<ListResult> {
1038        self.list
1039            .throttled(|| self.target.list_with_delimiter(prefix))
1040            .await
1041    }
1042
1043    async fn copy_opts(&self, from: &Path, to: &Path, opts: CopyOptions) -> OSResult<()> {
1044        self.write
1045            .throttled(|| self.target.copy_opts(from, to, opts.clone()))
1046            .await
1047    }
1048
1049    async fn rename_opts(&self, from: &Path, to: &Path, opts: RenameOptions) -> OSResult<()> {
1050        self.write
1051            .throttled(|| self.target.rename_opts(from, to, opts.clone()))
1052            .await
1053    }
1054}
1055
1056#[cfg(test)]
1057mod tests {
1058    use super::*;
1059    use object_store::memory::InMemory;
1060    use rstest::rstest;
1061    use std::collections::VecDeque;
1062    use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
1063
1064    const THROTTLE_ERROR_RESPONSE: &str = "request failed, after 3 retries, max_retries: 3, retry_timeout: 30s - Server returned non-2xx status code: 503: x-ms-request-id: azure-request-id";
1065
1066    fn make_generic_error(msg: &str) -> object_store::Error {
1067        object_store::Error::Generic {
1068            store: "test",
1069            source: msg.into(),
1070        }
1071    }
1072
1073    #[rstest]
1074    #[case::retry_error("Error after 10 retries, max_retries: 10, retry_timeout: 180s", true)]
1075    #[case::retries_in_message(
1076        "request failed, after 3 retries, max_retries: 5, retry_timeout: 60s",
1077        true
1078    )]
1079    #[case::not_found("Object not found", false)]
1080    #[case::permission_denied("Access denied", false)]
1081    #[case::timeout("Connection timed out", false)]
1082    #[case::http_429_without_retries("HTTP 429 Too Many Requests", true)]
1083    #[case::slowdown_without_retries("SlowDown: Please reduce your request rate", true)]
1084    #[case::azure_server_busy("Code: ServerBusy", true)]
1085    #[case::azure_egress_limit("Message: Egress is over the account limit", true)]
1086    fn test_is_throttle_error(#[case] msg: &str, #[case] expected: bool) {
1087        let err = make_generic_error(msg);
1088        assert_eq!(
1089            is_throttle_error(&err),
1090            expected,
1091            "is_throttle_error for '{}' should be {}",
1092            msg,
1093            expected
1094        );
1095    }
1096
1097    #[test]
1098    fn test_non_generic_errors_are_not_throttle() {
1099        let err = object_store::Error::NotFound {
1100            path: "test".to_string(),
1101            source: "not found".into(),
1102        };
1103        assert!(!is_throttle_error(&err));
1104    }
1105
1106    #[cfg(any(feature = "aws", feature = "azure", feature = "gcp"))]
1107    #[rstest]
1108    #[case::s3("https://bucket/object?partNumber=1&uploadId=id", true)]
1109    #[case::azure_block("https://account/object?comp=block&blockid=id", true)]
1110    #[case::azure_block_list("https://account/object?comp=blocklist", false)]
1111    #[case::ordinary_put("https://bucket/object", false)]
1112    fn test_is_multipart_part_request(#[case] uri: &str, #[case] expected: bool) {
1113        let request = ::http::Request::builder()
1114            .method(::http::Method::PUT)
1115            .uri(uri)
1116            .body(object_store::client::HttpRequestBody::empty())
1117            .unwrap();
1118        assert_eq!(is_multipart_part_request(&request), expected);
1119    }
1120
1121    /// One page of a fixed directory, counting the requests that reached it.
1122    #[derive(Default)]
1123    struct CountingListStore {
1124        calls: AtomicUsize,
1125        fail_with: Option<String>,
1126    }
1127
1128    #[async_trait]
1129    impl PaginatedListStore for CountingListStore {
1130        async fn list_paginated(
1131            &self,
1132            _prefix: Option<&str>,
1133            _opts: PaginatedListOptions,
1134        ) -> OSResult<PaginatedListResult> {
1135            self.calls.fetch_add(1, Ordering::SeqCst);
1136            match &self.fail_with {
1137                Some(message) => Err(make_generic_error(message)),
1138                None => Ok(PaginatedListResult {
1139                    result: ListResult {
1140                        common_prefixes: vec![Path::from("prefix/child")],
1141                        objects: Vec::new(),
1142                        extensions: Default::default(),
1143                    },
1144                    page_token: None,
1145                }),
1146            }
1147        }
1148    }
1149
1150    #[tokio::test(start_paused = true)]
1151    async fn test_paginated_lister_acquires_a_token_before_listing() {
1152        let lister = Arc::new(CountingListStore::default());
1153        let throttled = AimdThrottledStore::new(
1154            Arc::new(InMemory::new()) as Arc<dyn ObjectStore>,
1155            list_start_throttle_config(),
1156        )
1157        .unwrap();
1158        let throttled_lister = throttled.wrap_paginated(lister.clone());
1159
1160        let mut page = Box::pin(
1161            throttled_lister.list_paginated(Some("prefix/"), PaginatedListOptions::default()),
1162        );
1163        // With rate=10 tokens/s and burst_capacity=0, the token acquisition sleeps for
1164        // 100 ms. A 50 ms timeout must expire before that.
1165        assert!(
1166            tokio::time::timeout(std::time::Duration::from_millis(50), &mut page)
1167                .await
1168                .is_err()
1169        );
1170        assert_eq!(lister.calls.load(Ordering::SeqCst), 0);
1171
1172        let page = tokio::time::timeout(std::time::Duration::from_millis(300), page)
1173            .await
1174            .unwrap()
1175            .unwrap();
1176        assert_eq!(page.result.common_prefixes.len(), 1);
1177        assert_eq!(lister.calls.load(Ordering::SeqCst), 1);
1178    }
1179
1180    #[tokio::test]
1181    async fn test_paginated_lister_throttle_errors_decrease_rate() {
1182        let lister = Arc::new(CountingListStore {
1183            calls: AtomicUsize::new(0),
1184            fail_with: Some(THROTTLE_ERROR_RESPONSE.to_string()),
1185        });
1186        let mut config = AimdThrottleConfig::default().with_list_aimd(
1187            AimdConfig::default()
1188                .with_initial_rate(100.0)
1189                .with_decrease_factor(0.5)
1190                .with_window_duration(std::time::Duration::from_millis(1)),
1191        );
1192        config.max_retries = 1;
1193        // The AIMD window is only evaluated when an outcome is recorded after the
1194        // window has elapsed, so the retry backoff must outlast `window_duration`.
1195        // With a zero backoff the two attempts against this in-memory lister can
1196        // finish inside the first window (observed on Windows), leaving the rate
1197        // untouched.
1198        config.min_backoff_ms = 5;
1199        config.max_backoff_ms = 5;
1200        let throttled =
1201            AimdThrottledStore::new(Arc::new(InMemory::new()) as Arc<dyn ObjectStore>, config)
1202                .unwrap();
1203        let throttled_lister = throttled.wrap_paginated(lister.clone());
1204
1205        assert!(
1206            throttled_lister
1207                .list_paginated(Some("prefix/"), PaginatedListOptions::default())
1208                .await
1209                .is_err()
1210        );
1211
1212        // The request was retried once, and the throttle response pushed the rate down.
1213        assert_eq!(lister.calls.load(Ordering::SeqCst), 2);
1214        assert!(throttled.list.controller.current_rate() < 100.0);
1215    }
1216
1217    #[tokio::test]
1218    async fn test_basic_put_get_through_wrapper() {
1219        let store = Arc::new(InMemory::new());
1220        let config = AimdThrottleConfig::default();
1221        let throttled = AimdThrottledStore::new(store, config).unwrap();
1222
1223        let path = Path::from("test/file.txt");
1224        let data = PutPayload::from_static(b"hello world");
1225        throttled.put(&path, data).await.unwrap();
1226
1227        let result = throttled.get(&path).await.unwrap();
1228        let bytes = result.bytes().await.unwrap();
1229        assert_eq!(bytes.as_ref(), b"hello world");
1230    }
1231
1232    #[tokio::test]
1233    async fn test_rate_decreases_on_throttle() {
1234        let store = Arc::new(InMemory::new());
1235        let config = AimdThrottleConfig::default().with_aimd(
1236            AimdConfig::default()
1237                .with_initial_rate(100.0)
1238                .with_decrease_factor(0.5)
1239                .with_window_duration(std::time::Duration::from_millis(10)),
1240        );
1241        let throttled = AimdThrottledStore::new(store, config).unwrap();
1242
1243        let initial_rate = throttled.read.controller.current_rate();
1244        assert_eq!(initial_rate, 100.0);
1245
1246        // Simulate a throttle outcome directly
1247        throttled
1248            .read
1249            .controller
1250            .record_outcome(RequestOutcome::Throttled);
1251
1252        // Wait for window to expire and trigger evaluation
1253        tokio::time::sleep(std::time::Duration::from_millis(20)).await;
1254        throttled
1255            .read
1256            .controller
1257            .record_outcome(RequestOutcome::Success);
1258
1259        let new_rate = throttled.read.controller.current_rate();
1260        assert!(
1261            new_rate < initial_rate,
1262            "Rate should decrease after throttle: {} < {}",
1263            new_rate,
1264            initial_rate
1265        );
1266    }
1267
1268    #[tokio::test]
1269    async fn test_rate_recovers_on_success() {
1270        let store = Arc::new(InMemory::new());
1271        let config = AimdThrottleConfig::default().with_aimd(
1272            AimdConfig::default()
1273                .with_initial_rate(100.0)
1274                .with_decrease_factor(0.5)
1275                .with_additive_increment(10.0)
1276                .with_window_duration(std::time::Duration::from_millis(10)),
1277        );
1278        let throttled = AimdThrottledStore::new(store, config).unwrap();
1279
1280        // First decrease via throttle
1281        throttled
1282            .read
1283            .controller
1284            .record_outcome(RequestOutcome::Throttled);
1285        tokio::time::sleep(std::time::Duration::from_millis(20)).await;
1286        throttled
1287            .read
1288            .controller
1289            .record_outcome(RequestOutcome::Success);
1290        let decreased_rate = throttled.read.controller.current_rate();
1291        assert_eq!(decreased_rate, 50.0);
1292
1293        // Now recover via success
1294        tokio::time::sleep(std::time::Duration::from_millis(20)).await;
1295        throttled
1296            .read
1297            .controller
1298            .record_outcome(RequestOutcome::Success);
1299        let recovered_rate = throttled.read.controller.current_rate();
1300        assert_eq!(recovered_rate, 60.0);
1301    }
1302
1303    #[tokio::test]
1304    async fn test_as_dyn_object_store() {
1305        let store: Arc<dyn ObjectStore> = Arc::new(InMemory::new());
1306        let throttled: Arc<dyn ObjectStore> =
1307            Arc::new(AimdThrottledStore::new(store, AimdThrottleConfig::default()).unwrap());
1308
1309        let path = Path::from("test/data.bin");
1310        let data = PutPayload::from_static(b"test data");
1311        throttled.put(&path, data).await.unwrap();
1312
1313        let result = throttled.get(&path).await.unwrap();
1314        let bytes = result.bytes().await.unwrap();
1315        assert_eq!(bytes.as_ref(), b"test data");
1316    }
1317
1318    #[tokio::test]
1319    async fn test_token_bucket_delays_when_exhausted() {
1320        let store = Arc::new(InMemory::new());
1321        // Very low rate and burst capacity to force waiting
1322        let config = AimdThrottleConfig::default()
1323            .with_burst_capacity(1)
1324            .with_aimd(AimdConfig::default().with_initial_rate(10.0));
1325        let throttled = Arc::new(AimdThrottledStore::new(store, config).unwrap());
1326
1327        let path = Path::from("test/file.txt");
1328        let data = PutPayload::from_static(b"data");
1329        throttled.put(&path, data).await.unwrap();
1330
1331        // After consuming the burst token, the next request should take ~100ms
1332        // (1 token / 10 tokens-per-sec). We verify it takes at least 50ms.
1333        let start = std::time::Instant::now();
1334        let data2 = PutPayload::from_static(b"data2");
1335        throttled.put(&path, data2).await.unwrap();
1336        let elapsed = start.elapsed();
1337
1338        assert!(
1339            elapsed >= std::time::Duration::from_millis(50),
1340            "Expected delay for token refill, but elapsed was {:?}",
1341            elapsed
1342        );
1343    }
1344
1345    #[tokio::test]
1346    async fn test_list_observes_outcomes() {
1347        let store = Arc::new(InMemory::new());
1348        let config = AimdThrottleConfig::default();
1349        let throttled = AimdThrottledStore::new(store.clone(), config).unwrap();
1350
1351        let path = Path::from("prefix/file.txt");
1352        let data = PutPayload::from_static(b"data");
1353        store.put(&path, data).await.unwrap();
1354
1355        let items: Vec<_> = throttled.list(Some(&Path::from("prefix"))).collect().await;
1356        assert_eq!(items.len(), 1);
1357        assert!(items[0].is_ok());
1358    }
1359
1360    /// A mock store whose `list` stream yields a configurable sequence of
1361    /// Ok / throttle-error items. Used to verify that the AIMD wrapper
1362    /// observes errors surfaced inside list streams.
1363    struct ThrottlingListMockStore {
1364        inner: InMemory,
1365        /// Number of throttle errors to inject at the start of each list call.
1366        throttle_count: usize,
1367    }
1368
1369    impl Display for ThrottlingListMockStore {
1370        fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
1371            write!(f, "ThrottlingListMockStore")
1372        }
1373    }
1374
1375    impl Debug for ThrottlingListMockStore {
1376        fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
1377            f.debug_struct("ThrottlingListMockStore").finish()
1378        }
1379    }
1380
1381    #[async_trait]
1382    impl ObjectStore for ThrottlingListMockStore {
1383        async fn put_opts(
1384            &self,
1385            location: &Path,
1386            bytes: PutPayload,
1387            opts: PutOptions,
1388        ) -> OSResult<PutResult> {
1389            self.inner.put_opts(location, bytes, opts).await
1390        }
1391        async fn put_multipart_opts(
1392            &self,
1393            location: &Path,
1394            opts: PutMultipartOptions,
1395        ) -> OSResult<Box<dyn MultipartUpload>> {
1396            self.inner.put_multipart_opts(location, opts).await
1397        }
1398        async fn get_opts(&self, location: &Path, options: GetOptions) -> OSResult<GetResult> {
1399            self.inner.get_opts(location, options).await
1400        }
1401        async fn get_ranges(&self, location: &Path, ranges: &[Range<u64>]) -> OSResult<Vec<Bytes>> {
1402            self.inner.get_ranges(location, ranges).await
1403        }
1404        fn delete_stream(
1405            &self,
1406            locations: BoxStream<'static, OSResult<Path>>,
1407        ) -> BoxStream<'static, OSResult<Path>> {
1408            self.inner.delete_stream(locations)
1409        }
1410        fn list(&self, prefix: Option<&Path>) -> BoxStream<'static, OSResult<ObjectMeta>> {
1411            let n = self.throttle_count;
1412            let inner_stream = self.inner.list(prefix);
1413            let errors = futures::stream::iter((0..n).map(|_| {
1414                Err(object_store::Error::Generic {
1415                    store: "ThrottlingListMock",
1416                    source: "request failed, after 3 retries, max_retries: 5, retry_timeout: 60s"
1417                        .into(),
1418                })
1419            }));
1420            errors.chain(inner_stream).boxed()
1421        }
1422        fn list_with_offset(
1423            &self,
1424            prefix: Option<&Path>,
1425            offset: &Path,
1426        ) -> BoxStream<'static, OSResult<ObjectMeta>> {
1427            self.inner.list_with_offset(prefix, offset)
1428        }
1429        async fn list_with_delimiter(&self, prefix: Option<&Path>) -> OSResult<ListResult> {
1430            self.inner.list_with_delimiter(prefix).await
1431        }
1432        async fn copy_opts(&self, from: &Path, to: &Path, opts: CopyOptions) -> OSResult<()> {
1433            self.inner.copy_opts(from, to, opts).await
1434        }
1435    }
1436
1437    #[tokio::test]
1438    async fn test_list_stream_throttle_errors_decrease_rate() {
1439        let mock = Arc::new(ThrottlingListMockStore {
1440            inner: InMemory::new(),
1441            throttle_count: 5,
1442        });
1443
1444        // Seed a file so the real items come through after the errors.
1445        mock.put(
1446            &Path::from("prefix/file.txt"),
1447            PutPayload::from_static(b"data"),
1448        )
1449        .await
1450        .unwrap();
1451
1452        let config = AimdThrottleConfig::default().with_list_aimd(
1453            AimdConfig::default()
1454                .with_initial_rate(100.0)
1455                .with_decrease_factor(0.5)
1456                .with_window_duration(std::time::Duration::from_millis(10)),
1457        );
1458        let throttled = AimdThrottledStore::new(mock as Arc<dyn ObjectStore>, config).unwrap();
1459
1460        let initial_rate = throttled.list.controller.current_rate();
1461        assert_eq!(initial_rate, 100.0);
1462
1463        let items: Vec<_> = throttled.list(Some(&Path::from("prefix"))).collect().await;
1464
1465        // 5 errors + 1 real item
1466        assert_eq!(items.len(), 6);
1467        assert!(items[0].is_err());
1468        assert!(items[5].is_ok());
1469
1470        // Wait for the AIMD window to expire and trigger evaluation.
1471        tokio::time::sleep(std::time::Duration::from_millis(20)).await;
1472        throttled
1473            .list
1474            .controller
1475            .record_outcome(RequestOutcome::Success);
1476
1477        let new_rate = throttled.list.controller.current_rate();
1478        assert!(
1479            new_rate < initial_rate,
1480            "List rate should decrease after stream throttle errors: {} < {}",
1481            new_rate,
1482            initial_rate
1483        );
1484    }
1485
1486    struct CountingListStartStore {
1487        inner: InMemory,
1488        list_calls: AtomicUsize,
1489        offset_calls: AtomicUsize,
1490    }
1491
1492    impl Default for CountingListStartStore {
1493        fn default() -> Self {
1494            Self {
1495                inner: InMemory::new(),
1496                list_calls: AtomicUsize::new(0),
1497                offset_calls: AtomicUsize::new(0),
1498            }
1499        }
1500    }
1501
1502    impl CountingListStartStore {
1503        fn list_calls(&self) -> usize {
1504            self.list_calls.load(Ordering::SeqCst)
1505        }
1506
1507        fn offset_calls(&self) -> usize {
1508            self.offset_calls.load(Ordering::SeqCst)
1509        }
1510    }
1511
1512    impl Display for CountingListStartStore {
1513        fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
1514            write!(f, "CountingListStartStore")
1515        }
1516    }
1517
1518    impl Debug for CountingListStartStore {
1519        fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
1520            f.debug_struct("CountingListStartStore").finish()
1521        }
1522    }
1523
1524    #[async_trait]
1525    impl ObjectStore for CountingListStartStore {
1526        async fn put_opts(
1527            &self,
1528            location: &Path,
1529            bytes: PutPayload,
1530            opts: PutOptions,
1531        ) -> OSResult<PutResult> {
1532            self.inner.put_opts(location, bytes, opts).await
1533        }
1534
1535        async fn put_multipart_opts(
1536            &self,
1537            location: &Path,
1538            opts: PutMultipartOptions,
1539        ) -> OSResult<Box<dyn MultipartUpload>> {
1540            self.inner.put_multipart_opts(location, opts).await
1541        }
1542
1543        async fn get_opts(&self, location: &Path, options: GetOptions) -> OSResult<GetResult> {
1544            self.inner.get_opts(location, options).await
1545        }
1546
1547        async fn get_ranges(&self, location: &Path, ranges: &[Range<u64>]) -> OSResult<Vec<Bytes>> {
1548            self.inner.get_ranges(location, ranges).await
1549        }
1550
1551        fn delete_stream(
1552            &self,
1553            locations: BoxStream<'static, OSResult<Path>>,
1554        ) -> BoxStream<'static, OSResult<Path>> {
1555            self.inner.delete_stream(locations)
1556        }
1557
1558        fn list(&self, prefix: Option<&Path>) -> BoxStream<'static, OSResult<ObjectMeta>> {
1559            self.list_calls.fetch_add(1, Ordering::SeqCst);
1560            self.inner.list(prefix)
1561        }
1562
1563        fn list_with_offset(
1564            &self,
1565            prefix: Option<&Path>,
1566            offset: &Path,
1567        ) -> BoxStream<'static, OSResult<ObjectMeta>> {
1568            self.offset_calls.fetch_add(1, Ordering::SeqCst);
1569            self.inner.list_with_offset(prefix, offset)
1570        }
1571
1572        async fn list_with_delimiter(&self, prefix: Option<&Path>) -> OSResult<ListResult> {
1573            self.inner.list_with_delimiter(prefix).await
1574        }
1575
1576        async fn copy_opts(&self, from: &Path, to: &Path, opts: CopyOptions) -> OSResult<()> {
1577            self.inner.copy_opts(from, to, opts).await
1578        }
1579    }
1580
1581    fn list_start_throttle_config() -> AimdThrottleConfig {
1582        // Use a low rate (10 tokens/s) so that the token-acquisition sleep is
1583        // 1/10 = 100 ms — well above the 50 ms timeout used in assertions,
1584        // avoiding flakiness from coarse OS timer resolution (e.g. Windows ~16 ms).
1585        AimdThrottleConfig::default()
1586            .with_burst_capacity(0)
1587            .with_list_aimd(AimdConfig::default().with_initial_rate(10.0))
1588    }
1589
1590    #[tokio::test(start_paused = true)]
1591    async fn test_list_acquires_token_before_starting_underlying_stream() {
1592        let store = Arc::new(CountingListStartStore::default());
1593        store
1594            .put(
1595                &Path::from("prefix/file.txt"),
1596                PutPayload::from_static(b"data"),
1597            )
1598            .await
1599            .unwrap();
1600        let throttled = AimdThrottledStore::new(
1601            store.clone() as Arc<dyn ObjectStore>,
1602            list_start_throttle_config(),
1603        )
1604        .unwrap();
1605
1606        let mut stream = throttled.list(Some(&Path::from("prefix")));
1607        assert_eq!(store.list_calls(), 0);
1608        // With rate=10 tokens/s and burst_capacity=0, the token acquisition
1609        // sleeps for 100 ms. A 50 ms timeout must expire before that.
1610        assert!(
1611            tokio::time::timeout(std::time::Duration::from_millis(50), stream.next())
1612                .await
1613                .is_err()
1614        );
1615        assert_eq!(store.list_calls(), 0);
1616
1617        let item = tokio::time::timeout(std::time::Duration::from_millis(300), stream.next())
1618            .await
1619            .unwrap()
1620            .unwrap()
1621            .unwrap();
1622        assert_eq!(item.location, Path::from("prefix/file.txt"));
1623        assert_eq!(store.list_calls(), 1);
1624    }
1625
1626    #[tokio::test(start_paused = true)]
1627    async fn test_list_with_offset_acquires_token_before_starting_underlying_stream() {
1628        let store = Arc::new(CountingListStartStore::default());
1629        store
1630            .put(&Path::from("prefix/b"), PutPayload::from_static(b"data"))
1631            .await
1632            .unwrap();
1633        let throttled = AimdThrottledStore::new(
1634            store.clone() as Arc<dyn ObjectStore>,
1635            list_start_throttle_config(),
1636        )
1637        .unwrap();
1638
1639        let mut stream =
1640            throttled.list_with_offset(Some(&Path::from("prefix")), &Path::from("prefix/a"));
1641        assert_eq!(store.offset_calls(), 0);
1642        // With rate=10 tokens/s and burst_capacity=0, the token acquisition
1643        // sleeps for 100 ms. A 50 ms timeout must expire before that.
1644        assert!(
1645            tokio::time::timeout(std::time::Duration::from_millis(50), stream.next())
1646                .await
1647                .is_err()
1648        );
1649        assert_eq!(store.offset_calls(), 0);
1650
1651        let item = tokio::time::timeout(std::time::Duration::from_millis(300), stream.next())
1652            .await
1653            .unwrap()
1654            .unwrap()
1655            .unwrap();
1656        assert_eq!(item.location, Path::from("prefix/b"));
1657        assert_eq!(store.offset_calls(), 1);
1658    }
1659
1660    #[tokio::test]
1661    async fn test_per_category_independence() {
1662        let store = Arc::new(InMemory::new());
1663        let config = AimdThrottleConfig::default().with_aimd(
1664            AimdConfig::default()
1665                .with_initial_rate(100.0)
1666                .with_decrease_factor(0.5)
1667                .with_window_duration(std::time::Duration::from_millis(10)),
1668        );
1669        let throttled = AimdThrottledStore::new(store, config).unwrap();
1670
1671        // Push the read controller into a throttled state
1672        throttled
1673            .read
1674            .controller
1675            .record_outcome(RequestOutcome::Throttled);
1676        tokio::time::sleep(std::time::Duration::from_millis(20)).await;
1677        throttled
1678            .read
1679            .controller
1680            .record_outcome(RequestOutcome::Success);
1681
1682        let read_rate = throttled.read.controller.current_rate();
1683        let write_rate = throttled.write.controller.current_rate();
1684        let delete_rate = throttled.delete.controller.current_rate();
1685        let list_rate = throttled.list.controller.current_rate();
1686
1687        assert_eq!(read_rate, 50.0, "Read rate should have decreased");
1688        assert_eq!(write_rate, 100.0, "Write rate should be unaffected");
1689        assert_eq!(delete_rate, 100.0, "Delete rate should be unaffected");
1690        assert_eq!(list_rate, 100.0, "List rate should be unaffected");
1691    }
1692
1693    #[tokio::test]
1694    async fn test_per_category_config() {
1695        let store = Arc::new(InMemory::new());
1696        let config = AimdThrottleConfig::default()
1697            .with_read_aimd(AimdConfig::default().with_initial_rate(200.0))
1698            .with_write_aimd(AimdConfig::default().with_initial_rate(100.0))
1699            .with_delete_aimd(AimdConfig::default().with_initial_rate(50.0))
1700            .with_list_aimd(AimdConfig::default().with_initial_rate(25.0));
1701        let throttled = AimdThrottledStore::new(store, config).unwrap();
1702
1703        assert_eq!(throttled.read.controller.current_rate(), 200.0);
1704        assert_eq!(throttled.write.controller.current_rate(), 100.0);
1705        assert_eq!(throttled.delete.controller.current_rate(), 50.0);
1706        assert_eq!(throttled.list.controller.current_rate(), 25.0);
1707    }
1708
1709    /// A mock [`ObjectStore`] that measures request rate over a sliding window
1710    /// and returns 503 errors when the rate exceeds a configurable threshold.
1711    /// Write and metadata-only operations are not rate-limited.
1712    struct RateLimitingMockStore {
1713        inner: InMemory,
1714        /// Timestamps of recent successful (admitted) requests.
1715        timestamps: std::sync::Mutex<VecDeque<std::time::Instant>>,
1716        /// Maximum requests allowed within `window`.
1717        max_per_window: usize,
1718        /// Sliding window duration.
1719        window: std::time::Duration,
1720        success_count: AtomicU64,
1721        throttle_count: AtomicU64,
1722    }
1723
1724    impl RateLimitingMockStore {
1725        fn new(max_per_window: usize, window: std::time::Duration) -> Self {
1726            Self {
1727                inner: InMemory::new(),
1728                timestamps: std::sync::Mutex::new(VecDeque::new()),
1729                max_per_window,
1730                window,
1731                success_count: AtomicU64::new(0),
1732                throttle_count: AtomicU64::new(0),
1733            }
1734        }
1735
1736        /// Returns `true` if the request is admitted, `false` if throttled.
1737        fn check_rate(&self) -> bool {
1738            let mut ts = self.timestamps.lock().unwrap();
1739            let now = std::time::Instant::now();
1740            while let Some(&front) = ts.front() {
1741                if now.duration_since(front) > self.window {
1742                    ts.pop_front();
1743                } else {
1744                    break;
1745                }
1746            }
1747            if ts.len() >= self.max_per_window {
1748                self.throttle_count.fetch_add(1, Ordering::Relaxed);
1749                false
1750            } else {
1751                ts.push_back(now);
1752                self.success_count.fetch_add(1, Ordering::Relaxed);
1753                true
1754            }
1755        }
1756
1757        fn throttle_error() -> object_store::Error {
1758            object_store::Error::Generic {
1759                store: "RateLimitingMock",
1760                source: THROTTLE_ERROR_RESPONSE.into(),
1761            }
1762        }
1763    }
1764
1765    impl Display for RateLimitingMockStore {
1766        fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
1767            write!(f, "RateLimitingMockStore")
1768        }
1769    }
1770
1771    impl Debug for RateLimitingMockStore {
1772        fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
1773            f.debug_struct("RateLimitingMockStore").finish()
1774        }
1775    }
1776
1777    #[async_trait]
1778    impl ObjectStore for RateLimitingMockStore {
1779        async fn put_opts(
1780            &self,
1781            location: &Path,
1782            bytes: PutPayload,
1783            opts: PutOptions,
1784        ) -> OSResult<PutResult> {
1785            self.inner.put_opts(location, bytes, opts).await
1786        }
1787
1788        async fn put_multipart_opts(
1789            &self,
1790            location: &Path,
1791            opts: PutMultipartOptions,
1792        ) -> OSResult<Box<dyn MultipartUpload>> {
1793            self.inner.put_multipart_opts(location, opts).await
1794        }
1795
1796        async fn get_opts(&self, location: &Path, options: GetOptions) -> OSResult<GetResult> {
1797            if self.check_rate() {
1798                self.inner.get_opts(location, options).await
1799            } else {
1800                Err(Self::throttle_error())
1801            }
1802        }
1803
1804        async fn get_ranges(&self, location: &Path, ranges: &[Range<u64>]) -> OSResult<Vec<Bytes>> {
1805            if self.check_rate() {
1806                self.inner.get_ranges(location, ranges).await
1807            } else {
1808                Err(Self::throttle_error())
1809            }
1810        }
1811
1812        fn delete_stream(
1813            &self,
1814            locations: BoxStream<'static, OSResult<Path>>,
1815        ) -> BoxStream<'static, OSResult<Path>> {
1816            self.inner.delete_stream(locations)
1817        }
1818
1819        fn list(&self, prefix: Option<&Path>) -> BoxStream<'static, OSResult<ObjectMeta>> {
1820            self.inner.list(prefix)
1821        }
1822
1823        fn list_with_offset(
1824            &self,
1825            prefix: Option<&Path>,
1826            offset: &Path,
1827        ) -> BoxStream<'static, OSResult<ObjectMeta>> {
1828            self.inner.list_with_offset(prefix, offset)
1829        }
1830
1831        async fn list_with_delimiter(&self, prefix: Option<&Path>) -> OSResult<ListResult> {
1832            self.inner.list_with_delimiter(prefix).await
1833        }
1834
1835        async fn copy_opts(&self, from: &Path, to: &Path, opts: CopyOptions) -> OSResult<()> {
1836            self.inner.copy_opts(from, to, opts).await
1837        }
1838    }
1839
1840    /// Verify that multiple concurrent readers sharing an AIMD-throttled store
1841    /// converge to the backend's actual capacity.
1842    ///
1843    /// Setup:
1844    /// - Mock backend allows 30 requests per 100ms (= 300 req/s).
1845    /// - 5 reader tasks, each with their own [`AimdThrottledStore`] wrapping
1846    ///   the shared mock.
1847    /// - AIMD: 100ms window, initial rate 100 req/s, decrease 0.5, increase 2.
1848    /// - Readers issue `head()` requests as fast as the throttle allows for 2s.
1849    ///
1850    /// Expected behaviour:
1851    /// - Initial burst (100 burst tokens × 5 readers) overshoots the mock
1852    ///   capacity, causing many 503s. Each reader's AIMD halves its rate.
1853    /// - After the transient, each reader converges to ~60 req/s (300/5).
1854    /// - Over 2 seconds, total successful requests should be in the range
1855    ///   [300, 900] (theoretical max ≈ 600).
1856    #[tokio::test(flavor = "multi_thread", worker_threads = 8)]
1857    async fn test_aimd_throttle_under_concurrent_load() {
1858        let mock = Arc::new(RateLimitingMockStore::new(
1859            30,
1860            std::time::Duration::from_millis(100),
1861        ));
1862
1863        // Seed a test file so head() succeeds when admitted.
1864        let path = Path::from("test/data.bin");
1865        mock.put(&path, PutPayload::from_static(b"test data"))
1866            .await
1867            .unwrap();
1868
1869        let aimd = AimdConfig::default()
1870            .with_initial_rate(100.0)
1871            .with_decrease_factor(0.5)
1872            .with_additive_increment(2.0)
1873            .with_window_duration(std::time::Duration::from_millis(100));
1874        let throttle_config = AimdThrottleConfig::default()
1875            .with_aimd(aimd)
1876            .with_burst_capacity(100);
1877
1878        let num_readers = 5;
1879        let test_duration = std::time::Duration::from_secs(2);
1880        let mut handles = Vec::new();
1881
1882        for _ in 0..num_readers {
1883            let store = Arc::new(
1884                AimdThrottledStore::new(
1885                    mock.clone() as Arc<dyn ObjectStore>,
1886                    throttle_config.clone(),
1887                )
1888                .unwrap(),
1889            );
1890            let p = path.clone();
1891            handles.push(tokio::spawn(async move {
1892                let deadline = std::time::Instant::now() + test_duration;
1893                let mut count = 0u64;
1894                while std::time::Instant::now() < deadline {
1895                    let _ = store.head(&p).await;
1896                    count += 1;
1897                }
1898                count
1899            }));
1900        }
1901
1902        let mut total_reader_requests = 0u64;
1903        for handle in handles {
1904            total_reader_requests += handle.await.unwrap();
1905        }
1906
1907        let successes = mock.success_count.load(Ordering::Relaxed);
1908        let throttled = mock.throttle_count.load(Ordering::Relaxed);
1909        let total_mock = successes + throttled;
1910
1911        // Mock-side count >= reader-side count because the AIMD layer retries
1912        // throttle errors internally, causing multiple mock calls per reader call.
1913        assert!(
1914            total_mock >= total_reader_requests,
1915            "Mock-side count ({total_mock}) should be >= reader-side count ({total_reader_requests})"
1916        );
1917
1918        // Mock capacity is 30/100ms = 300 req/s. Over 2s the theoretical max is
1919        // ~600 successful requests. With AIMD ramp-up, expect somewhat fewer.
1920        assert!(
1921            successes >= 300,
1922            "Expected >= 300 successful requests over 2s, got {successes}"
1923        );
1924        assert!(
1925            successes <= 900,
1926            "Expected <= 900 successful requests, got {successes}"
1927        );
1928
1929        // The initial burst exceeds mock capacity, so throttling must occur.
1930        assert!(throttled > 0, "Expected some throttled requests but got 0");
1931
1932        // Without AIMD, raw tokio tasks against InMemory would fire 100k+ req/s.
1933        // AIMD should keep the total well under 5000 over 2s.
1934        assert!(
1935            total_mock <= 5000,
1936            "AIMD should limit total requests, got {total_mock}"
1937        );
1938    }
1939
1940    /// A mock store that returns a configurable number of throttle errors
1941    /// before succeeding on `get` operations. Used to test the retry logic
1942    /// inside `OperationThrottle::throttled()`.
1943    struct RetryTestMockStore {
1944        inner: InMemory,
1945        /// Number of throttle errors remaining before success.
1946        errors_remaining: std::sync::Mutex<usize>,
1947        /// Total number of `get` calls observed.
1948        get_call_count: AtomicU64,
1949    }
1950
1951    impl RetryTestMockStore {
1952        fn new(errors_before_success: usize) -> Self {
1953            Self {
1954                inner: InMemory::new(),
1955                errors_remaining: std::sync::Mutex::new(errors_before_success),
1956                get_call_count: AtomicU64::new(0),
1957            }
1958        }
1959    }
1960
1961    impl Display for RetryTestMockStore {
1962        fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
1963            write!(f, "RetryTestMockStore")
1964        }
1965    }
1966
1967    impl Debug for RetryTestMockStore {
1968        fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
1969            f.debug_struct("RetryTestMockStore").finish()
1970        }
1971    }
1972
1973    #[async_trait]
1974    impl ObjectStore for RetryTestMockStore {
1975        async fn put_opts(
1976            &self,
1977            location: &Path,
1978            bytes: PutPayload,
1979            opts: PutOptions,
1980        ) -> OSResult<PutResult> {
1981            self.inner.put_opts(location, bytes, opts).await
1982        }
1983        async fn put_multipart_opts(
1984            &self,
1985            location: &Path,
1986            opts: PutMultipartOptions,
1987        ) -> OSResult<Box<dyn MultipartUpload>> {
1988            self.inner.put_multipart_opts(location, opts).await
1989        }
1990        async fn get_opts(&self, location: &Path, options: GetOptions) -> OSResult<GetResult> {
1991            self.get_call_count.fetch_add(1, Ordering::Relaxed);
1992            let should_error = {
1993                let mut remaining = self.errors_remaining.lock().unwrap();
1994                if *remaining > 0 {
1995                    *remaining -= 1;
1996                    true
1997                } else {
1998                    false
1999                }
2000            };
2001            if should_error {
2002                Err(object_store::Error::Generic {
2003                    store: "RetryTestMock",
2004                    source: THROTTLE_ERROR_RESPONSE.into(),
2005                })
2006            } else {
2007                self.inner.get_opts(location, options).await
2008            }
2009        }
2010        async fn get_ranges(&self, location: &Path, ranges: &[Range<u64>]) -> OSResult<Vec<Bytes>> {
2011            self.inner.get_ranges(location, ranges).await
2012        }
2013        fn delete_stream(
2014            &self,
2015            locations: BoxStream<'static, OSResult<Path>>,
2016        ) -> BoxStream<'static, OSResult<Path>> {
2017            self.inner.delete_stream(locations)
2018        }
2019        fn list(&self, prefix: Option<&Path>) -> BoxStream<'static, OSResult<ObjectMeta>> {
2020            self.inner.list(prefix)
2021        }
2022        fn list_with_offset(
2023            &self,
2024            prefix: Option<&Path>,
2025            offset: &Path,
2026        ) -> BoxStream<'static, OSResult<ObjectMeta>> {
2027            self.inner.list_with_offset(prefix, offset)
2028        }
2029        async fn list_with_delimiter(&self, prefix: Option<&Path>) -> OSResult<ListResult> {
2030            self.inner.list_with_delimiter(prefix).await
2031        }
2032        async fn copy_opts(&self, from: &Path, to: &Path, opts: CopyOptions) -> OSResult<()> {
2033            self.inner.copy_opts(from, to, opts).await
2034        }
2035    }
2036
2037    #[tokio::test]
2038    async fn test_throttled_retries_on_throttle_error_then_succeeds() {
2039        // Mock returns 2 throttle errors then succeeds (within MAX_RETRIES=3)
2040        let mock = Arc::new(RetryTestMockStore::new(2));
2041        let path = Path::from("test/retry.txt");
2042        mock.put(&path, PutPayload::from_static(b"retry data"))
2043            .await
2044            .unwrap();
2045
2046        let config = AimdThrottleConfig::default();
2047        let throttled =
2048            AimdThrottledStore::new(mock.clone() as Arc<dyn ObjectStore>, config).unwrap();
2049
2050        let result = throttled.get(&path).await;
2051        assert!(result.is_ok(), "Expected success after retries");
2052
2053        let bytes = result.unwrap().bytes().await.unwrap();
2054        assert_eq!(bytes.as_ref(), b"retry data");
2055
2056        // Should have called get 3 times total: 2 failures + 1 success
2057        assert_eq!(mock.get_call_count.load(Ordering::Relaxed), 3);
2058    }
2059
2060    #[tokio::test]
2061    async fn test_throttled_fails_after_max_retries_exceeded() {
2062        // Mock returns 4 throttle errors (more than MAX_RETRIES=3),
2063        // so all 4 attempts (initial + 3 retries) will fail.
2064        let mock = Arc::new(RetryTestMockStore::new(10));
2065        let path = Path::from("test/fail.txt");
2066        mock.put(&path, PutPayload::from_static(b"fail data"))
2067            .await
2068            .unwrap();
2069
2070        let config = AimdThrottleConfig::default();
2071        let throttled =
2072            AimdThrottledStore::new(mock.clone() as Arc<dyn ObjectStore>, config).unwrap();
2073
2074        let result = throttled.get(&path).await;
2075        assert!(result.is_err(), "Expected error after max retries");
2076        let err = result.unwrap_err();
2077        assert!(is_throttle_error(&err));
2078
2079        let lance_error = lance_core::Error::from(err);
2080        let error_message = lance_error.to_string();
2081        assert!(error_message.contains("x-ms-request-id"));
2082        assert!(error_message.contains("azure-request-id"));
2083
2084        // Should have called get 4 times: initial attempt + 3 retries
2085        assert_eq!(mock.get_call_count.load(Ordering::Relaxed), 4);
2086    }
2087
2088    #[cfg(feature = "aws")]
2089    #[derive(Debug)]
2090    struct MultipartRetryState {
2091        failures_remaining: AtomicUsize,
2092        part_uris: std::sync::Mutex<Vec<String>>,
2093    }
2094
2095    #[cfg(feature = "aws")]
2096    #[derive(Debug)]
2097    struct MultipartRetryConnector {
2098        state: Arc<MultipartRetryState>,
2099    }
2100
2101    #[cfg(feature = "aws")]
2102    impl HttpConnector for MultipartRetryConnector {
2103        fn connect(&self, _options: &ClientOptions) -> object_store::Result<HttpClient> {
2104            Ok(HttpClient::new(MultipartRetryService {
2105                state: Arc::clone(&self.state),
2106            }))
2107        }
2108    }
2109
2110    #[cfg(feature = "aws")]
2111    #[derive(Debug)]
2112    struct MultipartRetryService {
2113        state: Arc<MultipartRetryState>,
2114    }
2115
2116    #[cfg(feature = "aws")]
2117    #[async_trait]
2118    impl HttpService for MultipartRetryService {
2119        async fn call(&self, request: HttpRequest) -> Result<HttpResponse, HttpError> {
2120            let method = request.method().clone();
2121            let query = request.uri().query().unwrap_or_default();
2122            let (status, body, e_tag) = if method == ::http::Method::POST
2123                && query
2124                    .split('&')
2125                    .any(|part| part == "uploads" || part == "uploads=")
2126            {
2127                (
2128                    ::http::StatusCode::OK,
2129                    "<InitiateMultipartUploadResult><Bucket>bucket</Bucket><Key>object</Key><UploadId>upload-id</UploadId></InitiateMultipartUploadResult>",
2130                    None,
2131                )
2132            } else if method == ::http::Method::PUT && query.contains("partNumber=") {
2133                self.state
2134                    .part_uris
2135                    .lock()
2136                    .unwrap()
2137                    .push(request.uri().to_string());
2138                let mut remaining = self.state.failures_remaining.load(Ordering::SeqCst);
2139                let should_fail = loop {
2140                    let Some(next) = remaining.checked_sub(1) else {
2141                        break false;
2142                    };
2143                    match self.state.failures_remaining.compare_exchange_weak(
2144                        remaining,
2145                        next,
2146                        Ordering::SeqCst,
2147                        Ordering::SeqCst,
2148                    ) {
2149                        Ok(_) => break true,
2150                        Err(actual) => remaining = actual,
2151                    }
2152                };
2153                if should_fail {
2154                    (
2155                        ::http::StatusCode::SERVICE_UNAVAILABLE,
2156                        "<Error><Code>SlowDown</Code><Message>Please reduce your request rate.</Message></Error>",
2157                        None,
2158                    )
2159                } else {
2160                    (::http::StatusCode::OK, "", Some("\"part-etag\""))
2161                }
2162            } else if method == ::http::Method::POST && query.contains("uploadId=") {
2163                (
2164                    ::http::StatusCode::OK,
2165                    "<CompleteMultipartUploadResult><Location>https://bucket/object</Location><Bucket>bucket</Bucket><Key>object</Key><ETag>\"object-etag\"</ETag></CompleteMultipartUploadResult>",
2166                    None,
2167                )
2168            } else {
2169                (::http::StatusCode::BAD_REQUEST, "unexpected request", None)
2170            };
2171
2172            let mut response = ::http::Response::builder().status(status);
2173            if let Some(e_tag) = e_tag {
2174                response = response.header(::http::header::ETAG, e_tag);
2175            }
2176            Ok(response
2177                .body(HttpResponseBody::from(body.to_string()))
2178                .unwrap())
2179        }
2180    }
2181
2182    /// Retries must remain inside the original S3 `put_part` call. Re-entering
2183    /// `MultipartUpload::put_part` would allocate a new part number and leave a
2184    /// gap that makes `complete` fail with "Missing part".
2185    #[cfg(feature = "aws")]
2186    #[tokio::test(start_paused = true)]
2187    async fn test_multipart_http_retry_reuses_part_number() {
2188        use object_store::RetryConfig;
2189        use object_store::aws::AmazonS3Builder;
2190
2191        let retry_state = Arc::new(MultipartRetryState {
2192            failures_remaining: AtomicUsize::new(3),
2193            part_uris: std::sync::Mutex::new(Vec::new()),
2194        });
2195        let throttle_state = AimdThrottleState::new(AimdThrottleConfig::default()).unwrap();
2196        let connector = AimdMultipartUploadConnector::new(
2197            MultipartRetryConnector {
2198                state: Arc::clone(&retry_state),
2199            },
2200            Some(&throttle_state),
2201        );
2202        let store = AmazonS3Builder::new()
2203            .with_bucket_name("bucket")
2204            .with_region("us-east-1")
2205            .with_skip_signature(true)
2206            .with_retry(RetryConfig {
2207                max_retries: 0,
2208                ..Default::default()
2209            })
2210            .with_http_connector(connector)
2211            .build()
2212            .unwrap();
2213
2214        let mut upload = store.put_multipart(&Path::from("object")).await.unwrap();
2215        upload
2216            .put_part(PutPayload::from_static(b"payload"))
2217            .await
2218            .unwrap();
2219        upload.complete().await.unwrap();
2220
2221        let part_uris = retry_state.part_uris.lock().unwrap();
2222        assert_eq!(part_uris.len(), 4);
2223        assert!(part_uris.iter().all(|uri| uri == &part_uris[0]));
2224        assert!(part_uris[0].contains("partNumber=1"));
2225    }
2226
2227    #[tokio::test]
2228    async fn test_throttled_multipart_reorders_parts() {
2229        let store = Arc::new(InMemory::new()) as Arc<dyn ObjectStore>;
2230        let config = AimdThrottleConfig::default();
2231        let throttled = AimdThrottledStore::new(store.clone(), config).unwrap();
2232
2233        let path = Path::from("test/multipart_ordering.bin");
2234        let mut upload = throttled.put_multipart(&path).await.unwrap();
2235
2236        // Create futures for two parts in order: A then B.
2237        let fut_a = upload.put_part(PutPayload::from_static(b"AAAA"));
2238        let fut_b = upload.put_part(PutPayload::from_static(b"BBBB"));
2239
2240        // Await in REVERSE order. Part ordering should be determined by
2241        // creation order (put_part call order), not by await order.
2242        fut_b.await.unwrap();
2243        fut_a.await.unwrap();
2244
2245        upload.complete().await.unwrap();
2246
2247        let result = store.get(&path).await.unwrap();
2248        let bytes = result.bytes().await.unwrap();
2249
2250        assert_eq!(
2251            bytes.as_ref(),
2252            b"AAAABBBB",
2253            "Parts were reordered! Got {:?} instead of AAAABBBB.",
2254            std::str::from_utf8(&bytes).unwrap_or("<non-utf8>"),
2255        );
2256    }
2257}