Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions crates/iceberg/public-api.txt
Original file line number Diff line number Diff line change
Expand Up @@ -1117,6 +1117,9 @@ pub const iceberg::io::S3_ENDPOINT: &str
pub const iceberg::io::S3_MULTIPART_PART_SIZE_BYTES: &str
pub const iceberg::io::S3_PATH_STYLE_ACCESS: &str
pub const iceberg::io::S3_REGION: &str
pub const iceberg::io::S3_RETRY_MAX_WAIT_MS: &str
pub const iceberg::io::S3_RETRY_MIN_WAIT_MS: &str
pub const iceberg::io::S3_RETRY_NUM_RETRIES: &str
pub const iceberg::io::S3_SECRET_ACCESS_KEY: &str
pub const iceberg::io::S3_SESSION_TOKEN: &str
pub const iceberg::io::S3_SSE_KEY: &str
Expand Down
7 changes: 7 additions & 0 deletions crates/iceberg/src/io/storage/config/s3.rs
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,13 @@ pub const S3_DISABLE_CONFIG_LOAD: &str = "s3.disable-config-load";
///
/// Only `iceberg-storage-opendal` reads this property, so [`S3Config`] has no matching field.
pub const S3_MULTIPART_PART_SIZE_BYTES: &str = "s3.multipart.part-size-bytes";
/// Number of times to retry an S3 operation, `0` to disable retrying. Retries back off exponentially,
/// with jitter, between [`S3_RETRY_MIN_WAIT_MS`] and [`S3_RETRY_MAX_WAIT_MS`].
pub const S3_RETRY_NUM_RETRIES: &str = "s3.retry.num-retries";
/// Minimum wait in milliseconds before retrying an S3 operation.
pub const S3_RETRY_MIN_WAIT_MS: &str = "s3.retry.min-wait-ms";
/// Maximum wait in milliseconds between two retries of an S3 operation.
pub const S3_RETRY_MAX_WAIT_MS: &str = "s3.retry.max-wait-ms";
Comment on lines +80 to +84

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a reason we would put these in the s3 namespace and not in the new opendal namespace? It seems like these could apply to all opendal backends?

