whua3 opened a new issue, #11303:
URL: https://github.com/apache/gravitino/issues/11303
### Version
main branch
### Describe what's wrong
### Title
[Bug][gvfs] FileSystem instance can be closed while in use, causing
RejectedExecutionException on long-running writes (Spark batch, etc.)
### Component(s)
clients/filesystem-hadoop3 (GravitinoVirtualFileSystem)
### Affected versions
0.7.x ~ main
### Describe the bug
`BaseGVFSOperations.internalFileSystemCache` is a Caffeine cache of
`(filesetIdent, locationName) -> FileSystem` configured with:
- `expireAfterAccess(evictionMillsAfterAccess)` (default **1 hour**)
- a `removalListener` that calls **`fileSystem.close()`** on eviction
- a dedicated `ScheduledThreadPoolExecutor("gvfs-filesystem-cache-cleaner")`
to proactively trigger eviction even without traffic
```java
// BaseGVFSOperations.java#newFileSystemCache
.removalListener((key, value, cause) -> {
FileSystem fs = (FileSystem) value;
if (fs != null) {
try { fs.close(); } catch (IOException e) { ... }
}
})
.expireAfterAccess(evictionMillsAfterAccess, TimeUnit.MILLISECONDS);
```
The cache's "access" is **only** updated when callers go through
`internalFileSystemCache.get(...)` (i.e.
`getActualFileSystemByLocationName`).
Once a caller has obtained a `FileSystem` handle and continues to use it
through long-lived streams (e.g. `FSDataOutputStream` on object stores),
**no further `cache.get(...)` happens**, so the entry's last-access time
is frozen at stream-open time.
If the stream remains open longer than `evictionMillsAfterAccess`, the
cache evicts the entry and the `removalListener` calls `fs.close()` while
the user code still holds a live reference and is actively writing to it.
For object-store implementations this shuts down internal IO thread pools,
so the next async upload submitted by the open stream throws
`RejectedExecutionException`.
### Reproduce
A Spark job that writes Parquet to a `gvfs://` path backed by `cosn`/`s3a`
and whose per-task duration exceeds 1 hour (default eviction).
After ~1h all in-flight write tasks fail with:
```
java.util.concurrent.RejectedExecutionException:
The bounded io thread pool has been shutdown
at
org.apache.hadoop.fs.CosNFileSystem$1.rejectedExecution(CosNFileSystem.java:209)
at
org.apache.hadoop.fs.CosNFSDataOutputStream$MultipartUpload.uploadPartAsync(...)
at org.apache.hadoop.fs.CosNFSDataOutputStream.uploadCurrentPart(...)
at org.apache.hadoop.fs.CosNFSDataOutputStream.write(...)
at org.apache.parquet.hadoop.ParquetFileWriter.writeColumnChunk(...)
...
Suppressed: java.io.IOException:
The file being written is in an invalid state. ... Current state: COLUMN
```
The same problem affects any long-running reader/writer:
- Spark Structured Streaming sinks that hold an output stream across batches
- Hive/Trino INSERT producing many large files in a single task
- Flink stateful sinks
- Any user code that obtains `FileSystem` once and uses it for a long time
### Why this is a design issue, not just a config tuning issue
Bumping `fs.gravitino.fileset.cache.evictionMillsAfterAccess` or
`maxCapacity` only **reduces the probability** of hitting this. The root
problem is that **eviction is not synchronized with active usage**: the
cache has no idea whether anyone still holds a reference to the cached
`FileSystem`. As long as the cache feels free to call `close()` while a
user holds the handle, the bug exists.
This is also inconsistent with how Hadoop's own `FileSystem.CACHE`
behaves: Hadoop never proactively closes a cached `FileSystem` based on
time; it only closes on `FileSystem.closeAll()` / `closeAllForUGI` /
explicit `close()`. GVFS introduced an `expireAfterAccess +
removalListener.close()` policy that has no analogue in Hadoop and is
the source of this race.
### Prior art
This exact race was first reported in **#3928** (June 2024) with the
same symptom ("Filesystem to be closed while the stream is still working").
It was "resolved" by **#3932**, which only increased the default
`evictionMillsAfterAccess` from 5 minutes to 1 hour - a 3-line change
that does not fix the underlying race, only enlarges the window. Any
long-running task whose per-task duration exceeds the new default still
hits the same bug; we reproduced it on Spark batch jobs writing Parquet
to `cosn://` in May 2026 (single task duration > 1h).
For reference, the **same anti-pattern was later replicated server-side**
in **#7782** (`FilesetCatalogOperations` introduced its own
`Caffeine.expireAfterAccess(1h) + removalListener.close()` cache,
August 2025). That instance is out of scope for this PR but should be
fixed in a follow-up using the same approach.
### Proposed fix
Bring GVFS back in line with Hadoop's own `FileSystem.CACHE` semantics:
**a cached `FileSystem` is never proactively closed by the cache.** Its
lifecycle is bound to the owning `BaseGVFSOperations` instance, not to
the cache's access pattern.
Concretely:
1. Remove the `fs.close()` call from the cache's `removalListener`.
Eviction (size or time based) only detaches the entry from the cache
map; the underlying `FileSystem` continues to be usable by anyone
still holding a reference to it.
2. Maintain a separate set of all `FileSystem` instances ever created
by this operations instance, so that `BaseGVFSOperations#close()`
can close them all at the unambiguous end of the GVFS lifecycle
(Spark App shutdown, explicit close, etc.) -- including those that
have already been evicted from the cache.
3. The cache continues to provide its primary value: avoiding the cost
of repeatedly creating expensive `FileSystem` instances for the same
`(filesetIdent, locationName)` key. It just no longer doubles as a
resource-lifecycle controller.
This is a **~30-line change**, fully backward compatible, and follows
the same model that Hadoop has used successfully for over a decade.
### Backward compatibility
- Default `evictionMillsAfterAccess` and `maxCapacity` values unchanged.
- Cache hit/miss semantics unchanged; existing tests pass without
modification (the cache still returns the same `FileSystem` for the
same key as before).
- The only observable change: an evicted entry's underlying `FileSystem`
is **not** closed at eviction time; instead it is closed when the
owning `BaseGVFSOperations` is closed.
### Trade-offs (called out explicitly for reviewers)
- The cache becomes a "fast lookup index" rather than a "hard upper
bound on resources". For long-running GVFS instances that access a
very large number of distinct `(filesetIdent, locationName)` tuples,
evicted-but-not-yet-closed `FileSystem`s accumulate until shutdown.
- **In practice this is a non-issue**: a single Spark/Flink/Hive
application typically reads/writes from O(1)-O(10) filesets, well
below the default `maxCapacity=20`. Operators with extreme cardinality
can size `maxCapacity` accordingly; they will simply observe more
resident FS instances in exchange for stream-safety.
- This trade-off is exactly the one Hadoop made in its own
`FileSystem.CACHE` — and that hasn't been a problem in 15 years.
### Alternatives considered
1. **Just increase the default eviction time to 24h.** Mitigates but
does not fix; any task longer than the new value still breaks.
**This is exactly what #3932 did in 2024 (5min → 1h) and it did not
prevent the regression we are reporting here.**
2. **Disable eviction altogether.** Equivalent to setting
`evictionMillsAfterAccess` to `Long.MAX_VALUE`; users can already do
this today, but it is opt-in and most users hit the bug before
discovering they need to.
3. **Reference counting (`RefCountedFileSystem` + acquire/release).**
Solves the bug but introduces ~250 lines of new code, requires
wrapping every returned `FSDataOutputStream`/`FSDataInputStream`
(risk of breaking `Syncable`/`StreamCapabilities` interfaces),
and silently leaks if any caller forgets to close a stream. Overkill
for the actual problem; the lifecycle-bound approach in this PR is
cleaner and matches Hadoop's own choice.
4. **Lock during operations.** Doesn't help, because the user holds the
`FSDataOutputStream` long after the operation method returns.
### Willingness to contribute
I'd like to implement this. Will follow up with a PR.
### Error message and/or stacktrace
When `BaseGVFSOperations` is shared across threads, an `IOException:
Filesystem closed` (HDFS) /
`RejectedExecutionException` (object stores) is thrown intermittently during
normal read/write
calls. Example stack:
org.apache.spark.SparkException: [TASK_WRITE_FAILED] Task failed while
writing rows to gvfs://fileset/xxxxxx/xxxxxx/xxxxxxx/20260423.
at
org.apache.spark.sql.errtingQueryExecutionErrors$.taskFailedWhileWritingRowsError(QueryExecutionErrors.scala:775)
at
org.apache.spark.sql.execution.datasources.FileFormatWriter$.executeTask(FileFormatWriter.scala:469)
at
org.apache.spark.sql.execution.datasources.WriteFilesExec.$anonfun$doExecuteWrite$1(WriteFiles.scala:100)
at
org.apache.spark.rdd.RDD.$anonfun$mapPartitionsInternal$2(RDD.scala:893)
at
org.apache.spark.rdd.RDD.$anonfun$mapPartitionsInternal$2$adapted(RDD.scala:893)
at
org.apache.spark.rdd.MapPartitionsRDD.compute(MapPartitionsRDD.scala:52)
at org.apache.spark.rdd.RDD.computeOrReadCheckpoint(RDD.scala:367)
at org.apache.spark.rdd.RDD.iterator(RDD.scala:331)
at org.apache.spark.scheduler.ResultTask.runTask(ResultTask.scala:94)
at
org.apache.spark.TaskContext.runTaskWithListeners(TaskContext.scala:166)
at org.apache.spark.scheduler.Task.run(Task.scala:146)
at
org.apache.spark.executor.Executor$TaskRunner.$anonfun$run$4(Executor.scala:628)
at
org.apache.spark.util.SparkErrorUtils.tryWithSafeFinally(SparkErrorUtils.scala:64)
at
org.apache.spark.util.SparkErrorUtils.tryWithSafeFinally$(SparkErrorUtils.scala:61)
at org.apache.spark.util.Utils$.tryWithSafeFinally(Utils.scala:94)
at org.apache.spark.executor.Executor$TaskRunner.run(Executor.scala:631)
at
java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1128)
at
java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:628)
at java.base/java.lang.Thread.run(Thread.java:829)
Caused by: java.util.concurrent.RejectedExecutionException: The bounded io
thread pool has been shutdown
at
org.apache.hadoop.fs.CosNFileSystem$1.rejectedExecution(CosNFileSystem.java:209)
at
java.base/java.util.concurrent.ThreadPoolExecutor.reject(ThreadPoolExecutor.java:825)
at
java.base/java.util.concurrent.ThreadPoolExecutor.execute(ThreadPoolExecutor.java:1355)
at
com.google.common.util.concurrent.MoreExecutors$ListeningDecorator.execute(MoreExecutors.java:484)
at
java.base/java.util.concurrent.AbstractExecutorService.submit(AbstractExecutorService.java:140)
at
com.google.common.util.concurrent.AbstractListeningExecutorService.submit(AbstractListeningExecutorService.java:58)
at
org.apache.hadoop.fs.CosNFSDataOutputStream$MultipartUpload.uploadPartAsync(CosNFSDataOutputStream.java:560)
at
org.apache.hadoop.fs.CosNFSDataOutputStream.uploadCurrentPart(CosNFSDataOutputStream.java:435)
at
org.apache.hadoop.fs.CosNFSDataOutputStream.write(CosNFSDataOutputStream.java:163)
at
org.apache.hadoop.fs.FSDataOutputStream$PositionCache.write(FSDataOutputStream.java:62)
at java.base/java.io.DataOutputStream.write(DataOutputStream.java:107)
at
java.base/java.io.FilterOutputStream.write(FilterOutputStream.java:108)
at
org.apache.parquet.hadoop.util.HadoopPositionOutputStream.write(HadoopPositionOutputStream.java:45)
at
org.apache.parquet.bytes.ConcatenatingByteArrayCollector.writeAllTo(ConcatenatingByteArrayCollector.java:46)
at
org.apache.parquet.hadoop.ParquetFileWriter.writeColumnChunk(ParquetFileWriter.java:903)
at
org.apache.parquet.hadoop.ParquetFileWriter.writeColumnChunk(ParquetFileWriter.java:848)
at
org.apache.parquet.hadoop.ColumnChunkPageWriteStore$ColumnChunkPageWriter.writeToFileWriter(ColumnChunkPageWriteStore.java:310)
at
org.apache.parquet.hadoop.ColumnChunkPageWriteStore.flushToFileWriter(ColumnChunkPageWriteStore.java:458)
at
org.apache.parquet.hadoop.InternalParquetRecordWriter.flushRowGroupToStore(InternalParquetRecordWriter.java:186)
at
org.apache.parquet.hadoop.InternalParquetRecordWriter.close(InternalParquetRecordWriter.java:124)
at
org.apache.parquet.hadoop.ParquetRecordWriter.close(ParquetRecordWriter.java:164)
at
org.apache.spark.sql.execution.datasources.parquet.ParquetOutputWriter.close(ParquetOutputWriter.scala:41)
at
org.apache.spark.sql.execution.datasources.FileFormatDataWriter.releaseCurrentWriter(FileFormatDataWriter.scala:74)
at
org.apache.spark.sql.execution.datasources.FileFormatDataWriter.releaseResources(FileFormatDataWriter.scala:129)
at
org.apache.spark.sql.execution.datasources.FileFormatDataWriter.commit(FileFormatDataWriter.scala:159)
at
org.apache.spark.sql.execution.datasources.SingleDirectoryDataWriter.commit(FileFormatDataWriter.scala:240)
at
org.apache.spark.sql.execution.datasources.FileFormatWriter$.$anonfun$executeTask$1(FileFormatWriter.scala:453)
at
org.apache.spark.util.Utils$.tryWithSafeFinallyAndFailureCallbacks(Utils.scala:1397)
at
org.apache.spark.sql.execution.datasources.FileFormatWriter$.executeTask(FileFormatWriter.scala:459)
... 17 more
Root cause: the internal Caffeine cache `internalFileSystemCache` calls
`FileSystem.close()` in
its eviction listener (size-based / time-based). If thread A is currently
using the cached
`FileSystem` instance while thread B triggers an eviction (cache full,
`expireAfterAccess` fired,
or `invalidate()` from a refresh path), the underlying FileSystem is closed
mid-call, breaking
thread A's in-flight I/O.
This is a classic "use-after-free" pattern on a pooled resource without
reference counting.
### How to reproduce
+ Which Gravitino version to use: `main` (reproduced on commit <1.0.0>),
also affects all released versions where `internalFileSystemCache` calls
`fs.close()`
in the eviction listener.
Reproduction steps (concurrent eviction race):
1. Build a `GravitinoVirtualFileSystem` instance and configure a small cache:
- fs.gravitino.fileset.cache.maxCapacity = 2
- fs.gravitino.fileset.cache.expireAfterAccess = 1s
(or use the default; both expiry and capacity-based eviction reproduce
the issue)
2. From multiple threads, repeatedly access more than 2 distinct filesets so
that the
cache is constantly evicting entries, e.g.:
ExecutorService pool = Executors.newFixedThreadPool(8);
for (int i = 0; i < 1000; i++) {
final int idx = i % 5; // 5 distinct filesets, cache capacity = 2 ->
frequent eviction
pool.submit(() -> {
Path p = new Path("gvfs://fileset/catalog/schema/fs_" + idx +
"/data.txt");
try (FSDataInputStream in = gvfs.open(p)) {
in.read(new byte[1024]);
}
});
}
3. Within seconds you will observe `IOException: Filesystem closed` from
threads whose
cached FileSystem was concurrently evicted (and `close()`d) by another
thread's lookup.
Expected behavior:
- An in-flight FileSystem operation must never see the underlying FileSystem
closed by the cache.
- Eviction should defer the actual `close()` until no caller is using the
instance
(reference-counted close).
Actual behavior:
- Eviction listener calls `fs.close()` immediately, racing with concurrent
users
-> `Filesystem closed` / `RejectedExecutionException`.
Fix proposal:
- Wrap each cached `FileSystem` in a reference-counted holder
(acquire on cache hit, release after use).
- Eviction listener marks the holder as "evicted" but only physically closes
when
refCount drops to 0.
- Provide a config switch `fs.gravitino.fileset.cache.closeOnEviction`
(default `false` = safe ref-counted close, `true` = legacy behavior) for
backward compatibility.
I have a working fix locally and will open a PR linking to this issue.
### Additional context
Affected component: clients/filesystem-hadoop3
(`internalFileSystemCache`).
Will be fixed by an upcoming PR introducing a reference-counted FileSystem
holder.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]