diff --git a/hadoop-ozone/integration-test/src/test/java/org/apache/hadoop/fs/ozone/TestOzoneFSInputStream.java b/hadoop-ozone/integration-test/src/test/java/org/apache/hadoop/fs/ozone/TestOzoneFSInputStream.java index c4073167faa..e704f289770 100644 --- a/hadoop-ozone/integration-test/src/test/java/org/apache/hadoop/fs/ozone/TestOzoneFSInputStream.java +++ b/hadoop-ozone/integration-test/src/test/java/org/apache/hadoop/fs/ozone/TestOzoneFSInputStream.java @@ -203,7 +203,13 @@ public void testByteBufferPositionedReadWithInvalidPosition(boolean isStreamEnab FSDataInputStream inputStream = fs.open(filePath)) { long currentPos = inputStream.getPos(); ByteBuffer buffer = ByteBuffer.allocate(20); - assertEquals(-1, inputStream.read(position, buffer)); + if (position < 0) { + // Negative position must throw EOFException (aligned with the byte-array PositionedReadable contract). + assertThrows(EOFException.class, () -> inputStream.read(position, buffer)); + } else { + // Position at or past EOF must return -1. + assertEquals(-1, inputStream.read(position, buffer)); + } // File position should not be changed assertEquals(currentPos, inputStream.getPos()); } diff --git a/hadoop-ozone/ozonefs-common/pom.xml b/hadoop-ozone/ozonefs-common/pom.xml index fe87bf574e8..e08b43268e8 100644 --- a/hadoop-ozone/ozonefs-common/pom.xml +++ b/hadoop-ozone/ozonefs-common/pom.xml @@ -94,6 +94,12 @@ org.slf4j slf4j-api + + org.apache.ozone + hdds-client + test-jar + test + diff --git a/hadoop-ozone/ozonefs-common/src/main/java/org/apache/hadoop/fs/ozone/OzoneFSInputStream.java b/hadoop-ozone/ozonefs-common/src/main/java/org/apache/hadoop/fs/ozone/OzoneFSInputStream.java index a9c2c8b2f0f..1b8e5e9c9db 100644 --- a/hadoop-ozone/ozonefs-common/src/main/java/org/apache/hadoop/fs/ozone/OzoneFSInputStream.java +++ b/hadoop-ozone/ozonefs-common/src/main/java/org/apache/hadoop/fs/ozone/OzoneFSInputStream.java @@ -35,9 +35,17 @@ /** * The input stream for Ozone file system. - * - * TODO: Make inputStream generic for both rest and rpc clients - * This class is not thread safe. + *

+ * Sequential reads are NOT thread safe. + *

+ * Positioned reads are thread safe. + * When the underlying stream is an {@link ExtendedInputStream} and + * when it supports {@link ExtendedInputStream#readFully(long, ByteBuffer)}, + * they delegate to it. + * Otherwise, they fall back to the default synchronized seek-read-restore implementation. + *

+ * Applications must use either sequential reads or position reads at any given time, + * but not concurrent sequential/position reads */ @InterfaceAudience.Private @InterfaceStability.Evolving @@ -46,6 +54,7 @@ public class OzoneFSInputStream extends FSInputStream private final InputStream inputStream; private final Statistics statistics; + private final Object positionedReadLock = new Object(); public OzoneFSInputStream(InputStream inputStream, Statistics statistics) { this.inputStream = inputStream; @@ -158,57 +167,161 @@ public void unbuffer() { } /** - * @param buf the ByteBuffer to receive the results of the read operation. + * @param buffer to receive the results of the read operation. * @param position offset * @return the number of bytes read, possibly zero, or -1 if * reach end-of-stream * @throws IOException if there is some error performing the read */ @Override - public int read(long position, ByteBuffer buf) throws IOException { - if (!buf.hasRemaining()) { + public int read(long position, ByteBuffer buffer) throws IOException { + if (!buffer.hasRemaining()) { return 0; } - if (inputStream instanceof ExtendedInputStream) { - final int remainingBeforeRead = buf.remaining(); - try { - if (((ExtendedInputStream) inputStream).readFully(position, buf)) { - return remainingBeforeRead - buf.remaining(); - } - } catch (EOFException e) { - return -1; + if (position < 0) { + throw new EOFException("position is negative: " + position); + } + return readImpl(position, buffer); + } + + @Override + public int read(long position, byte[] array, int offset, int length) throws IOException { + if (length == 0) { + return 0; + } + validatePositionedReadArgs(position, array, offset, length); + return readImpl(position, ByteBuffer.wrap(array, offset, length)); + } + + private int readImpl(long position, ByteBuffer buffer) throws IOException { + final Integer n = bestEffortExtendedInputStreamRead(position, buffer, false); + if (n != null) { + return n; + } + // Fallback: stateful seek-read-restore on the shared cursor. + return bestEffortReadFullySynchronized(position, buffer); + } + + /** + * @param buffer to receive the results of the read operation. + * @param position offset + * @throws IOException if there is some error performing the read + * @throws EOFException if end of file reached before reading fully + */ + @Override + public void readFully(long position, ByteBuffer buffer) throws IOException { + if (!buffer.hasRemaining()) { + return; + } + if (position < 0) { + throw new EOFException("position is negative: " + position); + } + readFullyImpl(position, buffer); + } + + @Override + public void readFully(long position, byte[] array, int offset, int length) throws IOException { + if (length == 0) { + return; + } + validatePositionedReadArgs(position, array, offset, length); + readFullyImpl(position, ByteBuffer.wrap(array, offset, length)); + } + + @Override + public void readFully(long position, byte[] buffer) throws IOException { + readFully(position, buffer, 0, buffer.length); + } + + private void readFullyImpl(long position, ByteBuffer buffer) throws IOException { + final int length = buffer.remaining(); + final Integer n = bestEffortExtendedInputStreamRead(position, buffer, true); + if (n != null) { + if (n < length) { + throw new EOFException("Failed to read fully length " + length + " at position " + position); } + return; } + final int m = bestEffortReadFullySynchronized(position, buffer); + if (m < length) { + throw new EOFException("Failed to read fully length " + length + " at position " + position); + } + } - long oldPos = this.getPos(); - int bytesRead; + /** + * Best effort read via {@link ExtendedInputStream#readFully(long, ByteBuffer)}. + * + * @return the number of bytes read; or null when the native stateless path is unsupported. + */ + private Integer bestEffortExtendedInputStreamRead(long position, ByteBuffer buffer, + boolean isReadFully) throws IOException { + if (!(inputStream instanceof ExtendedInputStream)) { + return null; // not an ExtendedInputStream + } + final int remainingBeforeRead = buffer.remaining(); try { - ((Seekable) inputStream).seek(position); - bytesRead = ((ByteBufferReadable) inputStream).read(buf); + if (!((ExtendedInputStream) inputStream).readFully(position, buffer)) { + return null; // ExtendedInputStream does not support readFully + } } catch (EOFException e) { - // Either position is negative or it has reached EOF + if (isReadFully) { + throw e; + } + // read() semantics: EOF -> -1 (fall through to bytesRead == 0 check below) + } + final int bytesRead = remainingBeforeRead - buffer.remaining(); + if (bytesRead == 0) { return -1; - } finally { - ((Seekable) inputStream).seek(oldPos); + } + if (statistics != null) { + statistics.incrementBytesRead(bytesRead); } return bytesRead; } /** - * @param buf the ByteBuffer to receive the results of the read operation. - * @param position offset - * @throws IOException if there is some error performing the read - * @throws EOFException if end of file reached before reading fully + * Fallback positioned read via synchronized seek-read-restore. + * Uses {@link #read(ByteBuffer)} -- which handles streams that do not implement + * {@link org.apache.hadoop.fs.ByteBufferReadable} (e.g. GDPR + * {@code javax.crypto.CipherInputStream}) -- so this works for any {@link Seekable} + * inner stream. + * + * @return the number of bytes read */ - @Override - public void readFully(long position, ByteBuffer buf) throws IOException { - int bytesRead; - for (int readCount = 0; buf.hasRemaining(); readCount += bytesRead) { - bytesRead = this.read(position + (long)readCount, buf); - if (bytesRead < 0) { - // Still buffer has space to read but stream has already reached EOF - throw new EOFException("End of file reached before reading fully."); + private int bestEffortReadFullySynchronized(long position, ByteBuffer buffer) throws IOException { + synchronized (positionedReadLock) { + final long oldPos = getPos(); + try { + ((Seekable) inputStream).seek(position); + final int n = bestEffortRead(buffer); + if (statistics != null) { + statistics.incrementBytesRead(n); + } + return n; + } finally { + ((Seekable) inputStream).seek(oldPos); } } } + + /** + * Best effort read to fill up the buffer using {@link #read(ByteBuffer)}. + * + * @return the number of bytes read + */ + private int bestEffortRead(ByteBuffer buffer) throws IOException { + int readLength = 0; + try { + while (buffer.hasRemaining()) { + final int n = read(buffer); + if (n < 0) { + return readLength; + } + readLength += n; + } + return readLength; + } catch (EOFException e) { + return readLength; + } + } } diff --git a/hadoop-ozone/ozonefs-common/src/test/java/org/apache/hadoop/fs/ozone/TestOzoneFSInputStream.java b/hadoop-ozone/ozonefs-common/src/test/java/org/apache/hadoop/fs/ozone/TestOzoneFSInputStream.java index 86df63949b5..6954547e739 100644 --- a/hadoop-ozone/ozonefs-common/src/test/java/org/apache/hadoop/fs/ozone/TestOzoneFSInputStream.java +++ b/hadoop-ozone/ozonefs-common/src/test/java/org/apache/hadoop/fs/ozone/TestOzoneFSInputStream.java @@ -17,8 +17,10 @@ package org.apache.hadoop.fs.ozone; +import static org.apache.hadoop.hdds.scm.storage.PositionedReadTestHelper.SOURCE_SIZE; import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.anyString; import static org.mockito.Mockito.mock; @@ -28,6 +30,7 @@ import com.google.common.collect.ImmutableList; import java.io.ByteArrayInputStream; +import java.io.EOFException; import java.io.IOException; import java.io.InputStream; import java.nio.ByteBuffer; @@ -42,15 +45,22 @@ import org.apache.hadoop.crypto.CryptoInputStream; import org.apache.hadoop.crypto.Decryptor; import org.apache.hadoop.fs.FileSystem; +import org.apache.hadoop.fs.Seekable; import org.apache.hadoop.fs.StreamCapabilities; +import org.apache.hadoop.hdds.scm.storage.ByteReaderStrategy; +import org.apache.hadoop.hdds.scm.storage.ExtendedInputStream; +import org.apache.hadoop.hdds.scm.storage.PositionedReadTestHelper; import org.apache.hadoop.ozone.client.io.KeyInputStream; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; /** * Tests for {@link OzoneFSInputStream}. */ public class TestOzoneFSInputStream { + private static final byte CORRUPT_BYTE = (byte) 0x5A; + private static final List> BUFFER_CONSTRUCTORS = ImmutableList.of(ByteBuffer::allocate, ByteBuffer::allocateDirect); @@ -187,4 +197,293 @@ public int read() { }; } + @Test + public void testByteBufferPositionedReadNegativePositionThrows() throws Exception { + // read(long, ByteBuffer) must throw EOFException for negative positions, + // aligning with the byte-array PositionedReadable behaviour. + final byte[] source = RandomUtils.secure().randomBytes(SOURCE_SIZE); + final InterleavingSeekableInputStream underlying = + new InterleavingSeekableInputStream(source); + try (OzoneFSInputStream subject = new OzoneFSInputStream(underlying, + new FileSystem.Statistics("test"))) { + ByteBuffer buf = ByteBuffer.allocate(16); + assertThrows(EOFException.class, () -> subject.read(-1L, buf)); + } + } + + @Test + @Timeout(value = 30) + public void testByteArrayFallbackWorksForSeekableOnlyStream() throws Exception { + // Regression: the old routing through read(long, ByteBuffer) would cast to + // ByteBufferReadable in readAtPositionSeekRestore and throw ClassCastException + // for a stream that only implements Seekable (not ByteBufferReadable). + final byte[] source = RandomUtils.secure().randomBytes(SOURCE_SIZE); + final SeekableOnlyInputStream underlying = new SeekableOnlyInputStream(source); + try (OzoneFSInputStream subject = new OzoneFSInputStream(underlying, + new FileSystem.Statistics("test"))) { + PositionedReadTestHelper.runConcurrentPositionedReads(source, (offset, buf) -> { + byte[] arr = new byte[buf.remaining()]; + subject.readFully(offset, arr); + buf.put(arr); + }); + } + } + + @Test + @Timeout(value = 30) + public void testConcurrentPositionedRead() throws Exception { + final byte[] source = RandomUtils.secure().randomBytes(SOURCE_SIZE); + final InterleavingSeekableInputStream underlying = + new InterleavingSeekableInputStream(source); + try (OzoneFSInputStream subject = new OzoneFSInputStream(underlying, + new FileSystem.Statistics("test"))) { + PositionedReadTestHelper.runConcurrentPositionedReads(source, + (offset, buf) -> subject.readFully(offset, buf)); + } + } + + @Test + @Timeout(value = 30) + public void testConcurrentPositionedReadEcFallback() throws Exception { + final byte[] source = RandomUtils.secure().randomBytes(SOURCE_SIZE); + final EcInterleavingInputStream underlying = + new EcInterleavingInputStream(source); + try (OzoneFSInputStream subject = new OzoneFSInputStream(underlying, + new FileSystem.Statistics("test"))) { + PositionedReadTestHelper.runConcurrentPositionedReads(source, + (offset, buf) -> subject.readFully(offset, buf)); + } + } + + @Test + @Timeout(value = 30) + public void testConcurrentByteArrayPositionedReadEcFallback() throws Exception { + final byte[] source = RandomUtils.secure().randomBytes(SOURCE_SIZE); + final EcInterleavingInputStream underlying = + new EcInterleavingInputStream(source); + try (OzoneFSInputStream subject = new OzoneFSInputStream(underlying, + new FileSystem.Statistics("test"))) { + PositionedReadTestHelper.runConcurrentPositionedReads(source, (offset, buf) -> { + byte[] arr = new byte[buf.remaining()]; + subject.readFully(offset, arr); + buf.put(arr); + }); + } + } + + @Test + @Timeout(value = 30) + public void testConcurrentMixedApiEcFallback() throws Exception { + // ByteBuffer and byte-array callers share positionedReadLock; verify no interleaving. + final byte[] source = RandomUtils.secure().randomBytes(SOURCE_SIZE); + final EcInterleavingInputStream underlying = + new EcInterleavingInputStream(source); + try (OzoneFSInputStream subject = new OzoneFSInputStream(underlying, + new FileSystem.Statistics("test"))) { + PositionedReadTestHelper.runConcurrentPositionedReads(source, (offset, buf) -> { + if ((offset & 1) == 0) { + subject.readFully(offset, buf); + } else { + byte[] arr = new byte[buf.remaining()]; + subject.readFully(offset, arr); + buf.put(arr); + } + }); + } + } + + /** + * Mimics KeyInputStream synchronized per-operation seek/read where multi-steps + * positioned reads must still be serialized at the FS layer. + */ + private static final class InterleavingSeekableInputStream extends InputStream + implements Seekable, org.apache.hadoop.fs.ByteBufferReadable { + + private final InterleavingReadState readState; + + private InterleavingSeekableInputStream(byte[] data) { + this.readState = new InterleavingReadState(data); + } + + @Override + public synchronized void seek(long p) { + readState.seek(p); + } + + @Override + public synchronized long getPos() { + return readState.getPos(); + } + + @Override + public synchronized boolean seekToNewSource(long targetPos) { + return false; + } + + @Override + public int read() { + return -1; + } + + @Override + public synchronized int read(ByteBuffer buf) { + return readState.read(buf); + } + } + + /** + * Mimics an erasure-coded key stream: {@link ExtendedInputStream#readFully} + * returns {@code false}, so {@link OzoneFSInputStream} falls back to + * seek-read-restore on the shared cursor. + */ + private static final class EcInterleavingInputStream extends ExtendedInputStream { + + private final InterleavingReadState readState; + + private EcInterleavingInputStream(byte[] data) { + this.readState = new InterleavingReadState(data); + } + + @Override + protected int readWithStrategy(ByteReaderStrategy strategy) { + throw new UnsupportedOperationException(); + } + + @Override + public synchronized void seek(long p) { + readState.seek(p); + } + + @Override + public synchronized long getPos() { + return readState.getPos(); + } + + @Override + public synchronized boolean seekToNewSource(long targetPos) { + return false; + } + + @Override + public synchronized int read(ByteBuffer buf) { + return readState.read(buf); + } + + @Override + public synchronized int read(byte[] b, int off, int len) { + return readState.read(b, off, len); + } + + @Override + public void unbuffer() { + return; + } + } + + /** + * A Seekable stream that does NOT implement ByteBufferReadable. Used to verify + * that the byte-array positioned-read fallback uses read(byte[]) rather than + * casting to ByteBufferReadable (which would throw ClassCastException). + */ + private static final class SeekableOnlyInputStream extends InputStream + implements Seekable { + + private final byte[] data; + private int pos; + + private SeekableOnlyInputStream(byte[] data) { + this.data = data; + } + + @Override + public synchronized int read() { + return pos < data.length ? (data[pos++] & 0xFF) : -1; + } + + @Override + public synchronized int read(byte[] b, int off, int len) { + if (pos >= data.length) { + return -1; + } + int n = Math.min(len, data.length - pos); + System.arraycopy(data, pos, b, off, n); + pos += n; + return n; + } + + @Override + public synchronized int available() { + return data.length - pos; + } + + @Override + public synchronized void seek(long newPos) { + pos = (int) newPos; + } + + @Override + public synchronized long getPos() { + return pos; + } + + @Override + public boolean seekToNewSource(long targetPos) { + return false; + } + } + + private static final class InterleavingReadState { + private final byte[] data; + private long pos; + private final ThreadLocal expectedReadPos = new ThreadLocal<>(); + + private InterleavingReadState(byte[] data) { + this.data = data; + } + + private void seek(long p) { + pos = p; + expectedReadPos.set(p); + } + + private long getPos() { + return pos; + } + + private int read(ByteBuffer buf) { + Long expected = expectedReadPos.get(); + if (expected != null && pos != expected) { + int len = buf.remaining(); + for (int i = 0; i < len; i++) { + buf.put(CORRUPT_BYTE); + } + return len; + } + int toRead = Math.min(buf.remaining(), data.length - (int) pos); + if (toRead <= 0) { + return -1; + } + buf.put(data, (int) pos, toRead); + pos += toRead; + expectedReadPos.remove(); + return toRead; + } + + private int read(byte[] b, int off, int len) { + Long expected = expectedReadPos.get(); + if (expected != null && pos != expected) { + java.util.Arrays.fill(b, off, off + len, CORRUPT_BYTE); + return len; + } + int toRead = Math.min(len, data.length - (int) pos); + if (toRead <= 0) { + return -1; + } + System.arraycopy(data, (int) pos, b, off, toRead); + pos += toRead; + expectedReadPos.remove(); + return toRead; + } + } + }