@emanuilov emanuilov Nov 2, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I put them under s3. on purpose: they’re the same keys Iceberg Java’s S3FileIO uses (s3.retry.num-retries, s3.retry.min-wait-ms, s3.retry.max-wait-ms), so a catalog configuration written for Java works unchanged with iceberg-rust. They aren’t meant to be opendal-only: the object_store S3 backend (#3165) should read the same keys. To make that explicit, I can move the parsing into a small backend-neutral retry config in iceberg::io, which the opendal and object_store backends each turn into their own retry setup. Other schemes (GCS, Azure) could feed the same struct from their own keys later. Want me to do that in this PR?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That makes sense, wanting java configured tables to work out of the box makes sense but I wonder if we could layer these configs better. i.e have both namespaces available with the more specific one taking preference? Does something like that make sense?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just to check I understand: do you mean a generic set of retry keys in the opendal namespace that every OpenDAL backend uses, with s3.retry.* taking precedence for S3?

If so, here’s what I’d do:

  • Add opendal.retry.num-retries, opendal.retry.min-wait-ms and opendal.retry.max-wait-ms to OpenDalClientConfig, next to opendal.io-timeout-ms, and build every backend’s RetryLayer from them.
  • For S3, any s3.retry.* key that is set overrides the matching field, so the order is s3.retry., then opendal.retry., then OpenDAL’s defaults. Each key is resolved separately, so you can set one at either layer.
  • Java-configured tables work unchanged. Anyone who wants the same retries on every backend sets the opendal.retry.* keys once.
  • Tests for generic-only, S3-only, both set (S3 wins), a key set at only one layer, and a non-S3 backend ignoring s3.retry.*.

Is that what you had in mind? If so, I’ll add it to this PR.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes! I think this makes sense to me!

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pushed the change with the layered config: opendal.retry.* for every backend, with s3.retry.* overriding per key for S3.

Tested locally:

  • The four existing retry tests are unchanged. New tests cover generic keys applying to every backend, S3 keys winning per key over the generic ones, a key set at only one layer, and the new keys rejecting invalid values.
  • Downstream: I built our Kafka → Iceberg (S3 Tables) sink against this branch (iceberg, iceberg-catalog-s3tables, iceberg-storage-opendal patched to it) and ran its end-to-end tests on a local kind cluster against real S3 Tables:
  • with s3.retry.num-retries=10, s3.retry.min-wait-ms=2000, s3.retry.max-wait-ms=20000 and opendal.retry.num-retries=3 all set, the run committed every row exactly once;
  • with an invalid opendal.retry.min-wait-ms=0, and separately s3.retry.min-wait-ms=0, the sink fails at startup with a property error naming the key, so both layers are read through the real FileIO path.

I didn’t reproduce real S3 throttling here, so which layer wins at runtime is covered by the unit tests rather than the e2e.


/// Amazon S3 storage configuration.
///
Expand Down
3 changes: 3 additions & 0 deletions crates/storage/opendal/public-api.txt
Original file line number Diff line number Diff line change
Expand Up @@ -130,3 +130,6 @@ pub fn iceberg_storage_opendal::OpenDalResolvingStorageFactory::serialize<__S>(&
impl<'de> serde_core::de::Deserialize<'de> for iceberg_storage_opendal::OpenDalResolvingStorageFactory
pub fn iceberg_storage_opendal::OpenDalResolvingStorageFactory::deserialize<__D>(__deserializer: __D) -> core::result::Result<Self, <__D as serde_core::de::Deserializer>::Error> where __D: serde_core::de::Deserializer<'de>
pub const iceberg_storage_opendal::OPENDAL_IO_TIMEOUT_MS: &str
pub const iceberg_storage_opendal::OPENDAL_RETRY_MAX_WAIT_MS: &str
pub const iceberg_storage_opendal::OPENDAL_RETRY_MIN_WAIT_MS: &str
pub const iceberg_storage_opendal::OPENDAL_RETRY_NUM_RETRIES: &str
127 changes: 123 additions & 4 deletions crates/storage/opendal/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,18 @@ pub use resolving::{OpenDalResolvingStorage, OpenDalResolvingStorageFactory};
/// affected and stays at its 60-second default.
pub const OPENDAL_IO_TIMEOUT_MS: &str = "opendal.io-timeout-ms";

/// Number of times to retry a failed operation on any backend, `0` to disable retrying. Unset keeps
/// OpenDAL's default. For S3, [`S3_RETRY_NUM_RETRIES`](iceberg::io::S3_RETRY_NUM_RETRIES) takes precedence.
pub const OPENDAL_RETRY_NUM_RETRIES: &str = "opendal.retry.num-retries";

/// Minimum wait in milliseconds before retrying a failed operation on any backend. Unset keeps
/// OpenDAL's default. For S3, [`S3_RETRY_MIN_WAIT_MS`](iceberg::io::S3_RETRY_MIN_WAIT_MS) takes precedence.
pub const OPENDAL_RETRY_MIN_WAIT_MS: &str = "opendal.retry.min-wait-ms";

/// Maximum wait in milliseconds between two retries on any backend. Unset keeps OpenDAL's default.
/// For S3, [`S3_RETRY_MAX_WAIT_MS`](iceberg::io::S3_RETRY_MAX_WAIT_MS) takes precedence.
pub const OPENDAL_RETRY_MAX_WAIT_MS: &str = "opendal.retry.max-wait-ms";

/// Matches OpenDAL's `TimeoutLayer` default.
const DEFAULT_IO_TIMEOUT_MS: NonZeroU64 = NonZeroU64::new(10_000).unwrap();

Expand All @@ -130,15 +142,35 @@ pub struct OpenDalClientConfig {
#[property(
key = OPENDAL_IO_TIMEOUT_MS,
default = DEFAULT_IO_TIMEOUT_MS,
parse_with = parse_io_timeout_ms
parse_with = parse_positive_ms
)]
io_timeout_ms: NonZeroU64,
/// Retries on any backend.
#[property(key = OPENDAL_RETRY_NUM_RETRIES, default = None)]
retry_num_retries: Option<usize>,
/// Delay before the first retry on any backend in milliseconds.
#[property(
key = OPENDAL_RETRY_MIN_WAIT_MS,
default = None,
parse_with = parse_positive_ms
)]
retry_min_wait_ms: Option<NonZeroU64>,
/// Upper bound of the delay between two retries on any backend in milliseconds.
#[property(
key = OPENDAL_RETRY_MAX_WAIT_MS,
default = None,
parse_with = parse_positive_ms
)]
retry_max_wait_ms: Option<NonZeroU64>,
}

impl Default for OpenDalClientConfig {
fn default() -> Self {
Self {
io_timeout_ms: DEFAULT_IO_TIMEOUT_MS,
retry_num_retries: None,
retry_min_wait_ms: None,
retry_max_wait_ms: None,
}
}
}
Expand All @@ -147,9 +179,30 @@ impl OpenDalClientConfig {
pub(crate) fn io_timeout(&self) -> Duration {
Duration::from_millis(self.io_timeout_ms.get())
}

/// The retry policy, with jitter; OpenDAL's default when no key is set.
pub(crate) fn retry_layer(&self) -> RetryLayer {
let mut layer = RetryLayer::new();
if self.retry_num_retries.is_none()
&& self.retry_min_wait_ms.is_none()
&& self.retry_max_wait_ms.is_none()
{
return layer;
}
if let Some(times) = self.retry_num_retries {
layer = layer.with_max_times(times);
}
if let Some(ms) = self.retry_min_wait_ms {
layer = layer.with_min_delay(Duration::from_millis(ms.get()));
}
if let Some(ms) = self.retry_max_wait_ms {
layer = layer.with_max_delay(Duration::from_millis(ms.get()));
}
layer.with_jitter()
}
}

