Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
c5b1ec4
HDDS-16396. Standardize positioned-read APIs across client streams
peterxcli Sep 27, 2026
5f3141d
HDDS-16396. Allow concurrent EC positioned reads
peterxcli Sep 27, 2026
a300470
HDDS-16396. Use concurrent decryption for multipart positioned reads
peterxcli Sep 27, 2026
0859cf2
HDDS-16396. Remove cursor emulation from positioned reads
peterxcli Sep 27, 2026
242ef5d
HDDS-16396. Report only supported input stream capabilities
peterxcli Oct 1, 2026
3513eb2
HDDS-16396. Cover release of unexpected streaming read clients
peterxcli Oct 1, 2026
0a5b8af
HDDS-16396. Keep merged streaming positioned reads
peterxcli Oct 1, 2026
7fbc445
HDDS-16396. Remove unused filesystem test constant
peterxcli Oct 1, 2026
b81b346
HDDS-16396. Clarify positioned read behavior and capability checks
peterxcli Oct 1, 2026
a78b33b
HDDS-16396. Apply feedback on positioned read comments
peterxcli Oct 1, 2026
2d2e4c7
HDDS-16396. Return zero for empty positioned reads
peterxcli Oct 4, 2026
27fcefe
HDDS-16396. Remove obsolete multipart stream type checks
peterxcli Oct 5, 2026
44f9ebf
HDDS-16396. Simplify positioned read capability check
peterxcli Oct 5, 2026
04c2c04
HDDS-16396. Check positioned read support by interface
peterxcli Oct 5, 2026
cb65829
HDDS-16396. Inline positioned read capability check
peterxcli Oct 5, 2026
105478c
HDDS-16396. Simplify OzoneInputStream capability checks and interfaces
peterxcli Oct 6, 2026
26e95e6
HDDS-16396. Address positioned read review feedback
peterxcli Oct 6, 2026
a4ebc7f
HDDS-16396. Test concurrent reads of regular encrypted keys and use d…
peterxcli Oct 6, 2026
834dce8
HDDS-16396. Merge upstream master
peterxcli Oct 6, 2026
7b6b7e4
Merge branch 'master' into HDDS-16396-positioned-read-api
peterxcli Oct 11, 2026
deee688
HDDS-16396. Fix stream read assertion after master merge
peterxcli Oct 11, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -476,7 +476,7 @@ protected synchronized int readWithStrategy(ByteReaderStrategy strategy)
@Override
public int readPositioned(long blockRelativePosition, ByteBuffer dst) throws IOException {
if (!dst.hasRemaining()) {
return EOF;
return 0;
}
if (blockRelativePosition < 0) {
return EOF;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -446,13 +446,13 @@ protected void readChunkDataIntoBuffers(ChunkInfo readChunkInfo)
* @return number of bytes copied into {@code dst}, or {@link #EOF} at EOF
*/
int readPositioned(long chunkRelativePosition, ByteBuffer dst) throws IOException {
if (!dst.hasRemaining()) {
return 0;
}
if (chunkRelativePosition < 0 || chunkRelativePosition >= length) {
return EOF;
}
final int toRead = (int) Math.min(dst.remaining(), length - chunkRelativePosition);
if (toRead == 0) {
return 0;
}

final ChunkInfo readChunkInfo = getChunkInfo(chunkRelativePosition, toRead);
final long adjustedOffset = readChunkInfo.getOffset() - chunkInfo.getOffset();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,15 @@

package org.apache.hadoop.hdds.scm.storage;

import java.io.EOFException;
import java.io.IOException;
import java.io.InputStream;
import java.nio.ByteBuffer;
import java.nio.ReadOnlyBufferException;
import org.apache.commons.lang3.NotImplementedException;
import org.apache.hadoop.fs.ByteBufferPositionedReadable;
import org.apache.hadoop.fs.ByteBufferReadable;
import org.apache.hadoop.fs.CanUnbuffer;
import org.apache.hadoop.fs.FSInputStream;
import org.apache.hadoop.fs.Seekable;
import org.apache.hadoop.fs.StreamCapabilities;
import org.apache.hadoop.util.StringUtils;
Expand All @@ -31,20 +34,51 @@
* Abstact class which extends InputStream and some common interfaces used by
* various Ozone InputStream classes.
*/
public abstract class ExtendedInputStream extends InputStream
implements Seekable, CanUnbuffer, ByteBufferReadable, StreamCapabilities {
public abstract class ExtendedInputStream extends FSInputStream
implements Seekable, CanUnbuffer, ByteBufferReadable, ByteBufferPositionedReadable, StreamCapabilities {

protected static final int EOF = -1;

@Override
public int read(long position, ByteBuffer buffer) throws IOException {
if (position < 0) {
throw new EOFException("position is negative: " + position);
}
if (buffer.isReadOnly()) {
throw new ReadOnlyBufferException();
}
return buffer.hasRemaining() ? readPositioned(position, buffer) : 0;
}

/**
* Positioned read.
*
* @param position the starting position of the read.
* @param buffer the buffer for storing the data.
* @return true iff positioned read is supported in this implementation.
* Read without changing the sequential cursor. Implementations must allow concurrent calls.
*/
public boolean readFully(long position, ByteBuffer buffer) throws IOException {
return false;
protected abstract int readPositioned(long position, ByteBuffer buffer) throws IOException;

@Override
public void readFully(long position, ByteBuffer buffer) throws IOException {
do {
int n = read(position, buffer);
if (n < 0) {
throw new EOFException("End of stream at position " + position);
}
if (n == 0 && buffer.hasRemaining()) {
throw new IOException("No progress reading at position " + position);
}
position += n;
} while (buffer.hasRemaining());
}

@Override
public int read(long position, byte[] buffer, int offset, int length) throws IOException {
validatePositionedReadArgs(position, buffer, offset, length);
return read(position, ByteBuffer.wrap(buffer, offset, length));
}

@Override
public void readFully(long position, byte[] buffer, int offset, int length) throws IOException {
validatePositionedReadArgs(position, buffer, offset, length);
readFully(position, ByteBuffer.wrap(buffer, offset, length));
}

@Override
Expand Down Expand Up @@ -100,6 +134,7 @@ public synchronized boolean seekToNewSource(long l) throws IOException {
@Override
public boolean hasCapability(String capability) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Advertising PREADBYTEBUFFER in the base class means every present and future
ExtendedInputStream claims it by default, and a subclass that cannot honour it has to
remember to say so. Two already have to, and the resulting override is hard to read:

// ECBlockInputStream:xxx and ECBlockReconstructedInputStream:xxx
return !StreamCapabilities.PREADBYTEBUFFER.equalsIgnoreCase(capability)
    && super.hasCapability(capability);

The previous shape (readFully(long, ByteBuffer) returning false until overridden)
was opt-in, which fails safe. Given that finding #1 above is exactly a
false-positive capability report, I would rather see this as an overridable hook —
e.g. a protected boolean supportsPositionedRead() { return true; } that the two EC
classes return false from, with the switch consulting it — than as a default that two
classes already have to undo.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The proposed hook still defaults to true, so unsupported streams would still need to opt out. I'd keep the existing overrides.

switch (StringUtils.toLowerCase(capability)) {
case StreamCapabilities.PREADBYTEBUFFER:
case StreamCapabilities.READBYTEBUFFER:
case StreamCapabilities.UNBUFFER:
return true;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,6 @@
import java.util.List;
import java.util.Objects;
import org.apache.hadoop.fs.FSExceptionMessages;
import org.apache.ratis.util.Preconditions;

/**
* A stream for accessing multipart streams.
Expand All @@ -39,8 +38,6 @@ public class MultipartInputStream extends ExtendedInputStream {

// List of PartInputStream, one for each part of the key
private final List<? extends PartInputStream> partStreams;
private final boolean isStreamBlockInputStream;
private final boolean statelessPositionedReadSupported;

// partOffsets[i] stores the index of the first data byte in
// partStream w.r.t the whole key data.
Expand All @@ -50,7 +47,7 @@ public class MultipartInputStream extends ExtendedInputStream {
// part[0]), partOffsets[1] = 200 and so on.
private final long[] partOffsets;

private boolean closed;
private volatile boolean closed;
// Index of the partStream corresponding to the current position of the
// MultipartCryptoKeyInputStream.
private int partIndex;
Expand All @@ -67,30 +64,18 @@ public MultipartInputStream(String keyName,

this.key = keyName;
this.partStreams = Collections.unmodifiableList(inputStreams);
this.isStreamBlockInputStream = !inputStreams.isEmpty() && inputStreams.get(0) instanceof StreamBlockInputStream;

// Calculate and update the partOffsets
this.partOffsets = new long[inputStreams.size()];
boolean statelessSupported = !inputStreams.isEmpty();
int i = 0;
long streamLength = 0L;
for (PartInputStream partInputStream : inputStreams) {
this.partOffsets[i++] = streamLength;
if (isStreamBlockInputStream) {
Preconditions.assertInstanceOf(partInputStream, StreamBlockInputStream.class);
} else if (statelessSupported && !(partInputStream instanceof BlockInputStream)) {
statelessSupported = false;
}
streamLength += partInputStream.getLength();
}
this.statelessPositionedReadSupported = statelessSupported;
this.length = streamLength;
}

public boolean isStreamBlockInputStream() {
return isStreamBlockInputStream;
}

@Override
protected synchronized int readWithStrategy(ByteReaderStrategy strategy)
throws IOException {
Expand Down Expand Up @@ -195,45 +180,28 @@ public synchronized void seek(long pos) throws IOException {
}

@Override
public boolean readFully(long position, ByteBuffer buffer) throws IOException {
if (!statelessPositionedReadSupported) {
return false;
}
return readFullyStateless(position, buffer);
}

/**
* Shared stateless positioned-read loop across parts using {@link PartInputStream#readPositioned}.
*/
private boolean readFullyStateless(long position, ByteBuffer buffer) throws IOException {
protected int readPositioned(long position, ByteBuffer buffer) throws IOException {
checkOpen();
if (!buffer.hasRemaining()) {
return true;
return 0;
}

long pos = position;
int bytesRead = 0;
while (buffer.hasRemaining()) {
if (pos < 0 || pos >= length) {
if (bytesRead > 0) {
return true;
}
throw new EOFException("EOF encountered at pos: " + pos +
" for key: " + key);
while (buffer.hasRemaining() && pos < length) {
Comment thread
peterxcli marked this conversation as resolved.
int index = binarySearchOffsetIndex(partOffsets, pos);
PartInputStream part = partStreams.get(index);
long partPosition = pos - partOffsets[index];
final int n = part.read(partPosition, buffer);
if (n < 0) {
break;
}
int idx = binarySearchOffsetIndex(partOffsets, pos);
long partPos = pos - partOffsets[idx];
int n = partStreams.get(idx).readPositioned(partPos, buffer);
if (n <= 0) {
if (bytesRead > 0) {
return true;
}
throw new EOFException("EOF encountered at pos: " + pos +
" for key: " + key);
if (n == 0) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

binarySearchOffsetIndex relies on Arrays.binarySearch, whose result among duplicate
offsets is unspecified. A zero-length part produces a duplicate offset, so the search
can land on it; the clamp at line 197 then sets the limit to the current position,
part.read() correctly returns 0 for an empty buffer, and this throws — for a stream
that is in no way broken. The method created the empty buffer itself, so 0 here is not
a protocol violation.

continue (after advancing past the part) rather than throwing would be correct. Same
available <= 0 guard suggested above covers it.

I could not construct a zero-length part from today's write paths, so this is
robustness rather than a live bug — but the throw is reachable from a state the method
itself produces, which is worth closing cheaply.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The write paths omit zero-length blocks when committing to OM, so we don't need an empty-part guard here. I've removed the buffer clamp and left bounds handling to each part.

throw new IOException("No progress reading key " + key + " at position " + pos);
}
bytesRead += n;
pos += n;
}
return true;
return bytesRead == 0 ? EOF : bytesRead;
}

static int binarySearchOffsetIndex(long[] offsets, long pos) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,32 +18,21 @@
package org.apache.hadoop.hdds.scm.storage;

import java.io.IOException;
import java.nio.ByteBuffer;
import org.apache.hadoop.fs.ByteBufferPositionedReadable;
import org.apache.hadoop.fs.CanUnbuffer;
import org.apache.hadoop.fs.Seekable;

/**
* A stream that can be a part of a {@link MultipartInputStream}.
* Positioned reads must allow concurrent calls without changing the sequential cursor.
*/
public interface PartInputStream
extends CanUnbuffer, Seekable {
extends CanUnbuffer, Seekable, ByteBufferPositionedReadable {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

with ByteBufferPositionedReadable, that looks better.

long getLength();

default long getRemaining() throws IOException {
return getLength() - getPos();
}

void close() throws IOException;

/**
* Positioned read within this part starting at {@code partOffset}.
*
* @return bytes copied into {@code buffer}, {@code -1} if {@code buffer} has no remaining space
* or at EOF
* @throws UnsupportedOperationException if this part stream does not support positioned read
*/
default int readPositioned(long partOffset, ByteBuffer buffer) throws IOException {
throw new UnsupportedOperationException(
"Positioned read is not supported by " + getClass().getName());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -185,12 +185,12 @@ synchronized int readFully(ByteBuffer targetBuf, boolean preRead) throws IOExcep
*
* @param blockOffset the offset within the block to read from.
* @param dst the buffer to read into.
* @return the number of bytes copied into {@code dst}, or -1 if no byte could be read.
* @return the number of bytes copied into {@code dst}, or -1 at EOF.
*/
@Override
public int readPositioned(long blockOffset, ByteBuffer dst) throws IOException {
if (!dst.hasRemaining()) {
return EOF;
return 0;
}
if (blockOffset < 0 || blockOffset >= blockLength) {
return EOF;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
import java.util.TreeMap;
import java.util.function.Function;
import org.apache.hadoop.fs.FSExceptionMessages;
import org.apache.hadoop.fs.StreamCapabilities;
import org.apache.hadoop.hdds.client.BlockID;
import org.apache.hadoop.hdds.client.ContainerBlockID;
import org.apache.hadoop.hdds.client.ECReplicationConfig;
Expand Down Expand Up @@ -300,6 +301,17 @@ protected long remaining() {
return blockLength() - position;
}

@Override
protected int readPositioned(long offset, ByteBuffer buffer) {
throw new UnsupportedOperationException("Use ECBlockInputStreamProxy for positioned reads");
}

@Override
public boolean hasCapability(String capability) {
return !StreamCapabilities.PREADBYTEBUFFER.equalsIgnoreCase(capability)
&& super.hasCapability(capability);
}

@Override
public synchronized int read(byte[] b, int off, int len) throws IOException {
return read(ByteBuffer.wrap(b, off, len));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,10 @@

import java.io.IOException;
import java.nio.ByteBuffer;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.function.Function;
import org.apache.hadoop.hdds.client.BlockID;
import org.apache.hadoop.hdds.client.ECReplicationConfig;
Expand Down Expand Up @@ -56,8 +56,8 @@ public class ECBlockInputStreamProxy extends BlockExtendedInputStream {

private BlockExtendedInputStream blockReader;
private boolean reconstructionReader = false;
private List<DatanodeDetails> failedLocations = new ArrayList<>();
private boolean closed = false;
private final CopyOnWriteArrayList<DatanodeDetails> failedLocations;
private volatile boolean closed = false;
private OzoneClientConfig config;

/**
Expand Down Expand Up @@ -102,12 +102,21 @@ public ECBlockInputStreamProxy(ECReplicationConfig repConfig,
BlockLocationInfo> refreshFunction,
ECBlockInputStreamFactory streamFactory,
OzoneClientConfig config) {
this(repConfig, blockInfo, xceiverClientFactory, refreshFunction, streamFactory, config,
new CopyOnWriteArrayList<>());
}

private ECBlockInputStreamProxy(ECReplicationConfig repConfig, BlockLocationInfo blockInfo,
XceiverClientFactory xceiverClientFactory, Function<BlockID, BlockLocationInfo> refreshFunction,
ECBlockInputStreamFactory streamFactory, OzoneClientConfig config,
CopyOnWriteArrayList<DatanodeDetails> failedLocations) {
this.repConfig = repConfig;
this.blockInfo = blockInfo;
this.ecBlockInputStreamFactory = streamFactory;
this.xceiverClientFactory = xceiverClientFactory;
this.refreshFunction = refreshFunction;
this.config = config;
this.failedLocations = failedLocations;

setReaderType();
createBlockReader();
Expand All @@ -116,9 +125,10 @@ public ECBlockInputStreamProxy(ECReplicationConfig repConfig,
private synchronized void setReaderType() {
int expected = expectedDataLocations(repConfig, getLength());
int available = availableDataLocations(blockInfo.getPipeline(), expected);
reconstructionReader = available < expected;
reconstructionReader = available < expected || !failedLocations.isEmpty();
if (reconstructionReader) {
LOG.info("Data locations available: {} < expected: {}, using reconstruction read", available, expected);
LOG.info("Data locations available: {}, expected: {}, failed: {}, using reconstruction read",
available, expected, failedLocations.size());
}
}

Expand Down Expand Up @@ -197,11 +207,28 @@ public synchronized int read(ByteBuffer buf) throws IOException {
return totalRead;
}

@Override
protected int readPositioned(long position, ByteBuffer buffer) throws IOException {
ensureNotClosed();
if (!buffer.hasRemaining()) {
return 0;
}
if (position >= blockInfo.getLength()) {
return EOF;
}
// Each request owns its cursor and reconstruction buffers; failed locations are shared.
try (ECBlockInputStreamProxy reader = new ECBlockInputStreamProxy(repConfig, blockInfo,
Comment thread
peterxcli marked this conversation as resolved.
xceiverClientFactory, refreshFunction, ecBlockInputStreamFactory, config, failedLocations)) {
reader.seek(position);
return reader.read(buffer);
}
}

private synchronized void failoverToReconstructionRead(
List<DatanodeDetails> badLocations, long lastPosition)
throws IOException {
if (badLocations != null) {
failedLocations.addAll(badLocations);
failedLocations.addAllAbsent(badLocations);
}
blockReader.close();
reconstructionReader = true;
Expand Down
Loading
Loading