You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
1.10.1 (dropdown selection). The defective declaration is byte-identical in 1.11.0 and on main at the time of writing.
Query engine
Spark (dropdown selection) — Structured Streaming on Spark 3.5.3. The defect is not Spark-specific;
it needs only more than 100 concurrent S3FileIO instances in one JVM with AAL enabled.
Everything else, in Iceberg and in AAL, is at its default — in particular AAL's physicalio.thread.pool.size (96 threads per factory), max.memory.limit (2 GB per factory) and small.objects.prefetching.enabled (true). The reproduction bundle points s3.endpoint at a local
MinIO instance; nothing depends on that choice.
2.29.52 (affected production path used BOM 2.42.13)
software.amazon.awssdk.crt:aws-crt
0.43.4 — not required; reproduced with s3.crt.enabled=false
com.github.ben-manes.caffeine:caffeine
3.1.8 in the reproduction
JDK
Corretto 17.0.15; also eclipse-temurin:17
Kubernetes (containerised runs)
kind node image kindest/node:v1.35.0
S3 endpoint for reproduction
MinIO RELEASE.2025-04-22T22-12-26Z
Please describe the bug 🐞
In one line: with the S3 Analytics Accelerator enabled, an application that holds more than 100 S3FileIO instances in one JVM will silently stop making progress — reads hang forever instead of
failing, so nothing crashes and nothing alerts.
With s3.analytics-accelerator.enabled=true, a JVM holding more than 100 live S3FileIO
instances starts closing AAL reader thread pools that other threads are actively reading through.
The reads do not fail loudly — they hang forever — so the application appears healthy while doing
no work.
What was observed
A Spark Structured Streaming driver running several dozen concurrent queries, each with its own
Spark session and therefore its own catalog and S3FileIO:
The Spark application stayed RUNNING; the driver was healthy.
About three-quarters of the driver's queries were absent from the Spark UI. They hung before query.start() registered them with the StreamingQueryManager, so they were not shown as
failed — they were not shown at all.
No exception, no crash, no retry, no application-level alert.
Source lag grew silently for several hours.
The only evidence visible from inside the application was AAL telemetry: tens of thousands of [failure] lines, all on *.metadata.json reads, all RejectedExecutionException. None before
AAL was enabled, none after it was disabled.
A representative line:
[failure] block.manager.make.range.available(generation=0, thread_id=<n>, range=8000-15999,
etag="<etag>", uri=s3://<bucket>/<table>/metadata/<n>.metadata.json,
range.effective=8000-73535): <duration> ns
[java.util.concurrent.RejectedExecutionException: 'Task … rejected from
ThreadPoolExecutor@…[Shutting down, pool size = 1, active threads = 1, completed tasks = 0]']
Thread dump of a hung reader (from the attached reproduction):
Query A thread factory cache Factory F Query Z thread
(holds factory F) (static, max 100) (96 readers) (another query)
| | | |
1 |---- get(key A) ---> | |
<---- factory F ----| | |
| A begins reading table metadata through F |
| | | |
2 | <---------------------------------|
| get(key Z) -- the 101st distinct key |
| past maximumSize(100), so Caffeine must evict something.
| It picks factory F. Query A is never consulted. |
| | | |
3 | |=================> |
| removalListener -> close() -> shutdown() |
| | | |
4 |------- read() its next range -------> |
5 <===== RejectedExecutionException ====| |
| AAL leaves its read buffer registered but unfilled, then
| waits on it with no timeout -> parks forever |
| | | |
| query.start() is never reached, so the query is never
| registered: no UI entry, no metrics, no crash, no retry
v
time
1. The key is object identity, so an entry can never be shared across S3FileIO instances.
To be precise, because this cuts both ways: within one S3FileIO the cache works as intended. S3InputFile.fromLocation passes client.s3Async() and client.s3FileIOProperties()
(S3InputFile.java:127-135), PrefixedS3Client holds the properties as a private final field and
memoises the async client (PrefixedS3Client.java:33,37,97-106), and org.apache.iceberg.util.Pair
delegates equals/hashCode to its components — so every read after the first through the same S3FileIO hits.
What cannot happen is sharing between instances. PrefixedS3Client constructs a fresh S3FileIOProperties per instance (PrefixedS3Client.java:50) and builds its own S3AsyncClient, and neither type overrides equals/hashCode. So two S3FileIO instances with
byte-identical configuration are always two entries, and the number of entries equals the number of
live S3FileIO instances. The bound therefore acts as a population limit on S3FileIO, which is
not what a reader of this code would expect a read-path cache to be.
2. The removal listener cannot distinguish who initiated the removal. It closes the factory for everyRemovalCause. That is correct for EXPLICIT — the invalidate() that cleanupCache()
performs when the owning S3FileIO closes. It is unsafe for SIZE, which Caffeine decides on its
own, with no knowledge of whether a caller obtained that factory from get() moments earlier and is
mid-read.
3. close() is destructive and immediate.S3SeekableInputStreamFactory.close() ends in threadPool.shutdown(), so every subsequent read submitted by any holder is rejected.
What AAL then does with that rejection is why this surfaces as a hang rather than an error, and it is
worth one sentence of gloss since it is another project's internals: AAL registers its read buffer before submitting the work that fills it, the submit fails, nothing marks the buffer as failed, and a
reader waiting on that buffer waits on a signal that will never come — with no timeout
(Block.awaitData()). That half is filed separately as awslabs/analytics-accelerator-s3#369, with a reproduction that uses
no Iceberg at all. This issue is about Iceberg closing a shared resource that callers still
hold.
Why S3FileIO instance count scales with concurrency
This is worth stating because "more than 100 S3FileIO in one JVM" sounds unlikely until you look at
how engines create them:
FileIO lifetime in Iceberg is effectively per-TableOperations; FileIOTracker closes it only
when the TableOperations is collected.
In Spark, each session gets its own CatalogManager and therefore its own catalog plugin
instances. SparkSession.newSession() passes parentSessionState = None
(SparkSession.scala:251-258), and CatalogManager holds its own catalogs map
(CatalogManager.scala:48-54).
Independently of that, Spark's StreamExecution constructor does sparkSession.cloneSession() (StreamExecution.scala:197, v3.5.3), so every streaming query
gets its own session — and its own catalogs and S3FileIO — whether or not the application forks
sessions itself.
So an application with N concurrent streaming queries and M AAL-enabled catalogs holds on the order
of N × M live S3FileIO instances. A few dozen queries across two catalogs already exceeds the
bound of 100; applications with hundreds of queries are far past it.
Evidence
The cache behaviour, driven directly
The reproduction inline at the bottom of this issue loads the real STREAM_FACTORY_CACHE out of the shipped iceberg-aws-1.10.1 jar by reflection — nothing about the cache is re-implemented. No AWS account, no
network, ~45 s:
130 S3FileIO-equivalent lookups -> factories created = 130 <-- no cross-instance sharing
cache size after 130 inserts = 100
first factory still referenced by caller? yes
first factory still IN the cache? false
first factory's reader pool isShutdown() = true
Then a read through that still-referenced factory hangs indefinitely (stack above).
Controlled experiment — the bound is the variable
Against a real S3 endpoint, readers held constant at 130 so thread count, socket count and
endpoint load are identical across rows; the only variable is the number of distinct S3FileIO
instances, i.e. distinct cache keys:
AAL
Readers
Distinct S3FileIO
OK
Threw
Hung
disabled
130
130
130
0
0
enabled
130
99
130
0
0
enabled
130
130
100
20
10
enabled
130
1
130
0
0
In the broken row exactly 100 readers succeed — the value of maximumSize.
Real Spark, separate executor JVMs
To check the executor side, a further harness runs Spark 3.5.3 in local-cluster[2,8,3072] (two genuinely separate executor JVMs), 260 tasks, each task carrying its
own FileIO serialized as task data — the way SerializableTable ships a table's FileIO to the
executors that read it. The FileIO is built through CatalogUtil.loadFileIO(io-impl, props, conf).
io-impl
Executor JVMs
Distinct FileIO per JVM
Reads OK
S3FileIO
2
125 / 135
260
a JVM-singleton FileIO (see workarounds)
2
1 / 1
260
Two conclusions. The instance multiplication is real on executors too — 125–135 cache keys per JVM.
But note the "Reads OK" column: the hang did not reproduce on the executor side, even well past
the bound, and raising executor cores from 2 to 8 did not change that. An executor's read is
short-lived, so eviction tends to close a factory whose reader has already returned. The driver is
exposed because it holds many concurrent, long-lived reads while the cache churns underneath them.
So on executors this manifests as resource cost rather than the hang.
Proposed fix
Options, worst to best:
Give S3FileIOProperties value equality. Collapses one half of the key, but the S3AsyncClient half is still per-instance, so cardinality barely changes. Insufficient.
Reference-count the cached factory and close only when the last stream closes. Correct in
principle, but callers routinely abandon streams to the GC, so a naive refcount may never reach
zero. Would need Cleaner/phantom-reference backup in a hot path.
Bind the factory's lifetime to the owner's, not to a cache bound. One factory per S3AsyncClient, created with it and closed with it. PrefixedS3Client already owns exactly that
lifecycle and already calls cleanupCache on close. No size bound is needed, because the
population is then bounded by the number of clients. Recommended.
There is a precedent in this repository
Of the twelve files in apache/iceberg that attach a Caffeine removalListener, I inspected the
nine distinct ones. AnalyticsAcceleratorUtil is the only one that pairs a hard maximumSize
bound with closing a shared resource in the listener.
Cache
Eviction driven by
Closes a resource on removal?
AnalyticsAcceleratorUtil
hard maximumSize(100)
yes
io.FileIOTracker
weakKeys() — reachability
yes, but eviction implies nobody holds the key
rest.auth.AuthSessionCache
expireAfterAccess
yes — time-driven, re-creatable resource
hive.CachedClientPool
expireAfter
yes — time-driven
io.ContentCache
softValues + expiry
no
CachingCatalog
soft/weak + expiry
no
spark.SparkExecutorCache
size + expiry
no
ManifestFiles
size + weak/soft
no
Every other close-on-removal cache in the project is driven by reachability or time, never by
a count — because a count carries no information about whether anyone is still using the value. FileIOTracker's weakKeys() pattern is the in-repo model for option 4.
A minimal interim change
If a full lifetime redesign is too large for a point release, the smallest correct-direction change
is to discriminate on RemovalCause — close on EXPLICIT/REPLACED (owner-initiated) and not on SIZE. I have that patch and it does stop the hang (verified: 130 readers / 130 keys → 130 ok,
0 failed, 0 hung, against 100/20/10 unpatched). But I want to be straight about the cost: it
converts the hang into exactly the leak described in option 2, so it is a stopgap, not the fix.
I have working patches for both the interim change and the AAL-side fixes (the latter attached to awslabs/analytics-accelerator-s3#369), and can share them or open a PR.
Related existing issues
CachingCatalog does not close FileIO on cache eviction, causing S3FileIO / SDK v2 thread leak in long-running applications #15898 (open) — CachingCatalog does not close FileIO on cache eviction, causing S3FileIO /
SDK v2 thread leak in long-running applications. This is the mirror image of the same missing
concept: there, FileIO instances proliferate and are never closed; here, they are closed too
eagerly, by the wrong party. Both point at the absence of a clear owner for FileIO and factory
lifetime, and I would suggest they be considered together.
AWS: Close the S3SeekableInputStreamFactory before removing from cache #12891 — AWS: Close the S3SeekableInputStreamFactory before removing from cache (merged
2025-05-26) introduced the removalListener. It was fixing a genuine leak; the PR body does not
discuss which removal causes are safe to close on, which is the gap.
AWS: Integrate S3 analytics accelerator library #12299 — the original AAL integration, which introduced the cache with maximumSize(100) and
the identity key. There is no stated rationale for the bound in the PR body, commit message, or
review discussion. Worth noting that a reviewer asked for wider review at the time — "I would like more eyes on this PR since AWS FileIO has a pretty big blast radius. Have you posted
this on the iceberg devlist?" — and it was not taken to the dev list.
Epic #14350 (Turn S3 Analytics Accelerator on by default) was closed not_planned by a stale
bot with the "Default On" item unticked — it stalled rather than being decided against. AAL is
default-off in 1.10.1, 1.11.0 and main, so today the blast radius is opt-in users. If that epic
is revived before this is fixed, the failure ships to every S3FileIO user.
Workarounds for anyone hitting this now
Workaround
Effect
Set s3.analytics-accelerator.enabled=false on the catalog
Prevents. Complete and immediate.
Share one S3FileIO per JVM via a delegating io-impl (see below)
Prevents: one cache key, so eviction never fires. Measured clean at 130 concurrent readers, 1 FileIO identity per executor JVM, and faster than the AAL-disabled baseline.
Enable AAL on only one catalog, or only on applications with few concurrent queries
Reduces probability only. Establishes no invariant and does not survive scaling up.
No effect. These are per-factory resource knobs; neither reads the cache bound, which is a private static final literal.
Set cache-enabled=true on the catalog
No effect. CachingCatalog caches Table objects, not FileIO instances.
Disable AAL small-object prefetching
Do not. Measured worse: converts thrown errors into silent hangs (0 thrown / 30 hung vs 20 / 10).
Notes on the shared-FileIO workaround
I have a working implementation of this. Four
things it must get right, each of which fails silently if missed:
readResolve() returning the JVM singleton. Without it, every task that deserializes the FileIO builds its own delegate and the multiplication returns per executor with no error.
Verified: 1 identity and 1 delegate per executor JVM across 2 JVMs and 260 tasks.
A no-op close(). Iceberg closes a FileIO per table/broadcast lifecycle; if that closed the
shared delegate, one table finishing would break every other reader.
Every capability interface.S3FileIO implements DelegateFileIO, SupportsRecoveryOperations and SupportsStorageCredentials, and Iceberg probes these with instanceof. A wrapper missing one silently loses the capability — for example bulk delete
quietly degrading to per-file.
Vended credentials are a hard blocker. A JVM-wide delegate can only hold one credential set,
so this is unsafe for REST catalogs that vend per-prefix credentials. The reference
implementation throws from setCredentials rather than silently applying one catalog's credentials
to another. Use it only for statically-credentialed catalogs.
Also worth stating: this is mitigation by staying under an undocumented library constant, not a fix.
It needs an invariant test on live instance count or it regresses the next time an application scales
up.
Detection, since the failure is silent: alert on the AAL telemetry failure line
(block.manager.make.range.available together with failure), which has a zero baseline in a healthy
system. Do not rely on application health signals — they are emitted by the component that hung.
Willingness to contribute
I can contribute a fix for this bug independently
Patches for both the interim RemovalCause change and the AAL-side fixes have been compiled and
tested against the released artifacts. I would welcome direction on which
of the four options above the maintainers prefer before opening a PR, since option 4 touches PrefixedS3Client lifetime and is a larger change than a point fix.
AI Disclosure
Model: Claude Opus 4.6
Platform/Tool: Claude Code
Human Oversight: fully reviewed
Prompt Summary: Investigate a production incident in which streaming queries silently failed to
start with the S3 Analytics Accelerator enabled; identify root cause from pinned sources, build
runnable reproductions, verify candidate patches, and draft an upstream bug report.
Full reproduction source — IcebergCacheMre.java, drives the real static cache by reflection
packagesoftware.amazon.s3.analyticsaccelerator;
importcom.github.benmanes.caffeine.cache.Cache;
importjava.io.ByteArrayInputStream;
importjava.io.IOException;
importjava.lang.reflect.Field;
importjava.util.HashMap;
importjava.util.Map;
importjava.util.concurrent.ExecutorService;
importjava.util.concurrent.atomic.AtomicInteger;
importorg.apache.iceberg.aws.s3.S3FileIOProperties;
importorg.apache.iceberg.util.Pair;
importsoftware.amazon.awssdk.services.s3.S3AsyncClient;
importsoftware.amazon.s3.analyticsaccelerator.request.GetRequest;
importsoftware.amazon.s3.analyticsaccelerator.request.HeadRequest;
importsoftware.amazon.s3.analyticsaccelerator.request.ObjectClient;
importsoftware.amazon.s3.analyticsaccelerator.request.ObjectContent;
importsoftware.amazon.s3.analyticsaccelerator.request.ObjectMetadata;
importsoftware.amazon.s3.analyticsaccelerator.util.OpenStreamInformation;
importsoftware.amazon.s3.analyticsaccelerator.util.S3URI;
/** * Minimal reproduction of the ICEBERG half of the defect: {@code AnalyticsAcceleratorUtil}'s static, * identity-keyed, {@code maximumSize(100)} factory cache closes an {@code * S3SeekableInputStreamFactory} on size eviction, i.e. while a caller still holds and uses it. * * <p>Drives the REAL static cache out of the shipped {@code iceberg-aws} jar by reflection — nothing * about the cache is re-implemented or simulated. No AWS account, no network, no credentials. * * <p>Lives in AAL's package only so it can read the package-private {@code getThreadPool()} accessor * to show that an evicted factory's pool has been shut down. * * <p>The consequence of that shutdown — a read that hangs forever rather than failing — is a separate * AAL defect, reproduced independently by {@code AalMre} with no Iceberg on the classpath at all. * * <p>Usage: {@code IcebergCacheMre [broken|fixed]}. Exits non-zero on unmet expectations. */publicfinalclassIcebergCacheMre {
privatestaticfinalintOBJECT_LEN = 128 * 1024;
privatestaticStringmode = "broken";
privatestaticfinaljava.util.List<String> FAILURES = newjava.util.ArrayList<>();
privatestaticbooleanfixed() {
return"fixed".equals(mode);
}
publicstaticvoidmain(String[] args) throwsException {
if (args.length > 0) {
mode = args[0];
}
System.out.println("iceberg factory-cache reproduction; expectation mode = " + mode);
step1IdentityKeys();
step2CacheEvictsAndClosesLiveFactory();
banner("SUMMARY");
if (FAILURES.isEmpty()) {
System.out.println(" ALL EXPECTATIONS MET for mode=" + mode);
Runtime.getRuntime().halt(0);
}
System.out.println(" UNMET EXPECTATIONS for mode=" + mode + ":");
FAILURES.forEach(f -> System.out.println(" - " + f));
Runtime.getRuntime().halt(1);
}
privatestaticvoidstep1IdentityKeys() {
banner("STEP 1 cache key is identity, so distinct S3FileIO instances never share an entry");
Map<String, String> props = newHashMap<>();
props.put("s3.analytics-accelerator.enabled", "true");
// PrefixedS3Client does exactly this, once per S3FileIO: `new S3FileIOProperties(properties)`.S3FileIOPropertiesa = newS3FileIOProperties(props);
S3FileIOPropertiesb = newS3FileIOProperties(props);
S3AsyncClientclient = fakeAsyncClient();
Pair<S3AsyncClient, S3FileIOProperties> k1 = Pair.of(client, a);
Pair<S3AsyncClient, S3FileIOProperties> k2 = Pair.of(client, b);
System.out.println(" same properties map -> a.equals(b) = " + a.equals(b));
System.out.println(" same async client -> k1.equals(k2) = " + k1.equals(k2));
System.out.println(" => two S3FileIO instances with IDENTICAL config are two distinct keys.");
System.out.println(" (within ONE S3FileIO the key components are the same objects, so"
+ " repeat reads DO hit -- the cache is only unshareable ACROSS instances.)");
check(!a.equals(b), "S3FileIOProperties has no value equality, so two instances are two keys");
check(!k1.equals(k2), "cache keys must be distinct");
}
privatestaticvoidstep2CacheEvictsAndClosesLiveFactory() throwsException {
banner("STEP 2 Iceberg's real STREAM_FACTORY_CACHE closes a factory that is still in use");
Class<?> util = Class.forName("org.apache.iceberg.aws.s3.AnalyticsAcceleratorUtil");
Fieldf = util.getDeclaredField("STREAM_FACTORY_CACHE");
f.setAccessible(true);
Cache<Pair<S3AsyncClient, S3FileIOProperties>, S3SeekableInputStreamFactory> cache =
(Cache<Pair<S3AsyncClient, S3FileIOProperties>, S3SeekableInputStreamFactory>) f.get(null);
System.out.println(" loaded " + util.getName() + "#STREAM_FACTORY_CACHE from iceberg-aws-1.10.1");
// A tiny thread pool per factory keeps the MRE cheap; production default is 96// (PhysicalIOConfiguration.DEFAULT_THREAD_POOL_SIZE).AtomicIntegercreated = newAtomicInteger();
S3SeekableInputStreamFactoryfirst = null;
Pair<S3AsyncClient, S3FileIOProperties> firstKey = null;
for (inti = 0; i < 130; i++) {
Pair<S3AsyncClient, S3FileIOProperties> key =
Pair.of(fakeAsyncClient(), newS3FileIOProperties(newHashMap<>()));
S3SeekableInputStreamFactoryfactory =
cache.get(
key,
k -> {
created.incrementAndGet();
returnnewFactory();
});
if (i == 0) {
first = factory;
firstKey = key;
}
}
cache.cleanUp(); // force Caffeine's pending maintenance so eviction is deterministic hereSystem.out.println(" 130 S3FileIO-equivalent lookups -> factories created = " + created.get());
System.out.println(" cache size after 130 inserts = " + cache.estimatedSize());
System.out.println(" first factory still referenced by caller? yes");
System.out.println(" first factory still IN the cache? " + (cache.getIfPresent(firstKey) != null));
ExecutorServicepool = first.getThreadPool();
System.out.println(" first factory's reader pool isShutdown() = " + pool.isShutdown());
check(created.get() == 130,
"130 distinct S3FileIO-equivalents must produce 130 entries (no cross-instance sharing)");
check(cache.estimatedSize() <= 100, "cache must be bounded at maximumSize(100)");
if (fixed()) {
booleanok = !pool.isShutdown();
System.out.println((ok ? " [PASS] " : " [FAIL] ")
+ "size eviction must NOT close a factory the caller still holds");
if (!ok) FAILURES.add("evicted factory was closed despite the Iceberg removal-cause patch");
System.out.println(" => the caller's factory survives eviction; its pool is still usable.");
} else {
check(pool.isShutdown(),
"the removal listener must have closed a factory the caller still holds");
System.out.println(" => a caller holding this factory now submits into a shut-down pool.");
}
}
privatestaticS3SeekableInputStreamFactorynewFactory() {
returnnewS3SeekableInputStreamFactory(
newFakeObjectClient(), S3SeekableInputStreamConfiguration.DEFAULT);
}
privatestaticS3AsyncClientfakeAsyncClient() {
return (S3AsyncClient)
java.lang.reflect.Proxy.newProxyInstance(
AalMre.class.getClassLoader(),
newClass<?>[] {S3AsyncClient.class},
(proxy, method, methodArgs) -> {
if ("hashCode".equals(method.getName())) returnSystem.identityHashCode(proxy);
if ("equals".equals(method.getName())) returnproxy == methodArgs[0];
if ("toString".equals(method.getName())) return"fake-s3-async";
returnnull;
});
}
privatestaticfinalclassFakeObjectClientimplementsObjectClient {
@OverridepublicObjectMetadataheadObject(HeadRequestr, OpenStreamInformationi) {
returnObjectMetadata.builder().contentLength(OBJECT_LEN).etag("etag-1").build();
}
@OverridepublicObjectContentgetObject(GetRequestr, OpenStreamInformationi) {
returnObjectContent.builder().stream(newByteArrayInputStream(newbyte[OBJECT_LEN])).build();
}
@Overridepublicvoidclose() throwsIOException {}
}
privatestaticvoidbanner(Strings) {
System.out.println();
System.out.println("================================================================");
System.out.println(s);
System.out.println("================================================================");
}
privatestaticvoidcheck(booleancond, Stringwhat) {
System.out.println((cond ? " [PASS] " : " [FAIL] ") + what);
if (!cond) thrownewAssertionError(what);
}
}
Apache Iceberg version
1.10.1(dropdown selection). The defective declaration is byte-identical in1.11.0and onmainat the time of writing.Query engine
Spark (dropdown selection) — Structured Streaming on Spark
3.5.3. The defect is not Spark-specific;it needs only more than 100 concurrent
S3FileIOinstances in one JVM with AAL enabled.Catalog configuration used
Everything else, in Iceberg and in AAL, is at its default — in particular AAL's
physicalio.thread.pool.size(96 threads per factory),max.memory.limit(2 GB per factory) andsmall.objects.prefetching.enabled(true). The reproduction bundle pointss3.endpointat a localMinIO instance; nothing depends on that choice.
Full version set
org.apache.iceberg:iceberg-aws,iceberg-core1.10.1(also1.11.0,main)software.amazon.s3.analyticsaccelerator:analyticsaccelerator-s31.3.1org.apache.spark:spark-sql_2.123.5.3software.amazon.awssdk(s3, kms, sts, glue, dynamodb)2.29.52(affected production path used BOM2.42.13)software.amazon.awssdk.crt:aws-crt0.43.4— not required; reproduced withs3.crt.enabled=falsecom.github.ben-manes.caffeine:caffeine3.1.8in the reproduction17.0.15; alsoeclipse-temurin:17kindnode imagekindest/node:v1.35.0RELEASE.2025-04-22T22-12-26ZPlease describe the bug 🐞
In one line: with the S3 Analytics Accelerator enabled, an application that holds more than 100
S3FileIOinstances in one JVM will silently stop making progress — reads hang forever instead offailing, so nothing crashes and nothing alerts.
With
s3.analytics-accelerator.enabled=true, a JVM holding more than 100 liveS3FileIOinstances starts closing AAL reader thread pools that other threads are actively reading through.
The reads do not fail loudly — they hang forever — so the application appears healthy while doing
no work.
What was observed
A Spark Structured Streaming driver running several dozen concurrent queries, each with its own
Spark session and therefore its own catalog and
S3FileIO:RUNNING; the driver was healthy.query.start()registered them with theStreamingQueryManager, so they were not shown asfailed — they were not shown at all.
[failure]lines, all on*.metadata.jsonreads, allRejectedExecutionException. None beforeAAL was enabled, none after it was disabled.
A representative line:
Thread dump of a hung reader (from the attached reproduction):
The sequence, in order
Root cause in Iceberg
aws/src/main/java/org/apache/iceberg/aws/s3/AnalyticsAcceleratorUtil.java:47-55:Three properties combine badly:
1. The key is object identity, so an entry can never be shared across
S3FileIOinstances.To be precise, because this cuts both ways: within one
S3FileIOthe cache works as intended.S3InputFile.fromLocationpassesclient.s3Async()andclient.s3FileIOProperties()(
S3InputFile.java:127-135),PrefixedS3Clientholds the properties as aprivate finalfield andmemoises the async client (
PrefixedS3Client.java:33,37,97-106), andorg.apache.iceberg.util.Pairdelegates
equals/hashCodeto its components — so every read after the first through the sameS3FileIOhits.What cannot happen is sharing between instances.
PrefixedS3Clientconstructs a freshS3FileIOPropertiesper instance (PrefixedS3Client.java:50) and builds its ownS3AsyncClient, and neither type overridesequals/hashCode. So twoS3FileIOinstances withbyte-identical configuration are always two entries, and the number of entries equals the number of
live
S3FileIOinstances. The bound therefore acts as a population limit onS3FileIO, which isnot what a reader of this code would expect a read-path cache to be.
2. The removal listener cannot distinguish who initiated the removal. It closes the factory for
every
RemovalCause. That is correct forEXPLICIT— theinvalidate()thatcleanupCache()performs when the owning
S3FileIOcloses. It is unsafe forSIZE, which Caffeine decides on itsown, with no knowledge of whether a caller obtained that factory from
get()moments earlier and ismid-read.
3.
close()is destructive and immediate.S3SeekableInputStreamFactory.close()ends inthreadPool.shutdown(), so every subsequent read submitted by any holder is rejected.What AAL then does with that rejection is why this surfaces as a hang rather than an error, and it is
worth one sentence of gloss since it is another project's internals: AAL registers its read buffer
before submitting the work that fills it, the submit fails, nothing marks the buffer as failed, and a
reader waiting on that buffer waits on a signal that will never come — with no timeout
(
Block.awaitData()). That half is filed separately as awslabs/analytics-accelerator-s3#369, with a reproduction that usesno Iceberg at all. This issue is about Iceberg closing a shared resource that callers still
hold.
Why
S3FileIOinstance count scales with concurrencyThis is worth stating because "more than 100
S3FileIOin one JVM" sounds unlikely until you look athow engines create them:
FileIOlifetime in Iceberg is effectively per-TableOperations;FileIOTrackercloses it onlywhen the
TableOperationsis collected.CatalogManagerand therefore its own catalog plugininstances.
SparkSession.newSession()passesparentSessionState = None(
SparkSession.scala:251-258), andCatalogManagerholds its owncatalogsmap(
CatalogManager.scala:48-54).StreamExecutionconstructor doessparkSession.cloneSession()(StreamExecution.scala:197, v3.5.3), so every streaming querygets its own session — and its own catalogs and
S3FileIO— whether or not the application forkssessions itself.
So an application with N concurrent streaming queries and M AAL-enabled catalogs holds on the order
of N × M live
S3FileIOinstances. A few dozen queries across two catalogs already exceeds thebound of 100; applications with hundreds of queries are far past it.
Evidence
The cache behaviour, driven directly
The reproduction inline at the bottom of this issue loads the real
STREAM_FACTORY_CACHEout of the shippediceberg-aws-1.10.1jar by reflection — nothing about the cache is re-implemented. No AWS account, nonetwork, ~45 s:
Then a read through that still-referenced factory hangs indefinitely (stack above).
Controlled experiment — the bound is the variable
Against a real S3 endpoint, readers held constant at 130 so thread count, socket count and
endpoint load are identical across rows; the only variable is the number of distinct
S3FileIOinstances, i.e. distinct cache keys:
S3FileIOIn the broken row exactly 100 readers succeed — the value of
maximumSize.Real Spark, separate executor JVMs
To check the executor side, a further harness runs Spark
3.5.3inlocal-cluster[2,8,3072](two genuinely separate executor JVMs), 260 tasks, each task carrying itsown
FileIOserialized as task data — the waySerializableTableships a table'sFileIOto theexecutors that read it. The
FileIOis built throughCatalogUtil.loadFileIO(io-impl, props, conf).io-implFileIOper JVMS3FileIOFileIO(see workarounds)Two conclusions. The instance multiplication is real on executors too — 125–135 cache keys per JVM.
But note the "Reads OK" column: the hang did not reproduce on the executor side, even well past
the bound, and raising executor cores from 2 to 8 did not change that. An executor's read is
short-lived, so eviction tends to close a factory whose reader has already returned. The driver is
exposed because it holds many concurrent, long-lived reads while the cache churns underneath them.
So on executors this manifests as resource cost rather than the hang.
Proposed fix
Options, worst to best:
S3FileIOPropertiesvalue equality. Collapses one half of the key, but theS3AsyncClienthalf is still per-instance, so cardinality barely changes. Insufficient.96 reader threads, a maintenance thread and a 2 GB blob-store budget, released only by
cleanupCache. This trades a liveness bug for a leak, and the leak already has an open report(CachingCatalog does not close FileIO on cache eviction, causing S3FileIO / SDK v2 thread leak in long-running applications #15898, below). Not recommended.
principle, but callers routinely abandon streams to the GC, so a naive refcount may never reach
zero. Would need
Cleaner/phantom-reference backup in a hot path.S3AsyncClient, created with it and closed with it.PrefixedS3Clientalready owns exactly thatlifecycle and already calls
cleanupCacheon close. No size bound is needed, because thepopulation is then bounded by the number of clients. Recommended.
There is a precedent in this repository
Of the twelve files in
apache/icebergthat attach a CaffeineremovalListener, I inspected thenine distinct ones.
AnalyticsAcceleratorUtilis the only one that pairs a hardmaximumSizebound with closing a shared resource in the listener.
AnalyticsAcceleratorUtilmaximumSize(100)io.FileIOTrackerweakKeys()— reachabilityrest.auth.AuthSessionCacheexpireAfterAccesshive.CachedClientPoolexpireAfterio.ContentCachesoftValues+ expiryCachingCatalogspark.SparkExecutorCacheManifestFilesEvery other close-on-removal cache in the project is driven by reachability or time, never by
a count — because a count carries no information about whether anyone is still using the value.
FileIOTracker'sweakKeys()pattern is the in-repo model for option 4.A minimal interim change
If a full lifetime redesign is too large for a point release, the smallest correct-direction change
is to discriminate on
RemovalCause— close onEXPLICIT/REPLACED(owner-initiated) and not onSIZE. I have that patch and it does stop the hang (verified: 130 readers / 130 keys → 130 ok,0 failed, 0 hung, against 100/20/10 unpatched). But I want to be straight about the cost: it
converts the hang into exactly the leak described in option 2, so it is a stopgap, not the fix.
I have working patches for both the interim change and the AAL-side fixes (the latter attached to
awslabs/analytics-accelerator-s3#369), and can share them or open a PR.
Related existing issues
SDK v2 thread leak in long-running applications. This is the mirror image of the same missing
concept: there,
FileIOinstances proliferate and are never closed; here, they are closed tooeagerly, by the wrong party. Both point at the absence of a clear owner for
FileIOand factorylifetime, and I would suggest they be considered together.
2025-05-26) introduced the
removalListener. It was fixing a genuine leak; the PR body does notdiscuss which removal causes are safe to close on, which is the gap.
maximumSize(100)andthe identity key. There is no stated rationale for the bound in the PR body, commit message, or
review discussion. Worth noting that a reviewer asked for wider review at the time —
"I would like more eyes on this PR since AWS FileIO has a pretty big blast radius. Have you posted
this on the iceberg devlist?" — and it was not taken to the dev list.
S3FileIO.close()→ AAL cleanup path.deliberately so vended credentials stay scoped, and the single-shared-client alternative (AWS: Use custom Execution interceptor to support multiple storage credentials #12827)
was closed unmerged.
Please gate the default-on work on this
Epic #14350 (Turn S3 Analytics Accelerator on by default) was closed
not_plannedby a stalebot with the "Default On" item unticked — it stalled rather than being decided against. AAL is
default-off in
1.10.1,1.11.0andmain, so today the blast radius is opt-in users. If that epicis revived before this is fixed, the failure ships to every
S3FileIOuser.Workarounds for anyone hitting this now
s3.analytics-accelerator.enabled=falseon the catalogS3FileIOper JVM via a delegatingio-impl(see below)FileIOidentity per executor JVM, and faster than the AAL-disabled baseline.s3.analytics-accelerator.physicalio.thread.pool.size/max.memory.limitprivate static finalliteral.cache-enabled=trueon the catalogCachingCatalogcachesTableobjects, notFileIOinstances.Notes on the shared-
FileIOworkaroundI have a working implementation of this. Four
things it must get right, each of which fails silently if missed:
readResolve()returning the JVM singleton. Without it, every task that deserializes theFileIObuilds its own delegate and the multiplication returns per executor with no error.Verified: 1 identity and 1 delegate per executor JVM across 2 JVMs and 260 tasks.
close(). Iceberg closes aFileIOper table/broadcast lifecycle; if that closed theshared delegate, one table finishing would break every other reader.
S3FileIOimplementsDelegateFileIO,SupportsRecoveryOperationsandSupportsStorageCredentials, and Iceberg probes these withinstanceof. A wrapper missing one silently loses the capability — for example bulk deletequietly degrading to per-file.
so this is unsafe for REST catalogs that vend per-prefix credentials. The reference
implementation throws from
setCredentialsrather than silently applying one catalog's credentialsto another. Use it only for statically-credentialed catalogs.
Also worth stating: this is mitigation by staying under an undocumented library constant, not a fix.
It needs an invariant test on live instance count or it regresses the next time an application scales
up.
Detection, since the failure is silent: alert on the AAL telemetry failure line
(
block.manager.make.range.availabletogether withfailure), which has a zero baseline in a healthysystem. Do not rely on application health signals — they are emitted by the component that hung.
Willingness to contribute
Patches for both the interim
RemovalCausechange and the AAL-side fixes have been compiled andtested against the released artifacts. I would welcome direction on which
of the four options above the maintainers prefer before opening a PR, since option 4 touches
PrefixedS3Clientlifetime and is a larger change than a point fix.AI Disclosure
start with the S3 Analytics Accelerator enabled; identify root cause from pinned sources, build
runnable reproductions, verify candidate patches, and draft an upstream bug report.
Full reproduction source —
IcebergCacheMre.java, drives the real static cache by reflection