fn parse_io_timeout_ms(value: &str) -> Result<NonZeroU64> {
fn parse_positive_ms(value: &str) -> Result<NonZeroU64> {
value.parse().map_err(|error| {
Error::new(
ErrorKind::DataInvalid,
Expand Down Expand Up @@ -242,7 +295,7 @@ impl StorageFactory for OpenDalStorageFactory {
config: s3_config_parse(config.props().clone())?.into(),
multipart_part_size: s3_multipart_part_size_parse(config.props())?,
customized_credential_load: customized_credential_load.clone(),
client_config,
client_config: s3_client_config(config.props(), client_config)?,
})),
#[cfg(feature = "opendal-gcs")]
OpenDalStorageFactory::Gcs => Ok(Arc::new(OpenDalStorage::Gcs {
Expand Down Expand Up @@ -513,7 +566,7 @@ impl OpenDalStorage {
// benefits non-object-store backends.
let operator = operator
.layer(TimeoutLayer::new().with_io_timeout(self.client_config().io_timeout()))
.layer(RetryLayer::new());
.layer(self.client_config().retry_layer());
Ok((operator, relative_path))
}

Expand Down Expand Up @@ -927,6 +980,72 @@ mod tests {
);
}

fn retry_config(props: &[(&str, &str)]) -> Result<OpenDalClientConfig> {
OpenDalClientConfig::from_properties(
&props
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect::<HashMap<_, _>>(),
)
}

fn retry_layer_debug(props: &[(&str, &str)]) -> Result<String> {
Ok(format!("{:?}", retry_config(props)?.retry_layer()))
}

#[test]
fn test_retry_defaults_match_opendal() {
// `RetryLayer` has no getters, so compare through `Debug`.
assert_eq!(
retry_layer_debug(&[]).unwrap(),
format!("{:?}", RetryLayer::new())
);
assert_ne!(
format!("{:?}", RetryLayer::new()),
format!("{:?}", RetryLayer::new().with_jitter())
);
}

#[test]
fn test_retry_properties_reach_retry_layer() {
let expected = RetryLayer::new()
.with_max_times(5)
.with_min_delay(Duration::from_millis(100))
.with_max_delay(Duration::from_millis(200))
.with_jitter();
assert_eq!(
retry_layer_debug(&[
(OPENDAL_RETRY_NUM_RETRIES, "5"),
(OPENDAL_RETRY_MIN_WAIT_MS, "100"),
(OPENDAL_RETRY_MAX_WAIT_MS, "200"),
])
.unwrap(),
format!("{expected:?}")
);

// any one property switches jitter on and leaves the others at OpenDAL's defaults
assert_eq!(
retry_layer_debug(&[(OPENDAL_RETRY_NUM_RETRIES, "0")]).unwrap(),
format!("{:?}", RetryLayer::new().with_max_times(0).with_jitter())
);
}

#[test]
fn test_retry_property_parsing_rejects_invalid_values() {
for (key, invalid) in [
(OPENDAL_RETRY_NUM_RETRIES, "-1"),
(OPENDAL_RETRY_NUM_RETRIES, "abc"),
(OPENDAL_RETRY_NUM_RETRIES, ""),
(OPENDAL_RETRY_MIN_WAIT_MS, "0"),
(OPENDAL_RETRY_MIN_WAIT_MS, "abc"),
(OPENDAL_RETRY_MAX_WAIT_MS, "0"),
(OPENDAL_RETRY_MAX_WAIT_MS, "12.5"),
] {
let err = retry_config(&[(key, invalid)]).unwrap_err().to_string();
assert!(err.contains(key), "{key}={invalid}: {err}");
}
}

#[cfg(feature = "opendal-s3")]
#[test]
fn test_client_config_serde_round_trip() {
Expand Down
2 changes: 1 addition & 1 deletion crates/storage/opendal/src/resolving.rs
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ fn build_storage_for_scheme(
config: Arc::new(config),
multipart_part_size: crate::s3::s3_multipart_part_size_parse(props)?,
customized_credential_load: customized_credential_load.clone(),
client_config,
client_config: crate::s3::s3_client_config(props, client_config)?,
})
}
#[cfg(feature = "opendal-gcs")]
Expand Down
Loading
Loading