From 28f7bf0f2f9d25ceff41d73ef0035236d4a0212c Mon Sep 17 00:00:00 2001 From: Vova Kolmakov Date: Fri, 31 Jul 2026 12:41:00 +0700 Subject: [PATCH] [common] Fix file system leaks when the Hadoop file system cache is disabled --- .../java/org/apache/paimon/fs/FileIO.java | 21 +- .../org/apache/paimon/fs/PluginFileIO.java | 37 +- .../org/apache/paimon/fs/ResolvingFileIO.java | 52 +- .../apache/paimon/fs/hadoop/HadoopFileIO.java | 93 +++- .../fs/hadoop/HadoopSecuredFileSystem.java | 23 + .../apache/paimon/fs/PluginFileIOTest.java | 53 ++ .../apache/paimon/fs/ResolvingFileIOTest.java | 79 +++ .../paimon/fs/hadoop/HadoopFileIOTest.java | 487 ++++++++++++++++++ .../hadoop/HadoopSecuredFileSystemTest.java | 21 + 9 files changed, 850 insertions(+), 16 deletions(-) create mode 100644 paimon-common/src/test/java/org/apache/paimon/fs/hadoop/HadoopFileIOTest.java diff --git a/paimon-common/src/main/java/org/apache/paimon/fs/FileIO.java b/paimon-common/src/main/java/org/apache/paimon/fs/FileIO.java index 2b0dcec3f760..846859ed2e40 100644 --- a/paimon-common/src/main/java/org/apache/paimon/fs/FileIO.java +++ b/paimon-common/src/main/java/org/apache/paimon/fs/FileIO.java @@ -270,8 +270,13 @@ default String createBlobPresignedUrl( } /** - * Override this method to empty, many FileIO implementation classes rely on static variables - * and do not have the ability to close them. + * Releases the resources this instance owns exclusively. The default is empty because many + * implementations hold nothing of their own, or reach their resources through static variables + * shared with the rest of the JVM, which they must not close. + * + *

Override it only for resources that belong to this instance alone, and make the override + * idempotent. Implementations that delegate to another {@link FileIO} should forward the call, + * otherwise the delegate can never be released. */ @Override default void close() throws IOException {} @@ -655,10 +660,16 @@ static FileIOLoader checkAccess(FileIOLoader fileIO, Path path, CatalogContext c return null; } - // check access + // check access, the probe is thrown away afterwards so it has to be released here: with + // the Hadoop file system cache disabled its exists() call creates a file system that no + // one else can reach FileIO io = fileIO.load(path); - io.configure(config); - io.exists(path); + try { + io.configure(config); + io.exists(path); + } finally { + IOUtils.closeQuietly(io); + } return fileIO; } } diff --git a/paimon-common/src/main/java/org/apache/paimon/fs/PluginFileIO.java b/paimon-common/src/main/java/org/apache/paimon/fs/PluginFileIO.java index 587c1f2d4423..3acbbbb574bd 100644 --- a/paimon-common/src/main/java/org/apache/paimon/fs/PluginFileIO.java +++ b/paimon-common/src/main/java/org/apache/paimon/fs/PluginFileIO.java @@ -37,6 +37,9 @@ public abstract class PluginFileIO implements FileIO, HadoopOptionsProvider { private transient volatile FileIO lazyFileIO; + /** Transient so that a deserialized copy starts out usable. */ + private transient volatile boolean closed; + @Override public void configure(CatalogContext context) { // Do not get Hadoop Configuration in CatalogOptions @@ -108,14 +111,40 @@ public String createBlobPresignedUrl( } private FileIO fileIO(Path path) throws IOException { - if (lazyFileIO == null) { + // read into a local, close() may null the field at any point and callers dereference the + // result directly + FileIO fileIO = lazyFileIO; + if (fileIO == null) { synchronized (this) { - if (lazyFileIO == null) { - lazyFileIO = wrap(() -> createFileIO(path)); + if (closed) { + throw new IOException("This FileIO is closed."); + } + fileIO = lazyFileIO; + if (fileIO == null) { + fileIO = wrap(() -> createFileIO(path)); + lazyFileIO = fileIO; } } } - return lazyFileIO; + return fileIO; + } + + @Override + public void close() throws IOException { + FileIO fileIO; + synchronized (this) { + closed = true; + fileIO = lazyFileIO; + lazyFileIO = null; + } + if (fileIO != null) { + // the delegate lives in the plugin classloader, so close it under that classloader too + wrap( + () -> { + fileIO.close(); + return null; + }); + } } protected abstract FileIO createFileIO(Path path); diff --git a/paimon-common/src/main/java/org/apache/paimon/fs/ResolvingFileIO.java b/paimon-common/src/main/java/org/apache/paimon/fs/ResolvingFileIO.java index 93bc4a4f6f90..bf7e555e33df 100644 --- a/paimon-common/src/main/java/org/apache/paimon/fs/ResolvingFileIO.java +++ b/paimon-common/src/main/java/org/apache/paimon/fs/ResolvingFileIO.java @@ -23,10 +23,13 @@ import org.apache.paimon.data.BlobDescriptor; import org.apache.paimon.options.CatalogOptions; import org.apache.paimon.options.Options; +import org.apache.paimon.utils.IOUtils; import java.io.IOException; import java.io.Serializable; import java.time.Duration; +import java.util.ArrayList; +import java.util.List; import java.util.Map; import java.util.Objects; import java.util.concurrent.ConcurrentHashMap; @@ -45,6 +48,9 @@ public class ResolvingFileIO implements FileIO { private CatalogContext context; + /** Transient so that a deserialized copy starts out usable. */ + private transient volatile boolean closed; + // TODO, how to decide the real fileio is object store or not? @Override public boolean isObjectStore() { @@ -120,15 +126,49 @@ public String createBlobPresignedUrl( @VisibleForTesting public FileIO fileIO(Path path) throws IOException { + if (closed) { + throw new IOException("This FileIO is closed."); + } CacheKey cacheKey = new CacheKey(path.toUri().getScheme(), path.toUri().getAuthority()); - return fileIOMap.computeIfAbsent( - cacheKey, - k -> { + FileIO fileIO = + fileIOMap.computeIfAbsent( + cacheKey, + k -> { + try { + return FileIO.get(path, context); + } catch (IOException e) { + throw new RuntimeException(e); + } + }); + if (closed) { + // a close() ran while we were resolving and may already have passed this key, so take + // the delegate back out rather than leaving it behind unclosed + fileIOMap.remove(cacheKey, fileIO); + IOUtils.closeQuietly(fileIO); + throw new IOException("This FileIO is closed."); + } + return fileIO; + } + + @Override + public void close() throws IOException { + closed = true; + // remove before closing, so that a concurrent close does not close the same delegate twice + List toClose = new ArrayList<>(); + for (CacheKey key : fileIOMap.keySet()) { + FileIO fileIO = fileIOMap.remove(key); + if (fileIO != null) { + toClose.add(fileIO); + } + } + wrap( + () -> { try { - return FileIO.get(path, context); - } catch (IOException e) { - throw new RuntimeException(e); + IOUtils.closeAll(toClose); + } catch (Exception e) { + throw new IOException("Failed to close the resolved file IOs", e); } + return null; }); } diff --git a/paimon-common/src/main/java/org/apache/paimon/fs/hadoop/HadoopFileIO.java b/paimon-common/src/main/java/org/apache/paimon/fs/hadoop/HadoopFileIO.java index 3ff241d6c8f2..35b7b48ca226 100644 --- a/paimon-common/src/main/java/org/apache/paimon/fs/hadoop/HadoopFileIO.java +++ b/paimon-common/src/main/java/org/apache/paimon/fs/hadoop/HadoopFileIO.java @@ -30,6 +30,7 @@ import org.apache.paimon.hadoop.SerializableConfiguration; import org.apache.paimon.utils.FileIOUtils; import org.apache.paimon.utils.FunctionWithException; +import org.apache.paimon.utils.IOUtils; import org.apache.paimon.utils.Pair; import org.apache.paimon.utils.ReflectionUtils; @@ -39,12 +40,16 @@ import org.apache.hadoop.fs.FileSystem; import org.apache.hadoop.fs.Options; +import javax.annotation.Nullable; + import java.io.IOException; import java.io.OutputStreamWriter; import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; import java.net.URI; import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; import java.util.Locale; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; @@ -61,6 +66,12 @@ public class HadoopFileIO implements FileIO, HadoopOptionsProvider { protected transient volatile Map, FileSystem> fsMap; + /** + * Transient so that a deserialized copy starts out usable: closing one instance must not + * disable the copies that were shipped to other processes. + */ + private transient volatile boolean closed; + private final Path path; public HadoopFileIO(Path path) { @@ -197,6 +208,10 @@ private FileSystem getFileSystem( org.apache.hadoop.fs.Path path, FunctionWithException creator) throws IOException { + if (closed) { + throw new IOException("This FileIO is closed."); + } + if (fsMap == null) { synchronized (this) { if (fsMap == null) { @@ -214,7 +229,25 @@ private FileSystem getFileSystem( FileSystem fs = map.get(key); if (fs == null) { fs = creator.apply(path); - map.put(key, fs); + boolean owned = isOwnedScheme(scheme); + // publish under the same monitor close() takes, otherwise an instance created here can + // land in the map after close() drained it and then nobody would ever release it + synchronized (this) { + if (closed) { + if (owned) { + IOUtils.closeQuietly(fs); + } + throw new IOException("This FileIO is closed."); + } + FileSystem previous = map.putIfAbsent(key, fs); + if (previous != null) { + // another thread won the race, release the instance we own and use theirs + if (owned) { + IOUtils.closeQuietly(fs); + } + fs = previous; + } + } } return fs; } @@ -226,6 +259,64 @@ protected FileSystem createFileSystem(org.apache.hadoop.fs.Path path) throws IOE return fileSystem; } + /** + * Whether the {@link FileSystem} instances created for the given scheme belong to this {@link + * FileIO} exclusively, and may therefore be closed by it. + * + *

