fix(odm): declare source retry policy and time out a stalled inline read (#7111)

* fix(odm): declare the remote client retry policy per consumer

The SDK retry policy was an inherited default: one logical call could cost
three wire requests, so the migration breaker counted logical calls on top
of a threefold amplification against a source that was already failing.

Make it an explicit RemoteS3EndpointSpec field. Replication targets declare
today's standard three attempts and keep their behaviour; the on-demand
migration source and its admin probe declare a disabled policy, so one
counted failure is exactly one source request and pull.rs owns the only
retry budget.

* fix(odm): count a stalled inline source as a source timeout

The inline tee wraps its source body in the idle guard, but the tee turns a
stalled source into an ordinary body read error, so the write-back reported
it as a local write failure. Hand commit_inline the guard so the pull is
counted under source_timeout instead.

The background pump now enforces the idle budget through the same guard
rather than a second copy of the timeout loop.

* test(odm): cover a stalled source body end to end

The fake target can now deliver a GetObject body in slices with a pause
between them, so the inline abort can be driven by a stalled source instead
of a truncated one. Two fault cases drop the workarounds they carried for
the SDK's retries: the scripted fault count and the observed source request
count now have to agree.

The operations guide records the retry and idle-timeout guarantees.
This commit is contained in:
Zhengchao An
2026-09-04 02:24:53 +08:00
committed by GitHub
parent 3a914b429d
commit 3005efe845
14 changed files with 486 additions and 109 deletions
+4 -3
View File
@@ -162,8 +162,9 @@ pub mod bucket {
};
pub use crate::bucket::on_demand_migration::{
EnqueueOutcome, LocalObject, MAX_MULTIPART_PARTS, OdmWriteBack, PULL_MAX_RETRIES, PULL_RETRY_BASE_DELAYS,
PullCompletion, PullQueue, PullReason, PullSource, QueuedPullOutcome, SourceBody, WriteBackBody, WriteBackError,
WriteBackOutcome, WriteBackPart, WriteBackRequest, commit_inline, commit_inline_with, idle_guarded_body,
PullCompletion, PullQueue, PullReason, PullSource, QueuedPullOutcome, SourceBody, SourceIdleGuard, WriteBackBody,
WriteBackError, WriteBackOutcome, WriteBackPart, WriteBackRequest, commit_inline, commit_inline_with,
idle_guarded_body,
};
pub mod backfill {
pub use crate::bucket::on_demand_migration::backfill::{
@@ -241,7 +242,7 @@ pub mod bucket {
pub mod remote_s3_client {
pub use crate::bucket::remote_s3_client::{
PathStyle, RemoteCredentials, RemoteS3ClientError, RemoteS3EndpointSpec, build_remote_s3_client,
PathStyle, RemoteCredentials, RemoteS3ClientError, RemoteS3EndpointSpec, RemoteS3RetryPolicy, build_remote_s3_client,
validate_remote_endpoint,
};
}
+17 -6
View File
@@ -15,7 +15,9 @@
use crate::bucket::metadata::BucketMetadata;
use crate::bucket::metadata_sys::get_bucket_targets_config;
use crate::bucket::metadata_sys::get_replication_config;
use crate::bucket::remote_s3_client::{PathStyle, RemoteCredentials, RemoteS3EndpointSpec, build_remote_s3_client};
use crate::bucket::remote_s3_client::{
PathStyle, REPLICATION_TARGET_RETRY_POLICY, RemoteCredentials, RemoteS3EndpointSpec, build_remote_s3_client,
};
use crate::bucket::replication::{ObjectLockIntegrity, object_lock_put_integrity};
use crate::bucket::replication::{ReplicationStatusType, ReplicationTargetConfigBridge};
use crate::bucket::target::ARN;
@@ -114,6 +116,10 @@ impl From<&BucketTarget> for RemoteS3EndpointSpec {
ca_cert_pem: (!target.ca_cert_pem.trim().is_empty()).then(|| target.ca_cert_pem.clone()),
connect_timeout: None,
read_timeout: None,
// Replication has no retry budget of its own on the request path,
// so it keeps the SDK's standard three attempts; stating it here
// pins the behaviour to this line instead of an SDK default.
retry: REPLICATION_TARGET_RETRY_POLICY,
user_agent_suffix: "",
}
}
@@ -2348,11 +2354,11 @@ impl Error for BucketTargetError {}
mod tests {
use super::*;
use crate::bucket::remote_s3_client::{
EXPIRED_REMOTE_TARGET_CREDENTIALS, RemoteTargetCredentialsProvider, build_aws_s3_http_client_for_spec,
build_aws_s3_http_client_from_target_ca_pem, build_aws_s3_http_client_with_trust_store,
build_insecure_aws_s3_http_client, compose_replication_trust_store, ensure_rustls_crypto_provider,
load_tls_path_ca_bundles, remote_sdk_credentials, replication_request_checksum_calculation,
validate_remote_endpoint_inner, validate_target_ca_pem,
EXPIRED_REMOTE_TARGET_CREDENTIALS, RemoteS3RetryPolicy, RemoteTargetCredentialsProvider,
build_aws_s3_http_client_for_spec, build_aws_s3_http_client_from_target_ca_pem,
build_aws_s3_http_client_with_trust_store, build_insecure_aws_s3_http_client, compose_replication_trust_store,
ensure_rustls_crypto_provider, load_tls_path_ca_bundles, remote_sdk_credentials,
replication_request_checksum_calculation, validate_remote_endpoint_inner, validate_target_ca_pem,
};
use aws_credential_types::Credentials as SdkCredentials;
use aws_sdk_s3::Config as S3Config;
@@ -3050,6 +3056,11 @@ mod tests {
assert!(spec.ca_cert_pem.is_none(), "whitespace-only CA PEM means unset");
assert!(spec.connect_timeout.is_none() && spec.read_timeout.is_none());
assert_eq!(spec.user_agent_suffix, "");
assert_eq!(
spec.retry,
RemoteS3RetryPolicy::Standard { max_attempts: 3 },
"replication targets keep the SDK's historical three attempts"
);
let credentials = spec.credentials.expect("credentials carry over");
assert_eq!(credentials.account_id, "reset-1");
assert!(credentials.session_token.is_none(), "blank session token is absent");
@@ -41,8 +41,8 @@ pub use config::{
pub use negative_cache::{NEGATIVE_CACHE_MAX_ENTRIES, NegativeCache};
pub use pull::{
EnqueueOutcome, LocalObject, MAX_MULTIPART_PARTS, OdmWriteBack, PULL_MAX_RETRIES, PULL_RETRY_BASE_DELAYS, PullCompletion,
PullQueue, PullReason, PullSource, QueuedPullOutcome, SourceBody, WriteBackBody, WriteBackError, WriteBackOutcome,
WriteBackPart, WriteBackRequest, commit_inline, commit_inline_with, idle_guarded_body,
PullQueue, PullReason, PullSource, QueuedPullOutcome, SourceBody, SourceIdleGuard, WriteBackBody, WriteBackError,
WriteBackOutcome, WriteBackPart, WriteBackRequest, commit_inline, commit_inline_with, idle_guarded_body,
};
pub use stats::{
GaugeGuard, LastSourceError, LatencyBucketSnapshot, OdmOp, OdmOutcome, OdmStats, OdmStatsSnapshot, PullFailureReason,
@@ -54,6 +54,7 @@ use std::fmt;
use std::io;
use std::pin::Pin;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::task::{Context, Poll};
use std::time::{Duration, Instant};
use time::OffsetDateTime;
@@ -149,28 +150,58 @@ pub enum EnqueueOutcome {
/// Body a source read produces; consumed inside the pump task only.
pub type SourceBody = Pin<Box<dyn Stream<Item = io::Result<Bytes>> + Send + 'static>>;
/// Says whether an [`idle_guarded_body`] stream ended because the source
/// stalled. The tee flattens a stalled source into an ordinary body read
/// error, which the write-back would otherwise report as a local write
/// failure; the inline path consults this to count the pull under
/// [`PullFailureReason::SourceTimeout`] instead.
#[derive(Clone, Debug, Default)]
pub struct SourceIdleGuard {
timed_out: Arc<AtomicBool>,
}
impl SourceIdleGuard {
/// True once the guarded stream ended on the idle budget.
pub fn timed_out(&self) -> bool {
self.timed_out.load(Ordering::Acquire)
}
}
/// Applies `source_timeout.idle_ms` to a source body chunk by chunk: a chunk
/// that does not arrive within `idle_timeout` ends the stream with a timeout
/// error. The background pump enforces the same budget itself; the inline tee
/// path in the app layer has no pump and wraps its body with this instead, so
/// a stalled source cannot hold an inline pull open past the configured
/// budget.
pub fn idle_guarded_body(body: SourceBody, idle_timeout: Duration) -> SourceBody {
Box::pin(futures::stream::unfold(Some(body), move |state| async move {
let mut body = state?;
match tokio::time::timeout(idle_timeout, body.next()).await {
Err(_elapsed) => Some((
Err(io::Error::new(
io::ErrorKind::TimedOut,
format!("source body stalled for more than {}ms", idle_timeout.as_millis()),
)),
None,
)),
Ok(None) => None,
Ok(Some(Err(err))) => Some((Err(err), None)),
Ok(Some(Ok(chunk))) => Some((Ok(chunk), Some(body))),
/// error. Both pull paths share this one implementation — the background pump
/// wraps the body it copies into the write-back channel, and the app layer
/// wraps the body it tees between the client and [`commit_inline`].
///
/// The budget measures the source, not the consumer: the timeout is armed
/// inside the stream's own poll, so a consumer that stops asking for bytes (a
/// slow client, or a tee whose queue is full) leaves no timer running and is
/// never mistaken for an idle source.
pub fn idle_guarded_body(body: SourceBody, idle_timeout: Duration) -> (SourceBody, SourceIdleGuard) {
let guard = SourceIdleGuard::default();
let timed_out = Arc::clone(&guard.timed_out);
let stream = futures::stream::unfold(Some(body), move |state| {
let timed_out = Arc::clone(&timed_out);
async move {
let mut body = state?;
match tokio::time::timeout(idle_timeout, body.next()).await {
Err(_elapsed) => {
timed_out.store(true, Ordering::Release);
Some((
Err(io::Error::new(
io::ErrorKind::TimedOut,
format!("source body stalled for more than {}ms", idle_timeout.as_millis()),
)),
None,
))
}
Ok(None) => None,
Ok(Some(Err(err))) => Some((Err(err), None)),
Ok(Some(Ok(chunk))) => Some((Ok(chunk), Some(body))),
}
}
}))
});
(Box::pin(stream), guard)
}
/// Body handed to the write-back; `Sync` because the app-layer put path
@@ -362,13 +393,15 @@ impl PumpState {
}
/// Copies the source body into a bounded channel, enforcing `idle_timeout`
/// per chunk, `cancel`, and the advertised `expected_size`.
/// per chunk (through [`idle_guarded_body`]), `cancel`, and the advertised
/// `expected_size`.
fn spawn_pump(
mut body: SourceBody,
body: SourceBody,
expected_size: u64,
idle_timeout: Duration,
cancel: CancellationToken,
) -> (mpsc::Receiver<io::Result<Bytes>>, Arc<PumpState>) {
let (mut body, _idle) = idle_guarded_body(body, idle_timeout);
let (tx, rx) = mpsc::channel(PUMP_CHANNEL_CHUNKS);
let state = Arc::new(PumpState::default());
let pump_state = Arc::clone(&state);
@@ -377,13 +410,12 @@ fn spawn_pump(
loop {
let next = tokio::select! {
_ = cancel.cancelled() => Err(pump_state.fail(PumpFailure::Canceled)),
next = tokio::time::timeout(idle_timeout, body.next()) => match next {
Err(_elapsed) => Err(pump_state.fail(PumpFailure::Source(SourceError::Timeout))),
Ok(None) if delivered < expected_size => Err(pump_state.fail(PumpFailure::Source(SourceError::Connect(
next = body.next() => match next {
None if delivered < expected_size => Err(pump_state.fail(PumpFailure::Source(SourceError::Connect(
format!("source body ended after {delivered} of {expected_size} bytes"),
)))),
Ok(None) => return,
Ok(Some(Err(err))) => {
None => return,
Some(Err(err)) => {
let failure = if err.kind() == io::ErrorKind::TimedOut {
SourceError::Timeout
} else {
@@ -391,7 +423,7 @@ fn spawn_pump(
};
Err(pump_state.fail(PumpFailure::Source(failure)))
}
Ok(Some(Ok(chunk))) => {
Some(Ok(chunk)) => {
let len = u64::try_from(chunk.len()).unwrap_or(u64::MAX);
delivered = delivered.saturating_add(len);
if delivered > expected_size {
@@ -738,19 +770,25 @@ fn record_completion(state: &BucketOdmState, key: &str, path: PullPath, result:
/// Inline write-back of a body the GET handler is already streaming (the
/// tee secondary). No retry: the bytes cannot be re-read. The caller owns
/// the singleflight slot; this only writes and accounts.
///
/// `idle` is the guard of the [`idle_guarded_body`] the caller teed, so a
/// write-back that failed because the source stalled is counted as the source
/// timeout it is rather than as a local write failure. Callers that do not
/// wrap the source body pass a default guard.
pub async fn commit_inline(
state: &Arc<BucketOdmState>,
key: &str,
head: SourceHead,
tags: Option<HashMap<String, String>>,
body: WriteBackBody,
idle: &SourceIdleGuard,
) -> Result<WriteBackOutcome, PullError> {
let Some(write_back) = state.write_back() else {
let error = PullError::new(PullFailureReason::LocalWrite, "on-demand migration write-back is not installed");
record_completion(state, key, PullPath::Inline, &Err(error.clone()));
return Err(error);
};
commit_inline_with(state, write_back, key, head, tags, body).await
commit_inline_with(state, write_back, key, head, tags, body, idle).await
}
/// [`commit_inline`] with an explicit write-back (tests and embedders).
@@ -761,12 +799,16 @@ pub async fn commit_inline_with(
head: SourceHead,
tags: Option<HashMap<String, String>>,
body: WriteBackBody,
idle: &SourceIdleGuard,
) -> Result<WriteBackOutcome, PullError> {
let request = WriteBackRequest::new(state, key, head, tags);
let result = write_back
.put_object(&request, body)
.await
.map_err(|err| PullError::new(err.reason(), err.to_string()));
let result = write_back.put_object(&request, body).await.map_err(|err| {
if idle.timed_out() {
PullError::new(PullFailureReason::SourceTimeout, SourceError::Timeout.to_string())
} else {
PullError::new(err.reason(), err.to_string())
}
});
let completion = result
.as_ref()
.map(|outcome| PullCompletion::Stored(outcome.clone()))
@@ -1516,10 +1558,11 @@ mod tests {
async fn idle_guarded_body_ends_a_stalled_stream_and_passes_chunks_through() {
let (tx, rx) = mpsc::channel::<io::Result<Bytes>>(4);
let body: SourceBody = Box::pin(tokio_stream::wrappers::ReceiverStream::new(rx));
let mut guarded = idle_guarded_body(body, Duration::from_secs(2));
let (mut guarded, idle) = idle_guarded_body(body, Duration::from_secs(2));
tx.send(Ok(Bytes::from_static(b"chunk"))).await.expect("send a chunk");
assert_eq!(guarded.next().await.expect("a chunk").expect("chunk is ok"), Bytes::from_static(b"chunk"));
assert!(!idle.timed_out(), "a delivered chunk is not a stall");
// The producer never sends again: the guard ends the stream itself.
let started = tokio::time::Instant::now();
@@ -1530,10 +1573,117 @@ mod tests {
.expect_err("a stalled body must time out");
assert_eq!(err.kind(), io::ErrorKind::TimedOut);
assert!(started.elapsed() >= Duration::from_secs(2));
assert!(idle.timed_out(), "the guard reports why the stream ended");
assert!(guarded.next().await.is_none(), "the stream ends after the timeout");
drop(tx);
}
/// A source that keeps producing, only slowly, must survive: the budget
/// is per chunk, not for the whole body.
#[tokio::test(start_paused = true)]
async fn idle_guarded_body_accepts_a_slow_but_advancing_source() {
let (tx, rx) = mpsc::channel::<io::Result<Bytes>>(1);
let body: SourceBody = Box::pin(tokio_stream::wrappers::ReceiverStream::new(rx));
let (mut guarded, idle) = idle_guarded_body(body, Duration::from_secs(2));
let producer = tokio::spawn(async move {
for _ in 0..5 {
tokio::time::sleep(Duration::from_millis(1_500)).await;
if tx.send(Ok(Bytes::from_static(b"chunk"))).await.is_err() {
return;
}
}
});
let mut chunks = 0;
while let Some(chunk) = guarded.next().await {
chunk.expect("a source that keeps advancing must not time out");
chunks += 1;
}
producer.await.expect("producer task");
assert_eq!(chunks, 5);
assert!(!idle.timed_out());
}
/// The budget measures the source, not the consumer: a reader that stops
/// asking for bytes for far longer than the budget still gets the rest of
/// the body once it resumes.
#[tokio::test(start_paused = true)]
async fn idle_guarded_body_does_not_time_out_a_slow_consumer() {
let (tx, rx) = mpsc::channel::<io::Result<Bytes>>(4);
let body: SourceBody = Box::pin(tokio_stream::wrappers::ReceiverStream::new(rx));
let (mut guarded, idle) = idle_guarded_body(body, Duration::from_secs(2));
for _ in 0..3 {
tx.send(Ok(Bytes::from_static(b"chunk"))).await.expect("send a chunk");
}
drop(tx);
let mut chunks = 0;
while let Some(chunk) = guarded.next().await {
chunk.expect("a stalled consumer must not fail the source");
chunks += 1;
// Ten times the budget passes between reads.
tokio::time::sleep(Duration::from_secs(20)).await;
}
assert_eq!(chunks, 3);
assert!(!idle.timed_out(), "the consumer's own pace is not source idleness");
}
/// The inline path has no pump: the guard is what turns a stalled source
/// into a `source_timeout` failure instead of a local write failure.
#[tokio::test(start_paused = true)]
async fn commit_inline_reports_a_stalled_source_as_a_source_timeout() {
let sys = OnDemandMigrationSys::new();
let mock = Arc::new(MockWriteBack::default());
let mock_dyn: Arc<dyn OdmWriteBack> = mock.clone();
sys.set_write_back(mock_dyn);
let state = enabled_state(&sys, &config()).await;
let (tx, rx) = mpsc::channel::<io::Result<Bytes>>(4);
let body: SourceBody = Box::pin(tokio_stream::wrappers::ReceiverStream::new(rx));
let (mut guarded, idle) = idle_guarded_body(body, Duration::from_secs(1));
tx.send(Ok(Bytes::from(body_bytes(100)))).await.expect("send the first chunk");
// The source announced 300 bytes and then stops sending; holding the
// sender keeps the stream open the way a stalled connection would.
let keep_open = tx;
// Stands in for the tee, which forwards the guarded source to the
// write-back and is the reason the write-back only ever sees a plain
// body read error.
let (relay_tx, relay_rx) = mpsc::channel::<io::Result<Bytes>>(4);
tokio::spawn(async move {
while let Some(chunk) = guarded.next().await {
let failed = chunk.is_err();
if relay_tx.send(chunk).await.is_err() || failed {
return;
}
}
});
let write_body: WriteBackBody = Box::pin(tokio_stream::wrappers::ReceiverStream::new(relay_rx));
// The inline path holds the singleflight slot across the commit, the
// way `odm_get_inline` does.
let PullSlot::Leader(leader) = state.acquire_pull_slot("stalled").await.expect("the first caller leads") else {
panic!("the first caller must be the leader");
};
assert_eq!(state.stats().inflight_pulls(), 1);
let err = commit_inline(&state, "stalled", head(300), None, write_body, &idle)
.await
.expect_err("a stalled source must not commit");
assert_eq!(err.reason, PullFailureReason::SourceTimeout, "{err}");
assert!(idle.timed_out());
assert_eq!(failures(&state).get("source_timeout"), Some(&1));
assert_eq!(failures(&state).get("local_write"), None, "a stalled source is not a local write failure");
assert!(mock.local.lock().get("stalled").is_none(), "nothing is stored");
leader.complete(Err(err));
assert_eq!(state.stats().inflight_pulls(), 0, "the aborted pull releases its slot");
assert_eq!(state.inflight_keys(), 0);
drop(keep_open);
}
#[tokio::test(start_paused = true)]
async fn stalled_source_body_hits_the_idle_timeout() {
let sys = OnDemandMigrationSys::new();
@@ -1695,7 +1845,7 @@ mod tests {
let body = body_bytes(300);
let stream: WriteBackBody = Box::pin(futures::stream::iter(vec![Ok(Bytes::from(body.clone()))]));
let outcome = commit_inline(&state, "inline", head(300), None, stream)
let outcome = commit_inline(&state, "inline", head(300), None, stream, &SourceIdleGuard::default())
.await
.expect("inline commit succeeds");
assert_eq!(outcome.size, 300);
@@ -1706,7 +1856,7 @@ mod tests {
*mock.forced_put_error.lock() = Some(WriteBackError::Integrity);
let stream: WriteBackBody = Box::pin(futures::stream::iter(vec![Ok(Bytes::from(body.clone()))]));
let err = commit_inline_with(&state, &mock_dyn, "inline", head(300), None, stream)
let err = commit_inline_with(&state, &mock_dyn, "inline", head(300), None, stream, &SourceIdleGuard::default())
.await
.expect_err("integrity failure surfaces");
assert_eq!(err.reason, PullFailureReason::EtagMismatch);
@@ -1717,7 +1867,7 @@ mod tests {
Ok(Bytes::from(body_bytes(100))),
Err(io::Error::new(io::ErrorKind::BrokenPipe, "tee primary dropped")),
]));
let err = commit_inline(&state, "torn", head(300), None, stream)
let err = commit_inline(&state, "torn", head(300), None, stream, &SourceIdleGuard::default())
.await
.expect_err("a broken secondary must fail");
assert_eq!(err.reason, PullFailureReason::LocalWrite);
@@ -1728,7 +1878,7 @@ mod tests {
let bare_state = enabled_state(&bare, &config()).await;
assert!(bare_state.write_back().is_none());
let stream: WriteBackBody = Box::pin(futures::stream::empty());
let err = commit_inline(&bare_state, "x", head(0), None, stream)
let err = commit_inline(&bare_state, "x", head(0), None, stream, &SourceIdleGuard::default())
.await
.expect_err("no write-back");
assert_eq!(err.reason, PullFailureReason::LocalWrite);
@@ -26,11 +26,10 @@
//! forwarded: v1 rejects SSE-C source objects outright.
use crate::bucket::remote_s3_client::{
PathStyle, RemoteCredentials, RemoteS3ClientError, RemoteS3EndpointSpec, build_remote_s3_config,
PathStyle, RemoteCredentials, RemoteS3ClientError, RemoteS3EndpointSpec, RemoteS3RetryPolicy, build_remote_s3_config,
};
use crate::storage_api_contracts::range::HTTPRangeSpec;
use aws_sdk_s3::Client as S3Client;
use aws_sdk_s3::config::retry::RetryConfig;
use aws_sdk_s3::error::{ProvideErrorMetadata, SdkError};
use aws_sdk_s3::operation::get_object::GetObjectOutput;
use aws_sdk_s3::operation::head_object::HeadObjectOutput;
@@ -150,6 +149,12 @@ pub struct SourceClientSpec {
pub skip_tls_verify: bool,
pub ca_cert_pem: Option<String>,
pub timeouts: SourceTimeouts,
/// Wire requests one logical source call may cost. The pull pipeline and
/// the backfill job own the retry budget (`pull.rs` `PULL_MAX_RETRIES`,
/// `backfill.rs` `LIST_MAX_RETRIES`) and the breaker counts logical calls,
/// so ODM declares [`RemoteS3RetryPolicy::Disabled`] and keeps one counted
/// failure equal to one request against a struggling source.
pub retry: RemoteS3RetryPolicy,
/// Bytes per second the pull pipeline may consume from this source;
/// `None` means unlimited. Enforced by the consumer, not by this client.
pub bandwidth_limit: Option<NonZeroU64>,
@@ -193,6 +198,7 @@ impl SourceClientSpec {
ca_cert_pem: self.ca_cert_pem.clone(),
connect_timeout: Some(self.timeouts.connect),
read_timeout: Some(self.timeouts.read),
retry: self.retry,
user_agent_suffix: USER_AGENT_SUFFIX,
})
}
@@ -579,19 +585,11 @@ impl SourceClient {
Ok(Self::from_config_builder(config, endpoint.endpoint_url(), spec))
}
/// `config` must come from [`SourceClientSpec::endpoint_spec`], which is
/// where the retry policy that keeps one logical call equal to one wire
/// request is declared.
fn from_config_builder(config: aws_sdk_s3::config::Builder, endpoint: String, spec: &SourceClientSpec) -> Self {
// The pull pipeline and the backfill job own the retry budget for a
// source call (`pull.rs` PULL_MAX_RETRIES, `backfill.rs`
// LIST_MAX_RETRIES), and the breaker counts logical calls. Leaving the
// smithy standard policy on top would turn one counted failure into
// three wire requests against a source that is already struggling, so
// keep one logical call equal to one wire request.
let client = S3Client::from_conf(
config
.retry_config(RetryConfig::disabled())
.interceptor(SourceProxyMarkerInterceptor::new())
.build(),
);
let client = S3Client::from_conf(config.interceptor(SourceProxyMarkerInterceptor::new()).build());
Self {
client,
endpoint,
@@ -862,6 +860,7 @@ mod tests {
}),
skip_tls_verify: false,
ca_cert_pem: None,
retry: RemoteS3RetryPolicy::Disabled,
timeouts: SourceTimeouts::default(),
bandwidth_limit: NonZeroU64::new(1_000_000),
}
@@ -48,7 +48,9 @@ use super::negative_cache::NegativeCache;
use super::pull::{OdmWriteBack, PullQueue};
use super::source_client::{SourceClient, SourceClientSpec, SourceError, SourceProvider, SourceTimeouts};
use super::stats::{GaugeGuard, OdmStats, OdmStatsSnapshot, PullFailureReason};
use crate::bucket::remote_s3_client::{PathStyle as ClientPathStyle, RemoteCredentials, RemoteS3ClientError};
use crate::bucket::remote_s3_client::{
PathStyle as ClientPathStyle, RemoteCredentials, RemoteS3ClientError, RemoteS3RetryPolicy,
};
use parking_lot::{Mutex, RwLock};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
@@ -602,6 +604,10 @@ pub fn source_client_spec(config: &OnDemandMigrationConfig) -> SourceClientSpec
connect: Duration::from_millis(policy.source_timeout.connect_ms),
read: Duration::from_millis(policy.source_timeout.first_byte_ms),
},
// The pull pipeline and the backfill job already retry, and the
// breaker counts logical calls: an SDK retry on top would triple the
// load on a source that is already failing.
retry: RemoteS3RetryPolicy::Disabled,
bandwidth_limit: policy.bandwidth_limit_bytes_per_sec.and_then(NonZeroU64::new),
}
}
@@ -1192,6 +1198,11 @@ mod tests {
assert_eq!(spec.timeouts.connect, Duration::from_millis(1500));
assert_eq!(spec.timeouts.read, Duration::from_millis(2500));
assert_eq!(spec.bandwidth_limit, NonZeroU64::new(1 << 20));
assert_eq!(
spec.retry,
RemoteS3RetryPolicy::Disabled,
"the pull pipeline owns the retry budget, so one source call is one wire request"
);
let mut aws = config(None);
aws.source.provider = Provider::Aws;
+101 -3
View File
@@ -17,8 +17,11 @@
//! Replication targets (`bucket_target_sys`) and the on-demand migration
//! source client build their remote clients from one neutral
//! [`RemoteS3EndpointSpec`]: endpoint assembly, credential handling, path-style
//! selection, custom CA / skip-TLS transports and the outbound SSRF gate all
//! live here so both callers share exactly one policy. The gate keeps the
//! selection, custom CA / skip-TLS transports, the SDK retry policy and the
//! outbound SSRF gate all live here so both callers share exactly one policy.
//! The retry policy is the one knob the two consumers deliberately disagree
//! on, so [`RemoteS3EndpointSpec::retry`] is a required field rather than an
//! inherited SDK default. The gate keeps the
//! relaxed replication semantics documented in
//! `docs/operations/outbound-connection-policy.md`: private addresses are
//! always allowed, loopback only behind `RUSTFS_REPLICATION_ALLOW_LOOPBACK_TARGET`.
@@ -29,6 +32,7 @@ use aws_sdk_s3::config::Region as SdkRegion;
use aws_sdk_s3::config::RequestChecksumCalculation;
use aws_sdk_s3::config::SharedCredentialsProvider;
use aws_sdk_s3::config::SharedHttpClient;
use aws_sdk_s3::config::retry::RetryConfig;
use aws_sdk_s3::{Client as S3Client, Config as S3Config};
use aws_smithy_http_client::{Builder as SmithyHttpClientBuilder, tls as smithy_tls};
use aws_smithy_runtime_api::box_error::BoxError;
@@ -80,6 +84,34 @@ impl PathStyle {
}
}
/// SDK-level retry policy for a remote client. Retries are invisible to the
/// caller — one logical call becomes several wire requests — so every consumer
/// states its own instead of inheriting the SDK default: a caller that already
/// owns a retry budget would otherwise multiply it against an endpoint that is
/// by definition already failing.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum RemoteS3RetryPolicy {
/// One logical call is exactly one wire request; the caller owns the
/// retry budget.
Disabled,
/// Smithy's standard strategy, capped at `max_attempts` attempts in total
/// (the initial request included). Values below 1 are clamped to 1.
Standard { max_attempts: u32 },
}
/// The SDK default replication targets have always run with, written out so a
/// change to it is a change to this line rather than to a dependency default.
pub const REPLICATION_TARGET_RETRY_POLICY: RemoteS3RetryPolicy = RemoteS3RetryPolicy::Standard { max_attempts: 3 };
impl RemoteS3RetryPolicy {
fn retry_config(self) -> RetryConfig {
match self {
RemoteS3RetryPolicy::Disabled => RetryConfig::disabled(),
RemoteS3RetryPolicy::Standard { max_attempts } => RetryConfig::standard().with_max_attempts(max_attempts.max(1)),
}
}
}
/// Static or temporary credentials for a remote endpoint. `expiration` without
/// a `session_token` is rejected at build time: only STS-style temporary
/// credentials expire, so that combination is a corrupted configuration
@@ -123,6 +155,9 @@ pub struct RemoteS3EndpointSpec {
pub ca_cert_pem: Option<String>,
pub connect_timeout: Option<Duration>,
pub read_timeout: Option<Duration>,
/// How many wire requests one logical call may cost. Every consumer
/// declares it; see [`RemoteS3RetryPolicy`].
pub retry: RemoteS3RetryPolicy,
/// Appended to the SDK `User-Agent` (space separated) so the remote side
/// can identify the caller; empty means no suffix.
pub user_agent_suffix: &'static str,
@@ -263,7 +298,8 @@ pub(crate) async fn build_remote_s3_config(
.credentials_provider(SharedCredentialsProvider::new(RemoteTargetCredentialsProvider { credentials: creds }))
.region(SdkRegion::new(spec.region.clone()))
.behavior_version(aws_sdk_s3::config::BehaviorVersion::latest())
.request_checksum_calculation(replication_request_checksum_calculation());
.request_checksum_calculation(replication_request_checksum_calculation())
.retry_config(spec.retry.retry_config());
if spec.path_style.force_path_style() {
config_builder = config_builder.force_path_style(true);
@@ -618,6 +654,7 @@ mod tests {
use super::*;
use aws_smithy_runtime_api::http::StatusCode as SmithyStatusCode;
use std::sync::Mutex;
use std::sync::atomic::{AtomicUsize, Ordering};
fn spec(endpoint: &str, secure: bool) -> RemoteS3EndpointSpec {
RemoteS3EndpointSpec {
@@ -636,6 +673,7 @@ mod tests {
ca_cert_pem: None,
connect_timeout: None,
read_timeout: None,
retry: RemoteS3RetryPolicy::Disabled,
user_agent_suffix: "",
}
}
@@ -726,6 +764,66 @@ mod tests {
assert!(err.to_string().contains("invalid target CA PEM"));
}
/// Answers every request with a retryable 503 and counts the wire
/// requests one logical call produced.
#[derive(Clone, Debug)]
struct CountingUnavailableConnector {
wire_requests: Arc<AtomicUsize>,
}
impl SmithyHttpConnector for CountingUnavailableConnector {
fn call(&self, _request: HttpRequest) -> HttpConnectorFuture {
self.wire_requests.fetch_add(1, Ordering::SeqCst);
HttpConnectorFuture::ready(Ok(HttpResponse::new(
SmithyStatusCode::try_from(503_u16).expect("503 should be a valid response status"),
SdkBody::empty(),
)))
}
}
async fn wire_requests_for_one_failed_call(retry: RemoteS3RetryPolicy) -> usize {
let wire_requests = Arc::new(AtomicUsize::new(0));
let connector = SharedHttpConnector::new(CountingUnavailableConnector {
wire_requests: Arc::clone(&wire_requests),
});
let http_client = http_client_fn(move |_settings, _components| connector.clone());
let mut spec = spec("s3.example.com", true);
spec.retry = retry;
let config = build_remote_s3_config(&spec)
.await
.expect("spec should build")
.http_client(http_client)
.build();
S3Client::from_conf(config)
.head_bucket()
.bucket("bucket")
.send()
.await
.expect_err("a 503 must fail the call");
wire_requests.load(Ordering::SeqCst)
}
#[tokio::test(start_paused = true)]
async fn retry_policy_decides_how_many_wire_requests_one_call_costs() {
assert_eq!(
wire_requests_for_one_failed_call(RemoteS3RetryPolicy::Disabled).await,
1,
"a disabled policy must not amplify one logical call"
);
assert_eq!(
wire_requests_for_one_failed_call(REPLICATION_TARGET_RETRY_POLICY).await,
3,
"replication targets keep the three-attempt SDK default"
);
assert_eq!(
wire_requests_for_one_failed_call(RemoteS3RetryPolicy::Standard { max_attempts: 0 }).await,
1,
"a zero attempt budget is clamped to the initial request"
);
}
#[test]
fn path_style_auto_and_path_force_path_style() {
assert!(PathStyle::Auto.force_path_style());