Repository navigation
Conversation
xanderbailey
left a comment
There was a problem hiding this comment.
Onboard with adding more configuration options here but mild concerns about their placement. Let me know what you think!
| 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"; |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Yes! I think this makes sense to me!
There was a problem hiding this comment.
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.
| /// S3 retries. | ||
| #[property(key = S3_RETRY_NUM_RETRIES, default = None)] | ||
| s3_retry_num_retries: Option<usize>, | ||
| /// S3 delay before the first retry in milliseconds. | ||
| #[property( | ||
| key = S3_RETRY_MIN_WAIT_MS, | ||
| default = None, | ||
| parse_with = parse_positive_ms | ||
| )] | ||
| s3_retry_min_wait_ms: Option<NonZeroU64>, | ||
| /// S3 upper bound of the delay between two retries in milliseconds. | ||
| #[property( | ||
| key = S3_RETRY_MAX_WAIT_MS, | ||
| default = None, | ||
| parse_with = parse_positive_ms | ||
| )] | ||
| s3_retry_max_wait_ms: Option<NonZeroU64>, |
There was a problem hiding this comment.
These are S3-specific so I don't think they belong on OpenDalClientConfig
|
|
||
| /// The retry policy, with jitter; OpenDAL's default when no key is set. Each setting is resolved on its own: | ||
| /// the `s3.retry.*` key (S3 only), then the `opendal.retry.*` key, then OpenDAL's default. | ||
| pub(crate) fn retry_layer(&self, s3: bool) -> RetryLayer { |
There was a problem hiding this comment.
If the S3 overrides are resolved into the client config when the S3 storage is built, this can drop the s3: bool flag and just build the layer from opendal.retry.* fields, which by then already hold the right values for each backend.
| } | ||
|
|
||
| /// Only S3 reads the `s3.retry.*` properties; the other backends read `opendal.retry.*`. | ||
| fn retry_layer(&self) -> RetryLayer { |
There was a problem hiding this comment.
With the overrides resolved at build time this per-variant match isn't needed; create_operator can call self.client_config().retry_layer() the same way it does for io_timeout().
|
Thanks for picking up the layering so quickly! The precedence behaviour is what I was after, but I think I explained the placement badly: I didn't mean for the What I had in mind:
Then Also, the title and description still say only S3 applies retry settings and the other backends keep OpenDAL's default, which isn't true anymore with |
The S3 operator was always wrapped in `RetryLayer::new()`: 3 retries, a 1 s first delay, no jitter. A throttled bucket (S3 `SlowDown`) answers a burst of requests faster than that policy gives up, and the error reaches the caller after about 7 s. Define Iceberg Java's `s3.retry.num-retries`, `s3.retry.min-wait-ms` and `s3.retry.max-wait-ms` in `iceberg::io` so every S3 backend can read the same keys, and apply them to the opendal S3 operator. Setting any of them also turns on jitter; with none set the layer stays `RetryLayer::new()`. Generated-by: Claude Code (Sonnet 5.5)
…ties `opendal.retry.num-retries`, `opendal.retry.min-wait-ms` and `opendal.retry.max-wait-ms` configure the `RetryLayer` of every backend, in the place where the `TimeoutLayer` is applied. For S3 each `s3.retry.*` key overrides the matching `opendal.retry.*` key, resolved per key; unset keeps OpenDAL's default. Any set key turns jitter on. Generated-by: Claude Code (Sonnet 5.5)
…e new cases Restore the four tests of a767ae1 as they were (their S3-only semantics still hold) and add only the generic-keys and per-key precedence cases; `s3_retry_layer` stays as a thin wrapper of `retry_layer(true)`. Generated-by: Claude Code (Sonnet 5.5)
… the S3 storage Parse the s3.retry.* properties next to the other s3.* properties and overlay them, per key, onto the opendal.retry.* settings of the S3 variant's client config. OpenDalClientConfig no longer knows about S3, so retry_layer takes no backend flag and other backends neither parse nor serialize s3.retry.*. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FhHg5U2mpkFVSMgVtmhThk
a7efbbb to
7048c0b
Compare
Thanks, that's clearer. Reworked it that way: Tested locally beyond the unit tests:
|
Which issue does this PR close?
What changes are included in this PR?
Problem. Every opendal operator is wrapped in
RetryLayer::new(): 3 retries starting at 1 s, factor 2, no jitter. Nothing exposes it (#3263 made the per-operation timeout configurable, not the retry policy). High-throughput writers hit this. One example is a streaming writer into Amazon S3 Tables: a backfill flushing hundreds of partitions at once, or dozens of writers creating files in new tables in one table bucket. S3 answers503 SlowDownwhile it scales the prefix up, which is expected behaviour, and AWS's guidance is to retry with exponential backoff and jitter. With the fixed policy:The only workaround today is a custom
StorageFactorythat re-implements the opendal storage to change one layer.Change. Two layers of retry keys, resolved per key:
opendal.retry.num-retries,opendal.retry.min-wait-msandopendal.retry.max-wait-mslive onOpenDalClientConfig, next toopendal.io-timeout-ms, and apply to every opendal backend.s3.retry.num-retries,s3.retry.min-wait-msands3.retry.max-wait-mshave the names and meaning of Iceberg Java'sS3FileIO, so a catalog configuration written for Java works unchanged. They are defined iniceberg::ionext to the otherS3_*constants, parsed in the opendals3module, and only the S3 storage reads them.OpenDalStorageFactory::S3or the resolving factory), eachs3.retry.*key that is set overrides the matchingopendal.retry.*value. The order iss3.retry.*, thenopendal.retry.*, then OpenDAL's default. Other backends don't parse or validates3.retry.*.RetryLayer(with_max_times,with_min_delay,with_max_delay). Setting any key also enables jitter: Java has no jitter key, and the AWS SDK always jitters.RetryLayer::new(), so existing users see no change.Example, retrying S3 requests for up to about 2.5 minutes:
Are these changes tested?
Unit tests in
iceberg-storage-opendal:RetryLayer::new();opendal.retry.*ands3.retry.*values (andnum-retries = 0) reach the layer, with jitter on;s3.retry.*wins per key, and a key set at only one layer is taken from that layer;s3.retry.*, even an invalid value, and still appliesopendal.retry.*.cargo fmt,clippy -D warningsand the tests oficeberg(io) andiceberg-storage-opendalpass. An earlier revision ran end to end against real S3 Tables through FileIO. Not tested against a real throttled store.AI Disclosure
Drafted with Claude Code; I reviewed the change and ran fmt, clippy and the tests of the two crates. Points worth a look:
num-retriesmay be 0;min-wait-msgreater thanmax-wait-msis not rejected (OpenDAL's backoff just caps at the maximum);s3.retry.*checks the serialized storage for the resolved value, sinceArc<dyn Storage>can't be downcast;parse_io_timeout_msis renamedparse_positive_ms, because it now also validates the waits.