Skip to content

Align storage backend with upstream's crates/storage/opendal - #80

Open
gbrgr wants to merge 8 commits into
mainfrom
gb/align-storage-with-upstream
Open

Align storage backend with upstream's crates/storage/opendal#80
gbrgr wants to merge 8 commits into
mainfrom
gb/align-storage-with-upstream

Conversation

@gbrgr

@gbrgr gbrgr commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

Summary

Migrates the fork off its old, pre-refactor in-tree OpenDAL storage implementation (crates/iceberg/src/io/storage/opendal/) onto upstream's own crates/storage/opendal crate, which the fork already had as a workspace member (pulled in by the recent upstream merge) but never actually used. Net -2,767 lines.

Goal: align as closely as possible with upstream so future git merge upstream/main runs don't keep re-touching this area — the old tree survived the last merge untouched (nothing to conflict with, since upstream deleted its own copy) and had silently drifted out of sync until a dependency bump broke it.

What changed

  1. adls.allow-anonymous ported onto iceberg-storage-opendal (this was added to the old tree very recently, in feat(io): support opt-in anonymous access for ADLS/Azure Blob storage #79 — same feature, just relocated to the new crate's azdls.rs/lib.rs/resolving.rs).
  2. PROP_TABLE_IDENT/PROP_METADATA_LOCATION in crates/iceberg widened from pub(crate) to pub, needed by (4) below.
  3. New crate, crates/storage/refreshable (iceberg-storage-refreshable): replaces the old accessor-level credential-refresh wrapper (RefreshableAccessor/RefreshableStorage, ~1,363 lines, implementing opendal's raw Service/oio::Read traits directly against old-tree internals) with a much simpler ~600-line call-level Storage wrapper built entirely against iceberg's public Storage/StorageFactory traits and iceberg-storage-opendal's public API. Every Storage method is retried once on any error: credentials refresh via the existing (already upstream-shared) StorageCredentialsLoader, the backend rebuilds, the call retries. Concurrency-safe (double-checked locking via a version counter + async refresh lock), unit-tested including a "concurrent failures trigger exactly one refresh" case.
  4. Deleted crates/iceberg/src/io/storage/opendal/ and the old refresh files entirely. crates/iceberg/Cargo.toml now matches upstream exactly — zero opendal/reqsign/reqsign-aws-v4/reqsign-core dependencies, no storage-* features.
  5. Fixed the one remaining in-workspace reference (a crates/catalog/rest test) and stale doc comments.

Preserved vs. intentionally dropped

  • S3 vended credentials (CustomAwsCredentialLoader): the old bridge had zero in-repo callers and iceberg-storage-opendal already ships its own native, cleaner equivalent (wraps reqsign_core::ProvideCredential directly) — not ported, just superseded.
  • Scheme-agnostic routing (OpenDalRoutingStorageFactory): subsumed by upstream's own OpenDalResolvingStorageFactory, now the default inside RefreshableStorageFactory.
  • Intentionally dropped: the old tree's strict Azdls scheme-match validation (path scheme must exactly equal what was configured at build time). iceberg-storage-opendal has no equivalent and accepts any abfs*/wasb* scheme uniformly. No on-record correctness/security reason for the strict check was found; confirmed with the team before dropping it.

Verification

All green: cargo check --workspace --all-features --all-targets, make check-fmt check-clippy cargo-machete check-toml check-public-api, cargo test --workspace --all-features (unit + doc tests).

🤖 Generated with Claude Code

gbrgr and others added 6 commits September 2, 2026 16:05
Phase 1 of migrating the fork onto upstream's crates/storage/opendal
crate structure. Ports the ADLS anonymous-access opt-in (originally
added in #79, on the old in-tree
crates/iceberg/src/io/storage/opendal/azdls.rs) onto the new crate.

opendal's Azdls service has no anonymous/unsigned mode of its own,
unlike its S3 service's skip_signature. adls.allow-anonymous routes to
opendal's generic Http service (unauthenticated GET+Range/HEAD)
instead, scoped to the same account/container endpoint. Opt-in only,
never inferred from "no credentials configured", since that can also
legitimately mean "rely on ambient Azure credentials".

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Phase 2 of migrating the fork onto upstream's crates/storage/opendal
crate structure. These were pub(crate), reachable only from inside
crates/iceberg where RefreshableStorageFactory used to live. The
replacement (a new, external crates/storage/refreshable crate, built
only against public APIs) needs to read them from StorageConfig::props
to recover table identity/metadata location for credential lookups.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Phase 3 of migrating the fork onto upstream's crates/storage/opendal
crate structure. Replaces the fork's old accessor-level credential-
refresh wrapper (RefreshableAccessor/RefreshableStorage, ~1363 lines,
implementing opendal's raw Service/oio::Read traits directly against
the old in-tree OpenDalStorage) with a much simpler, call-level
Storage wrapper built entirely against iceberg's public Storage/
StorageFactory traits and iceberg-storage-opendal's public API.

RefreshableStorage wraps any injected Arc<dyn StorageFactory>
(defaulting to OpenDalResolvingStorageFactory, which resolves the
backend per call from the path's scheme, so no separate scheme
tracking is needed at this layer). Every Storage method is retried at
most once on any error: credentials are refreshed via the existing,
upstream-shared StorageCredentialsLoader, the backend is rebuilt, and
the call is retried against the fresh backend. Retrying on any error
(rather than a narrower auth-specific heuristic) is exact behavioral
parity with the old design, which had no error-kind filter either --
and is the only practical option anyway, since
iceberg-storage-opendal's from_opendal_error flattens every
opendal::Error to a single ErrorKind::Unexpected. delete_stream is not
retried (its input Stream can't be safely replayed after partial
consumption).

Concurrency safety (double-checked locking via a credential version
counter + an async refresh lock) is ported near-as-is from the old
design. Unit-tested against a fake, backend-agnostic Storage/
StorageFactory pair -- no opendal or network needed -- including a
concurrent-failures-trigger-exactly-one-refresh test, a coverage gap
the old design's tests never had.

Lives in its own workspace crate rather than inside crates/iceberg
(where all of upstream's own churn concentrates) so this fork-only
functionality never conflicts with future `git merge upstream/main`
runs.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Phase 4 of migrating the fork onto upstream's crates/storage/opendal
crate structure. Now that Phase 3 (iceberg-storage-refreshable) covers
every fork-only feature the old tree provided, delete it entirely:

- crates/iceberg/src/io/storage/opendal/ (the fork's pre-refactor
  OpenDAL-based Storage/StorageFactory implementations for S3/GCS/OSS/
  Azdls/fs/memory -- superseded by iceberg-storage-opendal).
- crates/iceberg/src/io/{refreshable_accessor,refreshable_storage}.rs
  (superseded by crates/storage/refreshable).

crates/iceberg/Cargo.toml now matches upstream exactly: no opendal,
reqsign, reqsign-aws-v4, or reqsign-core dependencies, no storage-*
features. crates/iceberg/src/error.rs drops its
From<opendal::Error>/From<reqsign::Error> blanket conversions, no
longer needed now that nothing in this crate uses `?` on either.

A behavioral note: the old Azdls storage enforced that a path's scheme
(abfss/abfs/wasbs/wasb) exactly match what was configured at build
time; iceberg-storage-opendal has no equivalent check and accepts any
of those schemes uniformly. No on-record correctness/security reason
for the strict check was found, so this is intentionally not ported --
confirmed with the user.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Phase 5 of migrating the fork onto upstream's crates/storage/opendal
crate structure. Fixes up the one remaining in-workspace reference to
the deleted old-tree RefreshableStorageFactory, and stale doc-comment
cross-references left over from the same move.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ction

Audit against the deleted old tree found a real gap: the old
RefreshableAccessor wrapped the *returned* opendal reader itself
(RetryableReader), so every subsequent .read() call on a long-lived
reader got credential-refresh-and-retry protection for the reader's
whole lifetime. This crate's reader() only retried the call that
constructed the Box<dyn FileRead> -- a reader obtained once and then
used for many range reads (e.g. a Parquet scan, the primary scenario
this whole mechanism exists to protect) got zero retry protection
after the first successful construction.

Fixed by having reader() return a RefreshableReader wrapper that
resolves the current backend and constructs a fresh inner reader on
every .read() call, retrying once on failure exactly like every other
Storage method. This adds no real cost (Storage::reader() was already
exactly this cheap per call) and no lock (range reads are naturally
idempotent, so per-call re-resolution needs no shared mutable state,
unlike write()).

writer() is deliberately NOT given the same treatment and now has a
doc comment explaining why: a writer is frequently stateful (e.g. a
multipart upload), so replaying a failed write against a
freshly-constructed writer could silently drop already-written bytes.
This asymmetry matches the old design's own already-accepted lack of
write retry.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@gbrgr

gbrgr commented Sep 3, 2026

Copy link
Copy Markdown
Collaborator Author

Re-audited this migration against the deleted old tree (every backend's config/operator logic — S3/GCS/OSS/Azdls/fs/memory — plus the refresh mechanism) to make sure nothing fork-specific was silently dropped.

Result: all backend config/operator-building logic is clean — no gaps. One real gap found and fixed (pushed as 7f4d5d2): the old RefreshableAccessor wrapped the returned reader itself, so every subsequent .read() call on a long-lived reader got credential-refresh-and-retry protection, not just the call that constructed it. iceberg-storage-refreshable's reader() initially only retried construction — missing exactly the scenario (a long Parquet scan spanning a credential expiry window) this mechanism exists to protect. Fixed by wrapping the returned FileRead so every .read() call gets the same treatment; writer() is deliberately left as-is (retrying a failed write against a fresh writer isn't safe for stateful/multipart writers — documented in the code).

@gbrgr
gbrgr marked this pull request as ready for review September 3, 2026 09:58
…overage

Continuing the audit against the deleted old tree, found two more gaps:

1. Behavioral: the old design's retry error message referenced both the
   retry failure AND the original failure ("Original error: ..."), so a
   confusing retry-side error (e.g. NotFound after a successful refresh)
   didn't silently discard why a retry was attempted in the first place.
   This crate's retry paths discarded the original error entirely. Fixed
   via a shared refresh_and_retry() helper used by every Storage method
   and by RefreshableReader::read, which attaches the original error as
   `with_context("original_error", ...)` on the final error whenever the
   retry itself also fails.

2. Test coverage: the old tree had tests this crate had no equivalent
   for -- an end-to-end write-then-read-back roundtrip through a real
   backend (test_refreshable_operator_can_write_and_read), and coverage
   proving RefreshableStorageFactory::build() actually parses
   PROP_METADATA_LOCATION/PROP_TABLE_IDENT out of props and threads them
   to the credentials loader (test_refreshable_storage_factory_with_
   initial_credentials_creates_refreshable only checked build() didn't
   error, not that the values were used correctly). Added
   write_and_read_roundtrip_through_real_backend (using the real
   iceberg-storage-opendal Memory backend, not a fake) and
   factory_build_parses_table_ident_and_location_from_props, plus
   retry_failure_preserves_original_error_as_context for (1).

A few other old tests (test_build_from_props_memory,
test_build_from_props_s3_never_creates_refreshable,
test_routing_factory_routes_to_*, test_routing_factory_errors_on_missing_
location, test_do_refresh_rebuilds_inner_storage) were confirmed
structurally obsolete, not dropped coverage: they tested either the old
enum-variant-based OpenDalStorage::Refreshable design (replaced by a
standalone Storage impl) or OpenDalRoutingStorageFactory's build-time
scheme resolution (replaced by OpenDalResolvingStorage's per-call
resolution, which needs no location prop and so can't error on a
missing one).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@gbrgr

gbrgr commented Sep 3, 2026

Copy link
Copy Markdown
Collaborator Author

Did a systematic test-coverage diff too (every `#[test]`/`#[tokio::test]` function name in the deleted old tree vs. the new one). Found two more real gaps in `iceberg-storage-refreshable`, both fixed (pushed as `d6a08e9`):

  1. Error context loss: the old design's retry-failure error message referenced both the retry's failure and the original failure ("Original error: ..."), so a confusing retry-side error didn't silently discard why a retry was even attempted. My rewrite discarded the original error entirely on a failed retry. Fixed via a shared `refresh_and_retry()` helper (used by every method) that attaches the original error as context on the final error when the retry also fails.
  2. Missing test coverage: no end-to-end write-then-read roundtrip through a real backend (the old suite had one), and no test proving `RefreshableStorageFactory::build()` actually parses `PROP_METADATA_LOCATION`/`PROP_TABLE_IDENT` out of props and threads them to the credentials loader (only that `build()` didn't error). Added both.

A handful of other old tests (`test_build_from_props_`, `test_routing_factory_`, `test_do_refresh_rebuilds_inner_storage`) were confirmed structurally obsolete rather than dropped coverage — they tested the old enum-variant/build-time-scheme-resolution design that no longer exists by construction, not a behavior that still needs proving.

`iceberg-storage-refreshable` now has 9 unit tests (up from 6), all passing, plus clippy/fmt clean.

Careful re-read of the old refreshable_accessor.rs (RetryableReader)
surfaced a real performance regression in the read-retry fix from the
previous commit: reconstructing a fresh inner reader via
backend.reader(path) on *every* .read() call also rebuilds the whole
opendal Operator from config every time (confirmed: every Storage
method call does this). For a single call that's fine and matches
every other method's cost profile, but reader() returns a long-lived
handle meant to be read from many times (e.g. once per row group in a
Parquet scan) -- rebuilding the Operator on every one of those reads
multiplies that cost by the read count instead of paying it once,
unlike the old design.

The old RetryableReader avoided exactly this by holding one inner
reader behind a tokio::sync::RwLock, read-locked (not serialized) on
the happy path so concurrent range reads share it, and only
write-locked briefly to swap in a freshly-rebuilt reader after a
failure. RefreshableReader now does the same: reader() builds the
initial inner reader once (with its own refresh-and-retry on
construction failure), and .read() reuses it across calls, only
refreshing and rebuilding on an actual failure.

Added reader_reuses_inner_reader_across_successful_reads (a
regression test using a FakeStorage that now counts reader()
construction calls) to lock this in -- the existing
reader_retries_individual_read_calls_not_just_construction test still
passes unchanged, since the observable retry/refresh behavior is
identical; only the internal reuse strategy changed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@gbrgr

gbrgr commented Sep 3, 2026

Copy link
Copy Markdown
Collaborator Author

Did another careful pass — this time a full line-by-line re-read of the old `mod.rs` (factory/enum dispatch, `create_operator`, `Storage` impl) and `refreshable_accessor.rs` (the raw opendal `Service` impl, `RetryableReader`), not just the test-name diff from before. Found and fixed one more real issue (pushed as `7f9abe1`):

Performance regression: my `RefreshableReader::read()` was reconstructing the inner reader — which means rebuilding the entire `opendal::Operator` from config — on every single `.read()` call. That's fine for a one-shot operation, but a reader is typically obtained once and read from many times (e.g. once per row group in a Parquet scan), so this multiplied the rebuild cost by the read count instead of paying it once. The old `RetryableReader` avoided this by holding one inner reader behind a lock, shared (read-locked, not serialized) across concurrent reads on the happy path, and only rebuilding on an actual failure. `RefreshableReader` now does the same. Added a regression test (`reader_reuses_inner_reader_across_successful_reads`) that counts backend-rebuild calls to lock this in — the existing retry-behavior test is unaffected since the observable retry semantics didn't change, only the internal reuse strategy.

Also re-confirmed, via direct reading (not just delegated audit): every backend's `create_operator`/`Storage` impl match arms account for all fork behavior (including the deliberate, already-approved `RetryLayer`-skip-for-Refreshable latency tradeoff), S3's `configured_scheme` concept is actually superseded by the new crate's per-call scheme echoing (more general, not a gap), and `create_dir`/`rename`/`presign`/`capability`/`info` (retried in the old opendal-`Service`-level design) have no equivalent in `iceberg`'s own `Storage` trait at all — never reachable above that abstraction boundary, so nothing to port there.

`iceberg-storage-refreshable` is now at 10 unit tests, all green, clippy/fmt clean, full workspace check clean.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant