Conversation
Every native Iceberg scan or write task built its own FileIO, and since iceberg-rust creates the storage client lazily per instance, every task also opened and tore down its own client: an S3 client, or with hdfs-native a NameNode session. Spark's Hadoop layer shares one client per executor JVM. load_file_io now serves clones from a per-executor cache and only builds on a miss. The key is everything that shapes the client: access mode, catalog name, the full reference path, and the whole catalog property bag. The path is in the key because the S3 access bridge is constructed with the bucket and path of the reference location, so a bridge built for one table must not serve another. memory:/// is never cached, the map clears past 64 entries, and release_runtime clears it with the runtime. Closes apache#6105 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Review of apache#6106 found that the bound cleared the whole cache, including the entry a running query was using, and dropped every FileIO while holding the lock, where the last clone of an S3 access bridge releases JNI global refs. The cache is now a small LRU that evicts one entry per insert, and evicted or replaced FileIOs are dropped after the lock is released. The builder is injected into cached_file_io so tests can count builds: a repeated identical load builds once, a load without a key builds every time, memory:/// never enters the cache, and eviction removes the least recently used entry. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
Review of the first revision found three problems, all addressed in the latest push:
|
…lise A read whose configured S3 access provider fails to initialise falls back to opendal's default chain. Caching that FileIO made the fallback sticky for every later task on the executor. storage_factory_for and build_file_io now report whether the build is cacheable, and a degraded build is returned without being inserted so the next task retries. Also splits the S3 operator cache follow-up into apache#6109 and updates two comments that still described per-task lifetimes. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
Second review round, three findings, all addressed in the latest push:
|
andygrove
left a comment
There was a problem hiding this comment.
Thanks for the careful per-backend breakdown in the description. It makes the scope of this change much easier to reason about.
HDFS support (#5898) isn't on main yet, so today the gain comes only from the S3 factory and bridge construction. CometS3CredentialDispatcher.ensureInitialized already caches the provider JVM-side via KEY_TO_HANDLE.computeIfAbsent, so the per-task bridge cost is two new_string calls, two global refs and a map lookup. The provider isn't re-initialised. Do you have measurements showing the per-task FileIO build cost matters for S3 on its own? If not, would it make sense to land this together with #5898 or #6109, where the cache is clearly needed?
| const FILE_IO_CACHE_CAPACITY: usize = 64; | ||
|
|
||
| /// Least recently used entries are evicted first. | ||
| struct FileIoCache { |
There was a problem hiding this comment.
A cached CometS3CredentialBridge now keeps its dispatcher handle for the executor's lifetime, where before each task got a fresh one from ensureInitialized. If CometS3CredentialDispatcher.closeAll() runs, every cached bridge will fail on getCredentialsForPath with a missing-handle error and never recover. Could we either clear FILE_IO_CACHE from closeAll, or document that closeAll only runs at JVM shutdown? CometS3CredentialDispatcherTest does call it directly.
| } | ||
| } | ||
|
|
||
| #[derive(Clone, Debug, PartialEq, Eq, Hash)] |
There was a problem hiding this comment.
The key carries the whole catalog property bag, which can include vended s3.access-key-id, s3.secret-access-key and s3.session-token. Could we drop Debug here, or implement a redacting one, so a future {:?} can't leak credentials into logs?
|
|
||
| /// Shared per executor so tasks reuse one FileIO: its factory, parsed config and access bridge, | ||
| /// plus the storage client where the backend caches operators. | ||
| static FILE_IO_CACHE: LazyLock<Mutex<FileIoCache>> = |
There was a problem hiding this comment.
With #5898 this makes the hdfs-native client and its NameNode session live for the executor's lifetime. Have you checked how that client behaves when a Kerberos ticket or delegation token expires on a long-lived connection?
Which issue does this PR close?
Closes #6105.
Rationale for this change
Every native Iceberg scan or write task builds its own
FileIO. On a production workload that was 40,317FileIOconstructions against one endpoint for a single query, each one rebuilding the storage factory, its parsed configuration and, for S3 with a custom access provider, the JNI bridge. Spark's Hadoop layer shares one client per executor JVM. Details in #6105.What changes are included in this PR?
load_file_ioiniceberg_common.rsnow serves clones of a per-executor LRU cache and builds only on a miss; the previous body becomesbuild_file_io. Clones share iceberg-rust'sArc<OnceLock<Arc<dyn Storage>>>, so all tasks on an executor share oneStorage.The key is everything that shapes the client: access mode, catalog name, the full reference path, and the whole catalog property bag. The path is included because the S3 access bridge is constructed with the bucket and
url.path()of the reference location and the JVM provider is called with exactly that pair, so a bridge built for one table must not serve another.memory:///is never cached; the write path assembles manifest bytes there per task. The cache holds 64 entries and evicts the least recently used one, dropping it after the lock is released since the last clone of aFileIOreleases JNI global refs.release_runtimedrains it with the Tokio runtime. A read whose configured S3 access provider failed to initialise falls back to opendal's default chain, as before; that build is returned but not cached, so the next task retries the provider instead of inheriting the fallback.What is actually shared, per backend
Sharing a
FileIOshares what itsStorageholds. At the pinned iceberg-rust rev that differs by backend:Storageholdss3,s3a, aliases,gs,osscreate_operatorbuilds a new opendalOperatorper file openensureInitialized. Not the client or signer: opendal builds a newSignerper operator, and HTTP pooling is already process-widehdfs(#5898)memoryfileSo for S3-family backends this PR is necessary but not sufficient for client reuse: an operator cache inside
OpenDalStorage::S3, mirroring what the HDFS backend already does, would make the sharedStoragealso share the signer and its access cache. That is an iceberg-rust change, tracked in #6109. Without an executor-levelFileIOcache such an operator cache would die with every task, which is why this lands first.How are these changes tested?
iceberg_common.rs: the key separates access mode, path, catalog and properties and isNoneformemory:///;cached_file_iobuilds once for repeated identical loads and every time without a key;load_file_io("memory:///")leaves the cache untouched; a degraded build is returned but never cached; eviction removes the least recently used entry and a re-insert replaces without evicting.cargo clippy --all-targets -p datafusion-comet -- -D warningsis clean.CometIcebergNativeSuite,CometIcebergWriteActionSuiteand the MinIO-backedIcebergReadFromS3Suite, which runs REST catalog vending with wrong access immediately before correct access against the same bucket and catalog.AI Disclosure
Drafted, implemented and tested with AI assistance (Claude Code); reviewed before submission.
🤖 Generated with Claude Code