This is an automated email from the ASF dual-hosted git repository.
JingsongLi pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/paimon.git
The following commit(s) were added to refs/heads/master by this push:
new 796faf2f46 [common] Fix concurrent remote stream leak in
CachingSeekableInputStream (#9098)
796faf2f46 is described below
commit 796faf2f468930f57fe244125279989f5a6f958d
Author: Vova Kolmakov <[email protected]>
AuthorDate: Mon Aug 10 15:36:49 2026 +0700
[common] Fix concurrent remote stream leak in CachingSeekableInputStream
(#9098)
---
.../fs/cache/CachingSeekableInputStream.java | 92 ++++-
.../apache/paimon/fs/cache/CachingFileIOTest.java | 450 ++++++++++++++++++++-
2 files changed, 515 insertions(+), 27 deletions(-)
diff --git
a/paimon-common/src/main/java/org/apache/paimon/fs/cache/CachingSeekableInputStream.java
b/paimon-common/src/main/java/org/apache/paimon/fs/cache/CachingSeekableInputStream.java
index e3f990beb6..48032c8a70 100644
---
a/paimon-common/src/main/java/org/apache/paimon/fs/cache/CachingSeekableInputStream.java
+++
b/paimon-common/src/main/java/org/apache/paimon/fs/cache/CachingSeekableInputStream.java
@@ -22,21 +22,32 @@ import org.apache.paimon.fs.FileIO;
import org.apache.paimon.fs.Path;
import org.apache.paimon.fs.SeekableInputStream;
import org.apache.paimon.fs.VectoredReadable;
-
-import javax.annotation.Nullable;
+import org.apache.paimon.utils.IOUtils;
import java.io.IOException;
+import java.util.concurrent.atomic.AtomicReference;
-/** A {@link SeekableInputStream} that caches reads at block granularity on
local disk. */
+/**
+ * A {@link SeekableInputStream} that caches reads at block granularity on
local disk.
+ *
+ * <p>{@link #pread} and {@link #preadFully} are thread-safe, as {@link
VectoredReadable} requires,
+ * and open exactly one remote stream between them. {@link #close} is safe to
call concurrently with
+ * them and never leaks that stream, but a read already in flight may fail
with an
+ * implementation-specific {@link IOException} from the remote. The positional
methods {@link
+ * #seek}, {@link #read} and {@link #getPos} are not thread-safe, exactly as
for any other stream.
+ */
public class CachingSeekableInputStream extends SeekableInputStream implements
VectoredReadable {
private final FileIO fileIO;
private final Path path;
private final LocalCacheManager cache;
private final String cacheKey;
+ private final AtomicReference<SeekableInputStream> remoteStream = new
AtomicReference<>();
+ // private, so no caller can stall a lazy init by locking the stream itself
+ private final Object lock = new Object();
private long pos;
- private long fileSize;
- @Nullable private SeekableInputStream remoteStream;
+ private volatile long fileSize;
+ private volatile boolean closed;
public CachingSeekableInputStream(FileIO fileIO, Path path,
LocalCacheManager cache) {
this(fileIO, path, cache, path.toString(), -1);
@@ -53,16 +64,24 @@ public class CachingSeekableInputStream extends
SeekableInputStream implements V
}
private long fileSize() throws IOException {
- if (fileSize == -1) {
- long cached = cache.getFileSize(cacheKey);
- if (cached >= 0) {
- fileSize = cached;
- } else {
- fileSize = fileIO.getFileStatus(path).getLen();
- cache.putFileSize(cacheKey, fileSize);
+ // guarded like the remote stream below, and for the same reason: a
vectored fan-out reaches
+ // this first, and an unguarded lazy init would issue one
getFileStatus per thread
+ long current = fileSize;
+ if (current >= 0) {
+ return current;
+ }
+ synchronized (lock) {
+ current = fileSize;
+ if (current < 0) {
+ current = cache.getFileSize(cacheKey);
+ if (current < 0) {
+ current = fileIO.getFileStatus(path).getLen();
+ cache.putFileSize(cacheKey, current);
+ }
+ fileSize = current;
}
}
- return fileSize;
+ return current;
}
@Override
@@ -77,6 +96,7 @@ public class CachingSeekableInputStream extends
SeekableInputStream implements V
@Override
public int read() throws IOException {
+ checkNotClosed();
if (pos >= fileSize()) {
return -1;
}
@@ -90,6 +110,7 @@ public class CachingSeekableInputStream extends
SeekableInputStream implements V
@Override
public int read(byte[] b, int off, int len) throws IOException {
+ checkNotClosed();
if (len == 0) {
return 0;
}
@@ -120,6 +141,7 @@ public class CachingSeekableInputStream extends
SeekableInputStream implements V
@Override
public int pread(long position, byte[] buffer, int offset, int length)
throws IOException {
+ checkNotClosed();
if (length == 0) {
return 0;
}
@@ -178,10 +200,40 @@ public class CachingSeekableInputStream extends
SeekableInputStream implements V
}
private SeekableInputStream getRemoteStream() throws IOException {
- if (remoteStream == null) {
- remoteStream = fileIO.newInputStream(path);
+ // reached concurrently: readVectored fans preadFully out over an IO
thread pool, so an
+ // unguarded lazy init would open one stream per thread and leak all
but the last
+ SeekableInputStream current = remoteStream.get();
+ if (current != null) {
+ checkNotClosed();
+ return current;
+ }
+
+ boolean opened = false;
+ synchronized (lock) {
+ current = remoteStream.get();
+ if (current == null) {
+ // close() may have won while this call was queued for the lock
+ checkNotClosed();
+ current = fileIO.newInputStream(path);
+ remoteStream.set(current);
+ opened = true;
+ }
+ }
+
+ // close() does not wait for the open above, so it may have run right
through it. Only the
+ // thread that opened the stream hands it back: close() sets the flag
before detaching and
+ // this reads it after publishing, so one of the two always sees the
other.
+ if (opened && closed) {
+ IOUtils.closeQuietly(remoteStream.getAndSet(null));
+ }
+ checkNotClosed();
+ return current;
+ }
+
+ private void checkNotClosed() throws IOException {
+ if (closed) {
+ throw new IOException("Stream is closed: " + path);
}
- return remoteStream;
}
private static byte[] readFully(SeekableInputStream in, int size) throws
IOException {
@@ -201,9 +253,11 @@ public class CachingSeekableInputStream extends
SeekableInputStream implements V
@Override
public void close() throws IOException {
- if (remoteStream != null) {
- remoteStream.close();
- remoteStream = null;
+ // takes no lock, so it never waits behind an in-flight remote open
+ closed = true;
+ SeekableInputStream current = remoteStream.getAndSet(null);
+ if (current != null) {
+ current.close();
}
}
}
diff --git
a/paimon-common/src/test/java/org/apache/paimon/fs/cache/CachingFileIOTest.java
b/paimon-common/src/test/java/org/apache/paimon/fs/cache/CachingFileIOTest.java
index ae0bec51f0..35e9ec7668 100644
---
a/paimon-common/src/test/java/org/apache/paimon/fs/cache/CachingFileIOTest.java
+++
b/paimon-common/src/test/java/org/apache/paimon/fs/cache/CachingFileIOTest.java
@@ -21,10 +21,12 @@ package org.apache.paimon.fs.cache;
import org.apache.paimon.catalog.CatalogContext;
import org.apache.paimon.data.BlobDescriptor;
import org.apache.paimon.fs.FileIO;
+import org.apache.paimon.fs.FileRange;
import org.apache.paimon.fs.FileStatus;
import org.apache.paimon.fs.Path;
import org.apache.paimon.fs.PositionOutputStream;
import org.apache.paimon.fs.SeekableInputStream;
+import org.apache.paimon.fs.VectoredReadUtils;
import org.apache.paimon.options.MemorySize;
import org.apache.paimon.options.Options;
import org.apache.paimon.utils.FileType;
@@ -38,17 +40,28 @@ import javax.annotation.Nullable;
import java.io.IOException;
import java.time.Duration;
+import java.util.ArrayList;
+import java.util.Arrays;
import java.util.EnumSet;
import java.util.HashMap;
+import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.function.BooleanSupplier;
import static org.apache.paimon.options.CatalogOptions.LOCAL_CACHE_DIR;
import static org.apache.paimon.options.CatalogOptions.LOCAL_CACHE_ENABLED;
import static org.apache.paimon.options.CatalogOptions.LOCAL_CACHE_MAX_SIZE;
import static org.apache.paimon.options.CatalogOptions.LOCAL_CACHE_WHITELIST;
import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
@@ -58,6 +71,19 @@ import static org.mockito.Mockito.when;
/** Tests for {@link CachingFileIO} and {@link CachingSeekableInputStream}. */
class CachingFileIOTest {
+ private static final int VECTOR_RANGES = 8;
+ private static final int VECTOR_LENGTH = 256;
+ private static final int VECTOR_STRIDE = 32 * 1024;
+ // BlockingExecutor has exactly this many permits; below VECTOR_RANGES,
readVectored would block
+ // the calling thread, which is also the only thread that can release the
open gate
+ private static final int VECTOR_PARALLELISM = 32;
+
+ static {
+ if (VECTOR_PARALLELISM < VECTOR_RANGES) {
+ throw new AssertionError("VECTOR_PARALLELISM must be at least
VECTOR_RANGES");
+ }
+ }
+
@TempDir java.nio.file.Path tempDir;
private String cacheDir;
@@ -583,6 +609,341 @@ class CachingFileIOTest {
assertThat(cachingIO.isObjectStore()).isFalse();
}
+ @Test
+ void testConcurrentFirstReadOpensSingleRemoteStream() throws Exception {
+ byte[] data = positionMarkedBytes(256);
+ MockFileIO delegate = new MockFileIO();
+ delegate.addFile("global-index-concurrent.index", data);
+ CountDownLatch openGate = new CountDownLatch(1);
+ delegate.blockOpensUntil(openGate);
+
+ CachingSeekableInputStream stream =
+ new CachingSeekableInputStream(
+ delegate,
+ new Path("global-index-concurrent.index"),
+ new LocalMemoryCacheManager(Long.MAX_VALUE, 64),
+ "concurrent",
+ data.length);
+
+ AtomicReference<Throwable> failure = new AtomicReference<>();
+ byte[] firstBlock = new byte[64];
+ byte[] secondBlock = new byte[64];
+ Thread first = readerThread("first", stream, 0, firstBlock, failure);
+ Thread second = readerThread("second", stream, 64, secondBlock,
failure);
+
+ try {
+ // parks inside delegate.newInputStream, holding the lazy
initialisation lock
+ first.start();
+ assertThat(awaitCondition(() -> delegate.openCount() == 1,
30_000)).isTrue();
+
+ // a different block, so this reader has to go remote as well
+ second.start();
+
+ // guarded, second queues on the lock; unguarded, it opens a
stream of its own. The
+ // BLOCKED sample is only a fast exit for the passing case - a
guard built on something
+ // other than an intrinsic lock would simply wait out the budget
and still pass.
+ awaitCondition(
+ () -> second.getState() == Thread.State.BLOCKED ||
delegate.openCount() == 2,
+ 5_000);
+ assertThat(delegate.openCount()).as("remote streams
opened").isEqualTo(1);
+ } finally {
+ openGate.countDown();
+ }
+
+ first.join(30_000);
+ second.join(30_000);
+ assertThat(first.isAlive()).isFalse();
+ assertThat(second.isAlive()).isFalse();
+ assertThat(failure.get()).isNull();
+
+ assertThat(firstBlock).isEqualTo(Arrays.copyOfRange(data, 0, 64));
+ assertThat(secondBlock).isEqualTo(Arrays.copyOfRange(data, 64, 128));
+
+ stream.close();
+ assertThat(delegate.unclosedStreamCount()).as("remote streams left
open").isZero();
+ assertThat(delegate.openCount()).as("remote streams
opened").isEqualTo(1);
+ }
+
+ @Test
+ void testReadAfterCloseDoesNotReopenRemoteStream() throws Exception {
+ byte[] data = positionMarkedBytes(256);
+ MockFileIO delegate = new MockFileIO();
+ delegate.addFile("global-index-after-close.index", data);
+
+ CachingSeekableInputStream stream =
+ new CachingSeekableInputStream(
+ delegate,
+ new Path("global-index-after-close.index"),
+ new LocalMemoryCacheManager(Long.MAX_VALUE, 64),
+ "after-close",
+ data.length);
+
+ byte[] firstBlock = new byte[64];
+ stream.preadFully(0, firstBlock, 0, firstBlock.length);
+ assertThat(delegate.openCount()).isEqualTo(1);
+
+ stream.close();
+ assertThat(delegate.unclosedStreamCount()).as("remote streams left
open").isZero();
+
+ // a read that still needs the remote must fail rather than open a
stream with no owner
+ byte[] secondBlock = new byte[64];
+ assertThatThrownBy(() -> stream.preadFully(64, secondBlock, 0,
secondBlock.length))
+ .isInstanceOf(IOException.class)
+ .hasMessageContaining("Stream is closed");
+ assertThat(delegate.openCount()).as("remote streams
opened").isEqualTo(1);
+ assertThat(delegate.unclosedStreamCount()).as("remote streams left
open").isZero();
+
+ // and block 0 is still cached, so without a guard on the read itself
this would quietly
+ // succeed and post-close behaviour would depend on whether a block
happened to be resident
+ assertThatThrownBy(() -> stream.preadFully(0, new byte[64], 0, 64))
+ .isInstanceOf(IOException.class)
+ .hasMessageContaining("Stream is closed");
+ stream.seek(0);
+ assertThatThrownBy(() -> stream.read(new byte[64], 0, 64))
+ .isInstanceOf(IOException.class)
+ .hasMessageContaining("Stream is closed");
+ assertThatThrownBy(stream::read)
+ .isInstanceOf(IOException.class)
+ .hasMessageContaining("Stream is closed");
+ }
+
+ @Test
+ void testCloseDoesNotWaitForAnInFlightOpenAndLeavesNothingBehind() throws
Exception {
+ byte[] data = positionMarkedBytes(256);
+ MockFileIO delegate = new MockFileIO();
+ delegate.addFile("global-index-close-race.index", data);
+ CountDownLatch openGate = new CountDownLatch(1);
+ delegate.blockOpensUntil(openGate);
+
+ CachingSeekableInputStream stream =
+ new CachingSeekableInputStream(
+ delegate,
+ new Path("global-index-close-race.index"),
+ new LocalMemoryCacheManager(Long.MAX_VALUE, 64),
+ "close-race",
+ data.length);
+
+ AtomicReference<Throwable> failure = new AtomicReference<>();
+ Thread reader = readerThread("reader", stream, 0, new byte[64],
failure);
+ Thread closer = new Thread(() -> closeRecordingFailure(stream,
failure), "closer");
+ closer.setDaemon(true);
+
+ try {
+ reader.start();
+ assertThat(awaitCondition(() -> delegate.openCount() == 1,
30_000)).isTrue();
+
+ // the reader is parked inside newInputStream, so a close that
took the initialisation
+ // lock would sit here until the remote gave up. This half does
not regress against the
+ // old code, which took no lock either - it guards against the
obvious wrong fix.
+ closer.start();
+ closer.join(60_000);
+ assertThat(closer.isAlive())
+ .as("close() blocked behind an in-flight remote open")
+ .isFalse();
+ } finally {
+ openGate.countDown();
+ }
+
+ // close is already gone, so the thread that opened the stream has to
hand it back itself
+ reader.join(30_000);
+ assertThat(reader.isAlive()).isFalse();
+ assertThat(failure.get())
+ .as("the reader that lost the race should be told the stream
is closed")
+ .isInstanceOf(IOException.class)
+ .hasMessageContaining("Stream is closed");
+ assertThat(delegate.openCount()).as("remote streams
opened").isEqualTo(1);
+ assertThat(delegate.unclosedStreamCount()).as("remote streams left
open").isZero();
+ }
+
+ @Test
+ void testFailedOpenLeavesNoStateBehind() throws Exception {
+ byte[] data = positionMarkedBytes(256);
+ MockFileIO delegate = new MockFileIO();
+ delegate.addFile("global-index-failed-open.index", data);
+ delegate.failOpensWith(new IOException("boom"));
+
+ CachingSeekableInputStream stream =
+ new CachingSeekableInputStream(
+ delegate,
+ new Path("global-index-failed-open.index"),
+ new LocalMemoryCacheManager(Long.MAX_VALUE, 64),
+ "failed-open",
+ data.length);
+
+ byte[] block = new byte[64];
+ assertThatThrownBy(() -> stream.preadFully(0, block, 0, block.length))
+ .isInstanceOf(IOException.class)
+ .hasMessage("boom");
+ assertThat(delegate.openCount()).isZero();
+
+ // the failure must not have poisoned the stream: a retry opens
exactly once
+ delegate.failOpensWith(null);
+ stream.preadFully(0, block, 0, block.length);
+ assertThat(block).isEqualTo(Arrays.copyOfRange(data, 0, 64));
+ assertThat(delegate.openCount()).as("remote streams
opened").isEqualTo(1);
+
+ stream.close();
+ stream.close();
+ assertThat(delegate.unclosedStreamCount()).as("remote streams left
open").isZero();
+ }
+
+ @Test
+ void testVectoredReadOpensSingleRemoteStream() throws Exception {
+ byte[] data = positionMarkedBytes(VECTOR_STRIDE * VECTOR_RANGES);
+ MockFileIO delegate = new MockFileIO();
+ delegate.addFile("global-index-vectored.index", data);
+ CountDownLatch openGate = new CountDownLatch(1);
+ delegate.blockOpensUntil(openGate);
+
+ // the file size is resolved lazily here, as CachingFileIO does for
the memory cache
+ CachingSeekableInputStream stream =
+ new CachingSeekableInputStream(
+ delegate,
+ new Path("global-index-vectored.index"),
+ new LocalMemoryCacheManager(Long.MAX_VALUE, 512));
+
+ List<FileRange> ranges = vectorRanges();
+ try {
+ VectoredReadUtils.readVectored(stream, ranges,
vectorReadOptions(stream));
+ // with the first opener parked, every other task piles into the
lazy init. A guarded
+ // init can never reach two, so waiting out the full budget here
is the passing case.
+ assertThat(awaitCondition(() -> delegate.openCount() >= 2, 2_000))
+ .as("a second remote stream was opened while the first
open was in flight")
+ .isFalse();
+ } finally {
+ openGate.countDown();
+ }
+
+ for (int i = 0; i < VECTOR_RANGES; i++) {
+ int offset = i * VECTOR_STRIDE;
+ assertThat(ranges.get(i).getData().get(60, TimeUnit.SECONDS))
+ .isEqualTo(Arrays.copyOfRange(data, offset, offset +
VECTOR_LENGTH));
+ }
+
+ stream.close();
+ assertThat(delegate.unclosedStreamCount()).as("remote streams left
open").isZero();
+ assertThat(delegate.openCount()).as("remote streams
opened").isEqualTo(1);
+ }
+
+ @Test
+ void testCloseDuringVectoredFanOutLeavesNothingBehind() throws Exception {
+ byte[] data = positionMarkedBytes(VECTOR_STRIDE * VECTOR_RANGES);
+
+ // the production shutdown shape: the owner closes while pool tasks
are still reading
+ for (int attempt = 0; attempt < 20; attempt++) {
+ MockFileIO delegate = new MockFileIO();
+ delegate.addFile("global-index-close-fanout.index", data);
+ CachingSeekableInputStream stream =
+ new CachingSeekableInputStream(
+ delegate,
+ new Path("global-index-close-fanout.index"),
+ new LocalMemoryCacheManager(Long.MAX_VALUE, 512),
+ "close-fanout-" + attempt,
+ data.length);
+
+ List<FileRange> ranges = vectorRanges();
+ VectoredReadUtils.readVectored(stream, ranges,
vectorReadOptions(stream));
+ stream.close();
+
+ for (int i = 0; i < VECTOR_RANGES; i++) {
+ int offset = i * VECTOR_STRIDE;
+ try {
+ // a range that made it through must still carry its own
bytes, not another's
+ assertThat(ranges.get(i).getData().get(60,
TimeUnit.SECONDS))
+ .isEqualTo(Arrays.copyOfRange(data, offset, offset
+ VECTOR_LENGTH));
+ } catch (ExecutionException e) {
+ // losing the race against close is legitimate, but only
for that reason
+ assertThat(e.getCause())
+ .as("attempt %d, range %d", attempt, i)
+ .isInstanceOf(IOException.class)
+ .hasMessageContaining("closed");
+ }
+ }
+
+ // whichever way the interleaving fell, the accounting has to come
out even
+ assertThat(delegate.openCount())
+ .as("attempt %d: remote streams opened", attempt)
+ .isLessThanOrEqualTo(1);
+ assertThat(delegate.unclosedStreamCount())
+ .as("attempt %d: remote streams left open", attempt)
+ .isZero();
+ }
+ }
+
+ private static List<FileRange> vectorRanges() {
+ List<FileRange> ranges = new ArrayList<>();
+ for (int i = 0; i < VECTOR_RANGES; i++) {
+ ranges.add(FileRange.createFileRange((long) i * VECTOR_STRIDE,
VECTOR_LENGTH));
+ }
+ return ranges;
+ }
+
+ /** The options NativeVectorGlobalIndexReader uses for global index files.
*/
+ private static VectoredReadUtils.ReadOptions vectorReadOptions(
+ CachingSeekableInputStream stream) {
+ // the stride clears the minimum seek, so the ranges stay unmerged and
each becomes its own
+ // task; parallelism must stay >= VECTOR_RANGES or readVectored blocks
the calling thread
+ return VectoredReadUtils.ReadOptions.from(stream)
+ .withMinSeekForVectorReads(16 * 1024)
+ .withParallelismForVectorReads(VECTOR_PARALLELISM)
+ .withSequentialReadFallback(false);
+ }
+
+ private static void closeRecordingFailure(
+ CachingSeekableInputStream stream, AtomicReference<Throwable>
failure) {
+ try {
+ stream.close();
+ } catch (Throwable t) {
+ failure.compareAndSet(null, t);
+ }
+ }
+
+ private static Thread readerThread(
+ String name,
+ CachingSeekableInputStream stream,
+ long position,
+ byte[] buffer,
+ AtomicReference<Throwable> failure) {
+ Thread thread =
+ new Thread(
+ () -> {
+ try {
+ stream.preadFully(position, buffer, 0,
buffer.length);
+ } catch (Throwable t) {
+ failure.compareAndSet(null, t);
+ }
+ },
+ name);
+ thread.setDaemon(true);
+ return thread;
+ }
+
+ /** Returns whether the condition became true before the timeout elapsed.
*/
+ private static boolean awaitCondition(BooleanSupplier condition, long
timeoutMillis)
+ throws InterruptedException {
+ long deadline = System.nanoTime() +
TimeUnit.MILLISECONDS.toNanos(timeoutMillis);
+ while (!condition.getAsBoolean()) {
+ if (System.nanoTime() - deadline >= 0) {
+ return false;
+ }
+ Thread.sleep(1);
+ }
+ return true;
+ }
+
+ /**
+ * Position-dependent filler. A plain {@code (byte) i} repeats every 256
bytes, so every vector
+ * range would hold identical content and a range served another range's
bytes would go
+ * unnoticed - exactly the corruption a shared remote stream can produce.
+ */
+ private static byte[] positionMarkedBytes(int size) {
+ byte[] data = new byte[size];
+ for (int i = 0; i < size; i++) {
+ data[i] = (byte) (i ^ (i >> 5) ^ (i >> 11));
+ }
+ return data;
+ }
+
private byte[] readAll(SeekableInputStream s, int size) throws IOException
{
byte[] buf = new byte[size];
int off = 0;
@@ -615,13 +976,45 @@ class CachingFileIOTest {
new ConcurrentHashMap<>();
private final Map<String, byte[]> files = new HashMap<>();
- private final Map<String, Integer> fileStatusCalls = new HashMap<>();
- private final Map<String, Integer> newInputStreamCalls = new
HashMap<>();
+ // concurrent so the thread-safety tests below can count from several
reader threads
+ private final Map<String, Integer> fileStatusCalls = new
ConcurrentHashMap<>();
+ private final Map<String, Integer> newInputStreamCalls = new
ConcurrentHashMap<>();
+ private final List<ByteArraySeekableInputStream> openedStreams =
+ new CopyOnWriteArrayList<>();
+
+ private final AtomicInteger openCount = new AtomicInteger();
+
+ @Nullable private volatile CountDownLatch openGate;
+ @Nullable private volatile IOException openFailure;
static void resetGlobalInputStreamCalls() {
GLOBAL_INPUT_STREAM_CALLS.clear();
}
+ /** Parks every open inside {@link #newInputStream} until the latch is
counted down. */
+ void blockOpensUntil(CountDownLatch gate) {
+ this.openGate = gate;
+ }
+
+ /** Makes the next opens fail, until cleared with {@code null}. */
+ void failOpensWith(@Nullable IOException failure) {
+ this.openFailure = failure;
+ }
+
+ int openCount() {
+ return openCount.get();
+ }
+
+ int unclosedStreamCount() {
+ int unclosed = 0;
+ for (ByteArraySeekableInputStream stream : openedStreams) {
+ if (!stream.isClosed()) {
+ unclosed++;
+ }
+ }
+ return unclosed;
+ }
+
static int globalInputStreamCallCount(String name) {
AtomicInteger count = GLOBAL_INPUT_STREAM_CALLS.get(name);
return count == null ? 0 : count.get();
@@ -642,6 +1035,11 @@ class CachingFileIOTest {
@Override
public SeekableInputStream newInputStream(Path path) throws
IOException {
String name = path.getName();
+ // rejected before any counter moves, so all of them agree on what
was handed out
+ IOException failure = openFailure;
+ if (failure != null) {
+ throw failure;
+ }
newInputStreamCalls.merge(name, 1, Integer::sum);
GLOBAL_INPUT_STREAM_CALLS
.computeIfAbsent(name, ignored -> new AtomicInteger())
@@ -650,7 +1048,24 @@ class CachingFileIOTest {
if (data == null) {
throw new IOException("File not found: " + name);
}
- return new ByteArraySeekableInputStream(data);
+ // registered before parking, so openCount() never over-reports
what is tracked
+ ByteArraySeekableInputStream stream = new
ByteArraySeekableInputStream(data);
+ openedStreams.add(stream);
+ openCount.incrementAndGet();
+ CountDownLatch gate = openGate;
+ if (gate != null) {
+ try {
+ // far beyond every budget gated behind the countdown, so
a slow machine can
+ // never release the gate early and turn a correct run red
+ if (!gate.await(5, TimeUnit.MINUTES)) {
+ throw new IOException("open gate was never released");
+ }
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new IOException(e);
+ }
+ }
+ return stream;
}
@Override
@@ -723,10 +1138,12 @@ class CachingFileIOTest {
public void configure(CatalogContext context) {}
}
- /** SeekableInputStream backed by a byte array. */
+ /** SeekableInputStream backed by a byte array, recording whether it was
closed. */
private static class ByteArraySeekableInputStream extends
SeekableInputStream {
private final byte[] data;
+ private final AtomicBoolean closed = new AtomicBoolean();
+
private int pos;
ByteArraySeekableInputStream(byte[] data) {
@@ -734,8 +1151,21 @@ class CachingFileIOTest {
this.pos = 0;
}
+ boolean isClosed() {
+ return closed.get();
+ }
+
+ // a real remote stream rejects reads once closed, so this one has to
as well: otherwise no
+ // test could ever observe a read racing a close
+ private void checkNotClosed() throws IOException {
+ if (closed.get()) {
+ throw new IOException("Stream is closed");
+ }
+ }
+
@Override
- public void seek(long desired) {
+ public void seek(long desired) throws IOException {
+ checkNotClosed();
this.pos = (int) Math.max(0, Math.min(desired, data.length));
}
@@ -745,7 +1175,8 @@ class CachingFileIOTest {
}
@Override
- public int read() {
+ public int read() throws IOException {
+ checkNotClosed();
if (pos >= data.length) {
return -1;
}
@@ -753,7 +1184,8 @@ class CachingFileIOTest {
}
@Override
- public int read(byte[] b, int off, int len) {
+ public int read(byte[] b, int off, int len) throws IOException {
+ checkNotClosed();
if (pos >= data.length) {
return -1;
}
@@ -764,6 +1196,8 @@ class CachingFileIOTest {
}
@Override
- public void close() {}
+ public void close() {
+ closed.set(true);
+ }
}
}