This mirrors the branch Hadoop itself takes in {@code FileSystem#get(URI, Configuration)}: + * with {@code fs..impl.disable.cache} set, Hadoop hands out a fresh instance that + * nobody else can reach, so releasing it is our responsibility. Otherwise the instance lives in + * Hadoop's global cache and is shared with every other user in this JVM, including other {@link + * FileIO}s and the compute engine itself; {@code FileSystem#closeAll} releases those on + * shutdown and closing one here would break unrelated readers. + * + *

The scheme is the one taken from the path, not from {@code FileSystem#getUri()}, and it is + * matched as written rather than lower cased, because that is what Hadoop looks up. Any + * deviation could report a cached, shared instance as owned. + */ + @VisibleForTesting + boolean isOwnedScheme(@Nullable String scheme) { + if (hadoopConf == null) { + return false; + } + Configuration conf = hadoopConf.get(); + if (scheme == null) { + // a path without a scheme is served by the default file system + try { + scheme = FileSystem.getDefaultUri(conf).getScheme(); + } catch (IllegalArgumentException e) { + // a missing or malformed fs.defaultFS, so there is no scheme to claim ownership of + return false; + } + } + return conf.getBoolean(String.format("fs.%s.impl.disable.cache", scheme), false); + } + + @Override + public void close() throws IOException { + List owned = new ArrayList<>(); + synchronized (this) { + closed = true; + Map, FileSystem> map = fsMap; + if (map == null) { + return; + } + for (Map.Entry, FileSystem> entry : map.entrySet()) { + if (isOwnedScheme(entry.getKey().getLeft())) { + owned.add(entry.getValue()); + } + } + // drop the cached instances as well, a closed one must never be handed out again + map.clear(); + } + + try { + IOUtils.closeAll(owned); + } catch (Exception e) { + throw new IOException("Failed to close the file systems owned by this FileIO", e); + } + } + private static class HadoopSeekableInputStream extends SeekableInputStream { /** diff --git a/paimon-common/src/main/java/org/apache/paimon/fs/hadoop/HadoopSecuredFileSystem.java b/paimon-common/src/main/java/org/apache/paimon/fs/hadoop/HadoopSecuredFileSystem.java index cbfca1b6d953..a5d7ba7d182f 100644 --- a/paimon-common/src/main/java/org/apache/paimon/fs/hadoop/HadoopSecuredFileSystem.java +++ b/paimon-common/src/main/java/org/apache/paimon/fs/hadoop/HadoopSecuredFileSystem.java @@ -21,6 +21,7 @@ import org.apache.paimon.options.Options; import org.apache.paimon.security.HadoopModule; import org.apache.paimon.security.SecurityConfiguration; +import org.apache.paimon.utils.IOUtils; import org.apache.paimon.utils.StringUtils; import org.apache.hadoop.conf.Configuration; @@ -170,6 +171,28 @@ public FileStatus getFileStatus(Path path) throws IOException { return runSecuredWithIOException(() -> fileSystem.getFileStatus(path)); } + @Override + public void close() throws IOException { + // super.close() processes the delete-on-exit set, which is served by the wrapped file + // system, so it has to run while that one is still open. closeAll keeps going after the + // first failure and reports the rest as suppressed instead of dropping them. + try { + IOUtils.closeAll(super::close, this::closeWrapped); + } catch (IOException e) { + throw e; + } catch (Exception e) { + throw new IOException("Failed to close the secured file system.", e); + } + } + + private void closeWrapped() throws IOException { + runSecuredWithIOException( + () -> { + fileSystem.close(); + return null; + }); + } + private void runSecured(final Runnable securedRunnable) { runSecured( () -> { diff --git a/paimon-common/src/test/java/org/apache/paimon/fs/PluginFileIOTest.java b/paimon-common/src/test/java/org/apache/paimon/fs/PluginFileIOTest.java index 2411cce3d030..08eea3e4169a 100644 --- a/paimon-common/src/test/java/org/apache/paimon/fs/PluginFileIOTest.java +++ b/paimon-common/src/test/java/org/apache/paimon/fs/PluginFileIOTest.java @@ -26,7 +26,11 @@ import java.time.Duration; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; /** Tests for {@link PluginFileIO}. */ @@ -56,11 +60,59 @@ void testCreateBlobPresignedUrlUsesPluginClassLoader() throws IOException { assertThat(Thread.currentThread().getContextClassLoader()).isSameAs(original); } + @Test + void testCloseReleasesTheDelegateUnderThePluginClassLoader() throws IOException { + FileIO delegate = mock(FileIO.class); + ClassLoader pluginClassLoader = new ClassLoader() {}; + TestPluginFileIO fileIO = new TestPluginFileIO(delegate, pluginClassLoader); + ClassLoader original = Thread.currentThread().getContextClassLoader(); + doAnswer( + ignored -> { + assertThat(Thread.currentThread().getContextClassLoader()) + .isSameAs(pluginClassLoader); + return null; + }) + .when(delegate) + .close(); + + // nothing has been resolved yet, so there is nothing to release + new TestPluginFileIO(delegate, pluginClassLoader).close(); + verify(delegate, never()).close(); + + fileIO.exists(new Path("oss://bucket/table/file")); + fileIO.close(); + + verify(delegate).close(); + assertThat(Thread.currentThread().getContextClassLoader()).isSameAs(original); + + // the delegate has been dropped, closing again must not touch it a second time + fileIO.close(); + verify(delegate).close(); + } + + @Test + void testUseAfterCloseFailsWithIOExceptionRatherThanNpe() throws IOException { + FileIO delegate = mock(FileIO.class); + TestPluginFileIO fileIO = new TestPluginFileIO(delegate, new ClassLoader() {}); + Path path = new Path("oss://bucket/table/file"); + + fileIO.exists(path); + fileIO.close(); + + // close() is the only writer of null, so a plain field read here would NPE, and silently + // re-creating the delegate would build a second file system nobody releases + assertThatThrownBy(() -> fileIO.exists(path)) + .isInstanceOf(IOException.class) + .hasMessageContaining("closed"); + assertThat(fileIO.createdCount).isEqualTo(1); + } + private static class TestPluginFileIO extends PluginFileIO { private final FileIO delegate; private final ClassLoader classLoader; private Path createdFor; + private int createdCount; private TestPluginFileIO(FileIO delegate, ClassLoader classLoader) { this.delegate = delegate; @@ -75,6 +127,7 @@ public boolean isObjectStore() { @Override protected FileIO createFileIO(Path path) { createdFor = path; + createdCount++; return delegate; } diff --git a/paimon-common/src/test/java/org/apache/paimon/fs/ResolvingFileIOTest.java b/paimon-common/src/test/java/org/apache/paimon/fs/ResolvingFileIOTest.java index c550b84df171..8ba88b2d6da4 100644 --- a/paimon-common/src/test/java/org/apache/paimon/fs/ResolvingFileIOTest.java +++ b/paimon-common/src/test/java/org/apache/paimon/fs/ResolvingFileIOTest.java @@ -29,15 +29,21 @@ import java.io.IOException; import java.time.Duration; +import java.util.ArrayList; +import java.util.List; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.Future; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNotEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -144,6 +150,79 @@ public void testFileIOMapStoresFileIOInstances() throws IOException { assertEquals(hdfsFileIO, hdfsFileIOAgain); } + @Test + public void testCloseReleasesEveryResolvedFileIO() throws Exception { + List loaded = new ArrayList<>(); + configureWithFreshDelegates(loaded, false); + + // two authorities mean two entries in the delegate map + FileIO first = resolvingFileIO.fileIO(new Path("oss://bucket-1/table")); + FileIO second = resolvingFileIO.fileIO(new Path("oss://bucket-2/table")); + assertNotEquals(first, second); + + resolvingFileIO.close(); + + // both resolved delegates, and the throwaway probes FileIO.get made along the way + for (FileIO fileIO : loaded) { + verify(fileIO, times(1)).close(); + } + } + + @Test + public void testCloseKeepsGoingWhenADelegateFails() throws Exception { + List loaded = new ArrayList<>(); + configureWithFreshDelegates(loaded, true); + + FileIO first = resolvingFileIO.fileIO(new Path("oss://bucket-1/table")); + FileIO second = resolvingFileIO.fileIO(new Path("oss://bucket-2/table")); + + // the first failure must not keep the second entry from being closed + assertThrows(IOException.class, () -> resolvingFileIO.close()); + + verify(first, times(1)).close(); + verify(second, times(1)).close(); + } + + @Test + public void testUseAfterCloseIsRejectedAndTheMapIsEmptied() throws Exception { + List loaded = new ArrayList<>(); + configureWithFreshDelegates(loaded, false); + + FileIO delegate = resolvingFileIO.fileIO(new Path("oss://bucket-1/table")); + resolvingFileIO.close(); + + // the map really is drained, so a second close must not close the delegate again + resolvingFileIO.close(); + verify(delegate, times(1)).close(); + + // and resolving again must not silently rebuild a delegate nobody will release + assertThrows( + IOException.class, () -> resolvingFileIO.fileIO(new Path("oss://bucket-1/table"))); + } + + /** + * Hands out a fresh delegate per load, the way a real loader does. FileIO.get loads one + * throwaway instance to probe access and another one to return, so a single shared mock cannot + * tell the two apart. + */ + private void configureWithFreshDelegates(List loaded, boolean failOnClose) + throws IOException { + FileIOLoader loader = mock(FileIOLoader.class); + when(loader.getScheme()).thenReturn("oss"); + when(loader.load(any())) + .thenAnswer( + ignored -> { + FileIO delegate = mock(FileIO.class); + when(delegate.exists(any())).thenReturn(true); + if (failOnClose) { + doThrow(new IOException("cannot close")).when(delegate).close(); + } + loaded.add(delegate); + return delegate; + }); + resolvingFileIO.configure(CatalogContext.create(new Options(), loader, null)); + } + @Test public void testCreateBlobPresignedUrlResolvesDescriptorFileIO() throws IOException { FileIO delegate = mock(FileIO.class); diff --git a/paimon-common/src/test/java/org/apache/paimon/fs/hadoop/HadoopFileIOTest.java b/paimon-common/src/test/java/org/apache/paimon/fs/hadoop/HadoopFileIOTest.java new file mode 100644 index 000000000000..2db9ad0b55bf --- /dev/null +++ b/paimon-common/src/test/java/org/apache/paimon/fs/hadoop/HadoopFileIOTest.java @@ -0,0 +1,487 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.paimon.fs.hadoop; + +import org.apache.paimon.catalog.CatalogContext; +import org.apache.paimon.fs.FileIO; +import org.apache.paimon.fs.Path; +import org.apache.paimon.options.Options; +import org.apache.paimon.utils.Pair; + +import org.apache.hadoop.conf.Configuration; +import org.apache.hadoop.fs.FSDataInputStream; +import org.apache.hadoop.fs.FSDataOutputStream; +import org.apache.hadoop.fs.FileStatus; +import org.apache.hadoop.fs.FileSystem; +import org.apache.hadoop.fs.permission.FsPermission; +import org.apache.hadoop.util.Progressable; +import org.junit.jupiter.api.Test; + +import java.io.FileNotFoundException; +import java.io.IOException; +import java.net.URI; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** Tests for {@link HadoopFileIO}, mostly around releasing the file systems it owns. */ +public class HadoopFileIOTest { + + @Test + public void testUncachedFileSystemIsClosed() throws Exception { + Configuration conf = conf("testfs"); + conf.setBoolean("fs.testfs.impl.disable.cache", true); + HadoopFileIO fileIO = fileIO(conf, "testfs://owned/warehouse"); + + RecordingFileSystem fs = fileSystem(fileIO, "testfs://owned/a"); + assertThat(fs.closeCount()).isZero(); + + fileIO.close(); + + assertThat(fs.closeCount()).isEqualTo(1); + assertThat(fileIO.fsMap).isEmpty(); + } + + @Test + public void testCachedFileSystemIsNotClosed() throws Exception { + // without disable.cache the instance comes from Hadoop's global cache and is shared with + // every other user in this JVM, so closing it here would break unrelated readers + Configuration conf = conf("testfs"); + HadoopFileIO fileIO = fileIO(conf, "testfs://shared/warehouse"); + + RecordingFileSystem fs = fileSystem(fileIO, "testfs://shared/a"); + try { + fileIO.close(); + + assertThat(fs.closeCount()).isZero(); + + // still shared: another FileIO gets the very same instance back + HadoopFileIO other = fileIO(conf, "testfs://shared/warehouse"); + assertThat(fileSystem(other, "testfs://shared/a")).isSameAs(fs); + } finally { + // Hadoop's cache is static and this fork is reused, so never leave it behind + fs.close(); + } + } + + @Test + public void testOnlyTheOwnedSchemeIsClosed() throws Exception { + Configuration conf = conf("testfs", "otherfs"); + conf.setBoolean("fs.testfs.impl.disable.cache", true); + HadoopFileIO fileIO = fileIO(conf, "testfs://mixed/warehouse"); + + RecordingFileSystem owned = fileSystem(fileIO, "testfs://mixed/a"); + RecordingFileSystem shared = fileSystem(fileIO, "otherfs://mixed/a"); + + try { + fileIO.close(); + + assertThat(owned.closeCount()).isEqualTo(1); + assertThat(shared.closeCount()).isZero(); + } finally { + shared.close(); + } + } + + @Test + public void testFailingCloseDoesNotSkipTheOtherFileSystems() throws Exception { + Configuration conf = conf("testfs", "badfs"); + conf.setBoolean("fs.testfs.impl.disable.cache", true); + conf.setBoolean("fs.badfs.impl.disable.cache", true); + conf.setBoolean("fs.badfs.test.fail-on-close", true); + HadoopFileIO fileIO = fileIO(conf, "testfs://failing/warehouse"); + + RecordingFileSystem bad = fileSystem(fileIO, "badfs://failing/a"); + RecordingFileSystem good = fileSystem(fileIO, "testfs://failing/a"); + + assertThatThrownBy(fileIO::close).isInstanceOf(IOException.class); + + assertThat(bad.closeCount()).isEqualTo(1); + assertThat(good.closeCount()).isEqualTo(1); + assertThat(fileIO.fsMap).isEmpty(); + } + + @Test + public void testCloseIsIdempotent() throws Exception { + Configuration conf = conf("testfs"); + conf.setBoolean("fs.testfs.impl.disable.cache", true); + HadoopFileIO fileIO = fileIO(conf, "testfs://idempotent/warehouse"); + + RecordingFileSystem fs = fileSystem(fileIO, "testfs://idempotent/a"); + fileIO.close(); + fileIO.close(); + + assertThat(fs.closeCount()).isEqualTo(1); + } + + @Test + public void testCloseBeforeAnyUseIsSafe() throws Exception { + Configuration conf = conf("testfs"); + conf.setBoolean("fs.testfs.impl.disable.cache", true); + // nothing has been opened, so the lazily created map is still null; this is also the state + // of every instance that arrives by deserialization + HadoopFileIO fileIO = fileIO(conf, "testfs://untouched/warehouse"); + + fileIO.close(); + fileIO.close(); + } + + @Test + public void testInjectedFileSystemIsNotClosed() throws Exception { + // an unconfigured FileIO owns nothing, and a file system handed in from the outside stays + // the caller's to close + HadoopFileIO fileIO = new HadoopFileIO(new Path("testfs://injected/warehouse")); + RecordingFileSystem fs = new RecordingFileSystem(); + fileIO.setFileSystem(fs); + + fileIO.close(); + + assertThat(fs.closeCount()).isZero(); + } + + @Test + public void testFileSystemLosingTheCreationRaceIsReleased() throws Exception { + Configuration conf = conf("testfs"); + conf.setBoolean("fs.testfs.impl.disable.cache", true); + RecordingFileSystem winner = new RecordingFileSystem(); + RacingHadoopFileIO fileIO = + new RacingHadoopFileIO(new Path("testfs://race/warehouse"), winner); + fileIO.configure(CatalogContext.create(new Options(), conf)); + + FileSystem returned = + fileIO.getFileSystem(new org.apache.hadoop.fs.Path("testfs://race/a")); + + assertThat(returned).isSameAs(winner); + assertThat(fileIO.loser.closeCount()).isEqualTo(1); + assertThat(winner.closeCount()).isZero(); + } + + @Test + public void testOwnershipUsesTheSchemeAsWritten() { + Configuration conf = new Configuration(); + conf.setBoolean("fs.testfs.impl.disable.cache", true); + HadoopFileIO fileIO = fileIO(conf, "testfs://spelling/warehouse"); + + assertThat(fileIO.isOwnedScheme("testfs")).isTrue(); + // Hadoop looks the property up with the scheme exactly as the path spells it, so a + // differently cased scheme is served from the global cache and is not ours to close + assertThat(fileIO.isOwnedScheme("TESTFS")).isFalse(); + assertThat(fileIO.isOwnedScheme("otherfs")).isFalse(); + } + + @Test + public void testSchemelessPathFallsBackToTheDefaultFileSystem() { + Configuration conf = new Configuration(); + conf.set("fs.defaultFS", "testfs://default"); + conf.setBoolean("fs.testfs.impl.disable.cache", true); + HadoopFileIO fileIO = fileIO(conf, "testfs://default/warehouse"); + + assertThat(fileIO.isOwnedScheme(null)).isTrue(); + + conf.setBoolean("fs.testfs.impl.disable.cache", false); + assertThat(fileIO.isOwnedScheme(null)).isFalse(); + } + + @Test + public void testUseAfterCloseIsRejectedInsteadOfLeakingAgain() throws Exception { + Configuration conf = conf("testfs"); + conf.setBoolean("fs.testfs.impl.disable.cache", true); + HadoopFileIO fileIO = fileIO(conf, "testfs://reuse/warehouse"); + + RecordingFileSystem fs = fileSystem(fileIO, "testfs://reuse/a"); + fileIO.close(); + assertThat(fs.closeCount()).isEqualTo(1); + + // resurrecting would silently create an owned file system that nobody will ever release + RecordingFileSystem.resetCounters(); + assertThatThrownBy(() -> fileSystem(fileIO, "testfs://reuse/a")) + .isInstanceOf(IOException.class) + .hasMessageContaining("closed"); + assertThat(RecordingFileSystem.created()).isZero(); + } + + @Test + public void testFileSystemCreatedWhileClosingIsReleased() throws Exception { + Configuration conf = conf("testfs"); + conf.setBoolean("fs.testfs.impl.disable.cache", true); + // stands in for close() landing between the creation and the publication of a file system + ClosingWhileCreatingFileIO fileIO = + new ClosingWhileCreatingFileIO(new Path("testfs://late/warehouse")); + fileIO.configure(CatalogContext.create(new Options(), conf)); + + assertThatThrownBy( + () -> + fileIO.getFileSystem( + new org.apache.hadoop.fs.Path("testfs://late/a"))) + .isInstanceOf(IOException.class) + .hasMessageContaining("closed"); + + assertThat(fileIO.created.closeCount()).isEqualTo(1); + assertThat(fileIO.fsMap).isEmpty(); + } + + @Test + public void testCachedFileSystemLosingTheRaceIsNotClosed() throws Exception { + // with the Hadoop cache enabled both racing threads get the very same shared instance, so + // releasing the loser would close the instance we are about to hand out + Configuration conf = conf("testfs"); + RacingHadoopFileIO fileIO = + new RacingHadoopFileIO(new Path("testfs://cachedrace/warehouse"), null); + fileIO.configure(CatalogContext.create(new Options(), conf)); + + FileSystem returned = + fileIO.getFileSystem(new org.apache.hadoop.fs.Path("testfs://cachedrace/a")); + + assertThat(((RecordingFileSystem) returned).closeCount()).isZero(); + ((RecordingFileSystem) returned).close(); + } + + @Test + public void testOwnershipFollowsThePathSchemeNotTheFileSystemUri() throws Exception { + // the file system reports a scheme of its own; ownership must still follow the path, which + // is the scheme Hadoop consulted when it decided whether to cache + Configuration conf = conf("testfs"); + conf.setBoolean("fs.testfs.impl.disable.cache", true); + conf.set("fs.testfs.test.reported-scheme", "otherfs"); + HadoopFileIO fileIO = fileIO(conf, "testfs://reported/warehouse"); + + RecordingFileSystem fs = fileSystem(fileIO, "testfs://reported/a"); + assertThat(fs.getUri().getScheme()).isEqualTo("otherfs"); + + fileIO.close(); + + assertThat(fs.closeCount()).isEqualTo(1); + } + + @Test + public void testMalformedDefaultFileSystemDoesNotBreakClose() throws Exception { + // Hadoop 3 throws IllegalArgumentException out of getDefaultUri for a scheme-less + // fs.defaultFS, which must not escape close() and strand everything else + Configuration conf = new Configuration(); + conf.set("fs.defaultFS", "no-scheme-here"); + HadoopFileIO fileIO = fileIO(conf, "testfs://malformed/warehouse"); + + assertThat(fileIO.isOwnedScheme(null)).isFalse(); + fileIO.close(); + } + + @Test + public void testAccessProbeFileSystemIsNotLeakedByFileIOGet() throws Exception { + // FileIO.get probes a loader by calling exists() on a throwaway FileIO; with the Hadoop + // cache disabled that probe creates a file system nobody else can reach + Configuration conf = conf("testfs"); + conf.setBoolean("fs.testfs.impl.disable.cache", true); + RecordingFileSystem.resetCounters(); + + Path path = new Path("testfs://probe/warehouse"); + FileIO fileIO = FileIO.get(path, CatalogContext.create(new Options(), conf)); + try { + fileIO.exists(path); + } finally { + fileIO.close(); + } + + assertThat(RecordingFileSystem.created()).isGreaterThan(1); + assertThat(RecordingFileSystem.closed()).isEqualTo(RecordingFileSystem.created()); + } + + private static Configuration conf(String... schemes) { + Configuration conf = new Configuration(); + for (String scheme : schemes) { + conf.set("fs." + scheme + ".impl", RecordingFileSystem.class.getName()); + } + return conf; + } + + private static HadoopFileIO fileIO(Configuration conf, String warehouse) { + HadoopFileIO fileIO = new HadoopFileIO(new Path(warehouse)); + fileIO.configure(CatalogContext.create(new Options(), conf)); + return fileIO; + } + + private static RecordingFileSystem fileSystem(HadoopFileIO fileIO, String path) + throws IOException { + return (RecordingFileSystem) fileIO.getFileSystem(new org.apache.hadoop.fs.Path(path)); + } + + /** + * A {@link HadoopFileIO} that always loses the race for publishing a new file system. A null + * winner publishes the created instance itself, which is what Hadoop's own cache hands to both + * racing threads. + */ + private static class RacingHadoopFileIO extends HadoopFileIO { + + private static final long serialVersionUID = 1L; + + private final FileSystem winner; + private RecordingFileSystem loser; + + private RacingHadoopFileIO(Path path, FileSystem winner) { + super(path); + this.winner = winner; + } + + @Override + protected FileSystem createFileSystem(org.apache.hadoop.fs.Path path) throws IOException { + loser = (RecordingFileSystem) super.createFileSystem(path); + // stand in for a concurrent thread that published its own instance first + URI uri = path.toUri(); + fsMap.put( + Pair.of(uri.getScheme(), uri.getAuthority()), winner == null ? loser : winner); + return loser; + } + } + + /** A {@link HadoopFileIO} that is closed in the window between creating and publishing. */ + private static class ClosingWhileCreatingFileIO extends HadoopFileIO { + + private static final long serialVersionUID = 1L; + + private RecordingFileSystem created; + + private ClosingWhileCreatingFileIO(Path path) { + super(path); + } + + @Override + protected FileSystem createFileSystem(org.apache.hadoop.fs.Path path) throws IOException { + created = (RecordingFileSystem) super.createFileSystem(path); + close(); + return created; + } + } + + /** A {@link FileSystem} that records how often it was closed and does nothing else. */ + public static class RecordingFileSystem extends FileSystem { + + private static final AtomicInteger CREATED = new AtomicInteger(); + private static final AtomicInteger CLOSED = new AtomicInteger(); + + private URI uri; + private boolean failOnClose; + private int closeCount; + + static void resetCounters() { + CREATED.set(0); + CLOSED.set(0); + } + + static int created() { + return CREATED.get(); + } + + static int closed() { + return CLOSED.get(); + } + + @Override + public void initialize(URI name, Configuration conf) throws IOException { + super.initialize(name, conf); + String reportedScheme = + conf.get("fs." + name.getScheme() + ".test.reported-scheme", null); + this.uri = + reportedScheme == null + ? name + : URI.create(reportedScheme + "://" + name.getAuthority()); + this.failOnClose = + conf.getBoolean("fs." + name.getScheme() + ".test.fail-on-close", false); + CREATED.incrementAndGet(); + } + + @Override + public URI getUri() { + return uri; + } + + @Override + public void close() throws IOException { + closeCount++; + CLOSED.incrementAndGet(); + super.close(); + if (failOnClose) { + throw new IOException("close fails on purpose for " + uri); + } + } + + int closeCount() { + return closeCount; + } + + @Override + public FSDataInputStream open(org.apache.hadoop.fs.Path f, int bufferSize) { + throw new UnsupportedOperationException(); + } + + @Override + public FSDataOutputStream create( + org.apache.hadoop.fs.Path f, + FsPermission permission, + boolean overwrite, + int bufferSize, + short replication, + long blockSize, + Progressable progress) { + throw new UnsupportedOperationException(); + } + + @Override + public FSDataOutputStream append( + org.apache.hadoop.fs.Path f, int bufferSize, Progressable progress) { + throw new UnsupportedOperationException(); + } + + @Override + public boolean rename(org.apache.hadoop.fs.Path src, org.apache.hadoop.fs.Path dst) { + throw new UnsupportedOperationException(); + } + + @Override + public boolean delete(org.apache.hadoop.fs.Path f, boolean recursive) { + throw new UnsupportedOperationException(); + } + + @Override + public FileStatus[] listStatus(org.apache.hadoop.fs.Path f) { + throw new UnsupportedOperationException(); + } + + @Override + public void setWorkingDirectory(org.apache.hadoop.fs.Path dir) { + throw new UnsupportedOperationException(); + } + + @Override + public org.apache.hadoop.fs.Path getWorkingDirectory() { + return new org.apache.hadoop.fs.Path("/"); + } + + @Override + public boolean mkdirs(org.apache.hadoop.fs.Path f, FsPermission permission) { + throw new UnsupportedOperationException(); + } + + @Override + public FileStatus getFileStatus(org.apache.hadoop.fs.Path f) throws IOException { + // lets FileSystem#exists answer false instead of blowing up, which is what the + // FileIO.get access probe needs + throw new FileNotFoundException(f.toString()); + } + } +} diff --git a/paimon-common/src/test/java/org/apache/paimon/fs/hadoop/HadoopSecuredFileSystemTest.java b/paimon-common/src/test/java/org/apache/paimon/fs/hadoop/HadoopSecuredFileSystemTest.java index 54de46dc7c56..519cd5364a26 100644 --- a/paimon-common/src/test/java/org/apache/paimon/fs/hadoop/HadoopSecuredFileSystemTest.java +++ b/paimon-common/src/test/java/org/apache/paimon/fs/hadoop/HadoopSecuredFileSystemTest.java @@ -22,6 +22,8 @@ import org.apache.paimon.fs.Path; import org.apache.paimon.options.Options; +import org.apache.hadoop.conf.Configuration; +import org.apache.hadoop.fs.FileSystem; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; @@ -78,6 +80,25 @@ public void testReturnOriginalFileSystemWhenSecurityConfigIsIllegal() throws Exc .isNotInstanceOf(HadoopSecuredFileSystem.class); } + @Test + public void testCloseClosesTheWrappedFileSystem() throws Exception { + File keytabFile = new File(tmp.toFile(), "test-keytab.keytab"); + assertThat(keytabFile.createNewFile()).isTrue(); + + Options options = new Options(); + options.set("security.kerberos.login.principal", "test-user"); + options.set("security.kerberos.login.keytab", keytabFile.getAbsolutePath()); + + HadoopFileIOTest.RecordingFileSystem wrapped = new HadoopFileIOTest.RecordingFileSystem(); + FileSystem secured = + HadoopSecuredFileSystem.trySecureFileSystem(wrapped, options, new Configuration()); + assertThat(secured).isInstanceOf(HadoopSecuredFileSystem.class); + + secured.close(); + + assertThat(wrapped.closeCount()).isEqualTo(1); + } + private HadoopFileIO createFileIO(Options options) { HadoopFileIO fileIO = new HadoopFileIO(new Path("file:///tmp/test")); fileIO.configure(CatalogContext.create(options));