This is an automated email from the ASF dual-hosted git repository.

szetszwo pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/ratis.git


The following commit(s) were added to refs/heads/master by this push:
     new abfe70f34 RATIS-2546. Add input stream to DataStreamApi for read 
operations in Client (#1481)
abfe70f34 is described below

commit abfe70f34cc63b66049ed13d880232972c2f7e3d
Author: Peter Lee <[email protected]>
AuthorDate: Wed Jun 24 00:49:26 2026 +0800

    RATIS-2546. Add input stream to DataStreamApi for read operations in Client 
(#1481)
---
 .../apache/ratis/client/DataStreamClientRpc.java   |  15 +++
 .../org/apache/ratis/client/api/DataStreamApi.java |   8 ++
 .../DataStreamInput.java}                          |  25 ++--
 .../ratis/client/impl/DataStreamClientImpl.java    |   7 +
 .../ratis/client/impl/DataStreamInputImpl.java     | 143 ++++++++++++++++++++
 .../ratis/datastream/DataStreamObserver.java       |  39 ++++++
 .../datastream/impl/DataStreamPacketByteBuf.java   |   5 +-
 .../datastream/impl/DataStreamReplyByteBuf.java    |  15 +++
 .../java/org/apache/ratis/util/Preconditions.java  |   3 +-
 .../apache/ratis/netty/NettyDataStreamUtils.java   |  24 ++++
 .../ratis/netty/client/ClientReadStream.java       |  80 +++++++++++
 .../ratis/netty/client/NettyClientReplies.java     |   3 +-
 .../ratis/netty/client/NettyClientStreamRpc.java   |  86 +++++++++++-
 .../client/impl/TestDataStreamClientImpl.java      | 146 +++++++++++++++++++++
 .../ratis/datastream/DataStreamClusterTests.java   |  73 +++++++++++
 .../netty/server/TestDataStreamManagement.java     | 129 ++++++++++++++----
 16 files changed, 754 insertions(+), 47 deletions(-)

diff --git 
a/ratis-client/src/main/java/org/apache/ratis/client/DataStreamClientRpc.java 
b/ratis-client/src/main/java/org/apache/ratis/client/DataStreamClientRpc.java
index a9bcd9d58..6f94709b0 100644
--- 
a/ratis-client/src/main/java/org/apache/ratis/client/DataStreamClientRpc.java
+++ 
b/ratis-client/src/main/java/org/apache/ratis/client/DataStreamClientRpc.java
@@ -18,9 +18,11 @@
 
 package org.apache.ratis.client;
 
+import org.apache.ratis.datastream.DataStreamObserver;
 import org.apache.ratis.protocol.DataStreamReply;
 import org.apache.ratis.protocol.DataStreamRequest;
 import org.apache.ratis.util.JavaUtils;
+import org.apache.ratis.util.ReferenceCountedObject;
 
 import java.io.Closeable;
 import java.util.concurrent.CompletableFuture;
@@ -36,4 +38,17 @@ public interface DataStreamClientRpc extends Closeable {
     throw new UnsupportedOperationException(getClass() + " does not support "
         + JavaUtils.getCurrentStackTraceElement().getMethodName());
   }
+
+  /**
+   * Async call to send a request to receive a stream of intermediate replies 
and a final reply.
+   *
+   * @param request the request
+   * @param replyHandler to handle intermediate replies
+   * @return a future the final reply
+   */
+  default CompletableFuture<DataStreamReply> streamAsync(DataStreamRequest 
request,
+      DataStreamObserver<ReferenceCountedObject<DataStreamReply>> 
replyHandler) {
+    throw new UnsupportedOperationException(getClass() + " does not support "
+        + JavaUtils.getCurrentStackTraceElement().getMethodName());
+  }
 }
diff --git 
a/ratis-client/src/main/java/org/apache/ratis/client/api/DataStreamApi.java 
b/ratis-client/src/main/java/org/apache/ratis/client/api/DataStreamApi.java
index 9e5e2438c..7152d35ba 100644
--- a/ratis-client/src/main/java/org/apache/ratis/client/api/DataStreamApi.java
+++ b/ratis-client/src/main/java/org/apache/ratis/client/api/DataStreamApi.java
@@ -50,4 +50,12 @@ public interface DataStreamApi extends Closeable {
 
   /** Create a stream by providing a customized header message and route 
table. */
   DataStreamOutput stream(ByteBuffer headerMessage, RoutingTable routingTable);
+
+  /** Create a stream to read data. */
+  default DataStreamInput streamReadOnly() {
+    return streamReadOnly(null);
+  }
+
+  /** Create a stream by providing a customized header message for reading 
data. */
+  DataStreamInput streamReadOnly(ByteBuffer headerMessage);
 }
diff --git 
a/ratis-client/src/main/java/org/apache/ratis/client/DataStreamClientRpc.java 
b/ratis-client/src/main/java/org/apache/ratis/client/api/DataStreamInput.java
similarity index 56%
copy from 
ratis-client/src/main/java/org/apache/ratis/client/DataStreamClientRpc.java
copy to 
ratis-client/src/main/java/org/apache/ratis/client/api/DataStreamInput.java
index a9bcd9d58..e50033405 100644
--- 
a/ratis-client/src/main/java/org/apache/ratis/client/DataStreamClientRpc.java
+++ 
b/ratis-client/src/main/java/org/apache/ratis/client/api/DataStreamInput.java
@@ -15,25 +15,24 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-
-package org.apache.ratis.client;
+package org.apache.ratis.client.api;
 
 import org.apache.ratis.protocol.DataStreamReply;
-import org.apache.ratis.protocol.DataStreamRequest;
-import org.apache.ratis.util.JavaUtils;
+import org.apache.ratis.util.ReferenceCountedObject;
 
 import java.io.Closeable;
 import java.util.concurrent.CompletableFuture;
 
 /**
- * A client interface for sending stream requests.
- * The underlying implementation is pluggable, depending on the {@link 
org.apache.ratis.datastream.DataStreamType}.
- * The implementations of this interface define how the requests are 
transported to the server.
+ * An asynchronous input stream supporting zero buffer copying.
  */
-public interface DataStreamClientRpc extends Closeable {
-  /** Async call to send a request. */
-  default CompletableFuture<DataStreamReply> streamAsync(DataStreamRequest 
request) {
-    throw new UnsupportedOperationException(getClass() + " does not support "
-        + JavaUtils.getCurrentStackTraceElement().getMethodName());
-  }
+public interface DataStreamInput extends Closeable {
+  /**
+   * Read the next chunk in the stream asynchronously.
+   * The caller owns the returned {@link DataStreamReply} which is a {@link 
ReferenceCountedObject}.
+   * It must call {@link ReferenceCountedObject#release()} after consuming it.
+   *
+   * @return a future of the reference-counted reply.
+   */
+  CompletableFuture<ReferenceCountedObject<DataStreamReply>> readAsync();
 }
diff --git 
a/ratis-client/src/main/java/org/apache/ratis/client/impl/DataStreamClientImpl.java
 
b/ratis-client/src/main/java/org/apache/ratis/client/impl/DataStreamClientImpl.java
index 3c055009f..bc8334bf9 100644
--- 
a/ratis-client/src/main/java/org/apache/ratis/client/impl/DataStreamClientImpl.java
+++ 
b/ratis-client/src/main/java/org/apache/ratis/client/impl/DataStreamClientImpl.java
@@ -23,6 +23,7 @@ import org.apache.ratis.client.DataStreamClient;
 import org.apache.ratis.client.DataStreamClientRpc;
 import org.apache.ratis.client.DataStreamOutputRpc;
 import org.apache.ratis.client.RaftClient;
+import org.apache.ratis.client.api.DataStreamInput;
 import org.apache.ratis.conf.RaftProperties;
 import org.apache.ratis.datastream.impl.DataStreamPacketByteBuffer;
 import org.apache.ratis.datastream.impl.DataStreamReplyByteBuffer;
@@ -265,6 +266,12 @@ public class DataStreamClientImpl implements 
DataStreamClient {
         .build());
   }
 
+  @Override
+  public DataStreamInput streamReadOnly(ByteBuffer headerMessage) {
+    return new DataStreamInputImpl(dataStreamClientRpc,
+        
newBuilder(headerMessage).setType(RaftClientRequest.readRequestType()).build());
+  }
+
   private RaftClientRequest.Builder newBuilder(ByteBuffer headerMessage) {
     final Message message = headerMessage == null ? null : 
Message.valueOf(headerMessage);
     return RaftClientRequest.newBuilder()
diff --git 
a/ratis-client/src/main/java/org/apache/ratis/client/impl/DataStreamInputImpl.java
 
b/ratis-client/src/main/java/org/apache/ratis/client/impl/DataStreamInputImpl.java
new file mode 100644
index 000000000..f1e38df88
--- /dev/null
+++ 
b/ratis-client/src/main/java/org/apache/ratis/client/impl/DataStreamInputImpl.java
@@ -0,0 +1,143 @@
+/*
+ * 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.ratis.client.impl;
+
+import org.apache.ratis.client.DataStreamClientRpc;
+import org.apache.ratis.client.api.DataStreamInput;
+import org.apache.ratis.datastream.DataStreamObserver;
+import org.apache.ratis.datastream.impl.DataStreamRequestByteBuffer;
+import org.apache.ratis.io.StandardWriteOption;
+import org.apache.ratis.proto.RaftProtos.DataStreamPacketHeaderProto.Type;
+import org.apache.ratis.protocol.ClientId;
+import org.apache.ratis.protocol.DataStreamReply;
+import org.apache.ratis.protocol.DataStreamRequestHeader;
+import org.apache.ratis.protocol.RaftClientRequest;
+import org.apache.ratis.protocol.exceptions.AlreadyClosedException;
+import org.apache.ratis.util.JavaUtils;
+import org.apache.ratis.util.Preconditions;
+import org.apache.ratis.util.ReferenceCountedObject;
+
+import java.io.EOFException;
+import java.nio.ByteBuffer;
+import java.util.LinkedList;
+import java.util.Objects;
+import java.util.Queue;
+import java.util.concurrent.CompletableFuture;
+
+final class DataStreamInputImpl implements DataStreamInput,
+    DataStreamObserver<ReferenceCountedObject<DataStreamReply>> {
+  private final RaftClientRequest header;
+  private final ClientId clientId;
+  private final Queue<ReferenceCountedObject<DataStreamReply>> replies = new 
LinkedList<>();
+  private final 
Queue<CompletableFuture<ReferenceCountedObject<DataStreamReply>>> pendingReads 
= new LinkedList<>();
+
+  /*
+   * null                  : the stream is open.
+   * EOFException          : the stream is ended, i.e. no more data.
+   * AlreadyClosedException: the stream is closed by the caller.
+   * Other exception       : the stream is failed.
+   */
+  private Throwable readException;
+
+  DataStreamInputImpl(DataStreamClientRpc dataStreamClientRpc, 
RaftClientRequest request) {
+    this.header = request;
+    this.clientId = request.getClientId();
+    final ByteBuffer buffer = 
ClientProtoUtils.toRaftClientRequestProtoByteBuffer(header);
+    final DataStreamRequestHeader h = new DataStreamRequestHeader(clientId, 
Type.STREAM_HEADER,
+        header.getCallId(), 0, buffer.remaining(), StandardWriteOption.FLUSH, 
StandardWriteOption.CLOSE);
+    dataStreamClientRpc.streamAsync(new DataStreamRequestByteBuffer(h, 
buffer), this)
+        .whenComplete(asWhenCompleteBiConsumer());
+  }
+
+  @Override
+  public synchronized void onNext(ReferenceCountedObject<DataStreamReply> 
reply) {
+    if (readException != null) {
+      return;
+    }
+
+    reply.retain();
+    final CompletableFuture<ReferenceCountedObject<DataStreamReply>> pending = 
pendingReads.poll();
+    if (pending != null) {
+      final boolean completed = pending.complete(reply);
+      Preconditions.assertTrue(completed);
+      return;
+    }
+    replies.add(reply);
+  }
+
+  @Override
+  public synchronized void onError(Throwable throwable) {
+    Objects.requireNonNull(throwable, "throwable == null");
+    // An error case, release the replies
+    releaseReplies();
+    if (readException == null) {
+      readException = throwable;
+      failPendingReads();
+    }
+  }
+
+  @Override
+  public synchronized void onCompleted() {
+    // Not an error case, do not release the replies
+    if (readException == null) {
+      // No more onNext(), the pending reads cannot be completed.
+      readException = new EOFException(clientId + ": end of stream, request=" 
+ header);
+      failPendingReads();
+    }
+  }
+
+  private void releaseReplies() {
+    for (ReferenceCountedObject<DataStreamReply> reply; (reply = 
replies.poll()) != null; ) {
+      reply.release();
+    }
+  }
+
+  private void failPendingReads() {
+    Objects.requireNonNull(readException, "readException == null");
+    for (CompletableFuture<ReferenceCountedObject<DataStreamReply>> p; (p = 
pendingReads.poll()) != null; ) {
+      p.completeExceptionally(readException);
+    }
+  }
+
+  @Override
+  public synchronized 
CompletableFuture<ReferenceCountedObject<DataStreamReply>> readAsync() {
+    final ReferenceCountedObject<DataStreamReply> reply = replies.poll();
+    if (reply != null) {
+      return CompletableFuture.completedFuture(reply);
+    }
+    if (readException != null) {
+      return JavaUtils.completeExceptionally(readException);
+    }
+    final CompletableFuture<ReferenceCountedObject<DataStreamReply>> f = new 
CompletableFuture<>();
+    pendingReads.add(f);
+    return f;
+  }
+
+  @Override
+  public synchronized void close() {
+    // When close() is called the first time, we set
+    // (1) release all replies
+    // (2) set readException to AlreadyClosedException
+    // (3) complete all pendingReads, and
+    releaseReplies();
+    if (readException == null) {
+      readException = new AlreadyClosedException(clientId + ": stream already 
closed, request=" + header);
+      failPendingReads();
+    }
+  }
+}
diff --git 
a/ratis-common/src/main/java/org/apache/ratis/datastream/DataStreamObserver.java
 
b/ratis-common/src/main/java/org/apache/ratis/datastream/DataStreamObserver.java
new file mode 100644
index 000000000..92f62b751
--- /dev/null
+++ 
b/ratis-common/src/main/java/org/apache/ratis/datastream/DataStreamObserver.java
@@ -0,0 +1,39 @@
+/*
+ * 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.ratis.datastream;
+
+import java.util.function.BiConsumer;
+
+/** An interface similar to gRPC {@link  
org.apache.ratis.thirdparty.io.grpc.stub.StreamObserver}. */
+public interface DataStreamObserver<V> {
+  void onNext(V value);
+
+  void onCompleted();
+
+  void onError(Throwable throwable);
+
+  default <T> BiConsumer<T, Throwable> asWhenCompleteBiConsumer() {
+    return (v, throwable) -> {
+      if (throwable != null) {
+        onError(throwable);
+      } else  {
+        onCompleted();
+      }
+    };
+  }
+}
diff --git 
a/ratis-common/src/main/java/org/apache/ratis/datastream/impl/DataStreamPacketByteBuf.java
 
b/ratis-common/src/main/java/org/apache/ratis/datastream/impl/DataStreamPacketByteBuf.java
index 76400ff80..e6ceeb93d 100644
--- 
a/ratis-common/src/main/java/org/apache/ratis/datastream/impl/DataStreamPacketByteBuf.java
+++ 
b/ratis-common/src/main/java/org/apache/ratis/datastream/impl/DataStreamPacketByteBuf.java
@@ -30,7 +30,7 @@ import java.util.concurrent.atomic.AtomicReference;
  * <p>
  * This class is immutable.
  */
-class DataStreamPacketByteBuf extends DataStreamPacketImpl {
+public class DataStreamPacketByteBuf extends DataStreamPacketImpl {
   private final AtomicReference<ByteBuf> buf;
 
   DataStreamPacketByteBuf(ClientId clientId, Type type, long streamId, long 
streamOffset, ByteBuf buf) {
@@ -48,7 +48,8 @@ class DataStreamPacketByteBuf extends DataStreamPacketImpl {
 
   @Override
   public final long getDataLength() {
-    return getBuf().readableBytes();
+    final ByteBuf got = buf.get();
+    return got != null ? got.readableBytes() : -1;
   }
 
   public final ByteBuf slice() {
diff --git 
a/ratis-common/src/main/java/org/apache/ratis/datastream/impl/DataStreamReplyByteBuf.java
 
b/ratis-common/src/main/java/org/apache/ratis/datastream/impl/DataStreamReplyByteBuf.java
index 2477b089b..ab2849b19 100644
--- 
a/ratis-common/src/main/java/org/apache/ratis/datastream/impl/DataStreamReplyByteBuf.java
+++ 
b/ratis-common/src/main/java/org/apache/ratis/datastream/impl/DataStreamReplyByteBuf.java
@@ -21,10 +21,13 @@ import org.apache.ratis.proto.RaftProtos.CommitInfoProto;
 import org.apache.ratis.protocol.DataStreamReply;
 import org.apache.ratis.protocol.DataStreamReplyHeader;
 import org.apache.ratis.thirdparty.io.netty.buffer.ByteBuf;
+import org.apache.ratis.util.Preconditions;
+import org.apache.ratis.util.ReferenceCountedObject;
 
 import java.nio.ByteBuffer;
 import java.util.Collection;
 import java.util.Collections;
+import java.util.Objects;
 
 /**
  * Implements {@link DataStreamReply} with {@link ByteBuf}.
@@ -64,6 +67,18 @@ public final class DataStreamReplyByteBuf extends 
DataStreamPacketByteBuf implem
     return new Builder();
   }
 
+  public static ReferenceCountedObject<DataStreamReply> 
asReferenceCounted(DataStreamReplyByteBuf reply) {
+    Objects.requireNonNull(reply, "reply == null");
+    return ReferenceCountedObject.<DataStreamReply>newBuilder()
+        .setValue(reply)
+        .setReleaseMethod(r -> {
+          if (r != null) {
+            Preconditions.assertSame(reply, r, "reply").release();
+          }
+        })
+        .build();
+  }
+
   private final boolean success;
   private final long bytesWritten;
   private final Collection<CommitInfoProto> commitInfos;
diff --git 
a/ratis-common/src/main/java/org/apache/ratis/util/Preconditions.java 
b/ratis-common/src/main/java/org/apache/ratis/util/Preconditions.java
index f37b250f5..b2498dac1 100644
--- a/ratis-common/src/main/java/org/apache/ratis/util/Preconditions.java
+++ b/ratis-common/src/main/java/org/apache/ratis/util/Preconditions.java
@@ -88,9 +88,10 @@ public interface Preconditions {
         () -> name + ": expected == " + expected + " but computed == " + 
computed);
   }
 
-  static void assertSame(Object expected, Object computed, String name) {
+  static <T> T assertSame(T expected, Object computed, String name) {
     assertTrue(expected == computed,
         () -> name + ": expected == " + expected + " but computed == " + 
computed);
+    return expected;
   }
 
   static void assertEquals(Object expected, Object computed, String name) {
diff --git 
a/ratis-netty/src/main/java/org/apache/ratis/netty/NettyDataStreamUtils.java 
b/ratis-netty/src/main/java/org/apache/ratis/netty/NettyDataStreamUtils.java
index 2c5015b12..9f8cbecc8 100644
--- a/ratis-netty/src/main/java/org/apache/ratis/netty/NettyDataStreamUtils.java
+++ b/ratis-netty/src/main/java/org/apache/ratis/netty/NettyDataStreamUtils.java
@@ -169,6 +169,22 @@ public interface NettyDataStreamUtils {
     encodeDataStreamReply(reply, reply.slice(), out, allocator);
   }
 
+  static void encodeDataStreamReplyByteBuf(DataStreamReplyByteBuf reply, 
Consumer<ByteBuf> out,
+      ByteBufAllocator allocator) {
+    ByteBuffer headerBuf = getDataStreamReplyHeaderProtoByteBuf(reply);
+    final ByteBuf headerLenBuf = 
allocator.ioBuffer(DataStreamPacketHeader.getSizeOfHeaderLen());
+    headerLenBuf.writeInt(headerBuf.remaining());
+    out.accept(headerLenBuf);
+    out.accept(Unpooled.wrappedBuffer(headerBuf));
+
+    final ByteBuf data = reply.slice();
+    if (data.readableBytes() == 0) {
+      out.accept(Unpooled.EMPTY_BUFFER);
+    } else {
+      out.accept(data.retain());
+    }
+  }
+
   static DataStreamRequestByteBuf decodeDataStreamRequestByteBuf(ByteBuf buf) {
     return Optional.ofNullable(decodeDataStreamRequestHeader(buf))
         .map(header -> checkHeader(header, buf))
@@ -228,6 +244,14 @@ public interface NettyDataStreamUtils {
         .orElse(null);
   }
 
+  static DataStreamReplyByteBuffer 
toDataStreamReplyByteBuffer(DataStreamReplyByteBuf reply) {
+    try {
+      return reply.copy();
+    } finally {
+      reply.release();
+    }
+  }
+
   static DataStreamReplyHeader decodeDataStreamReplyHeader(ByteBuf buf) {
     if (DataStreamPacketHeader.getSizeOfHeaderLen() > buf.readableBytes()) {
       return null;
diff --git 
a/ratis-netty/src/main/java/org/apache/ratis/netty/client/ClientReadStream.java 
b/ratis-netty/src/main/java/org/apache/ratis/netty/client/ClientReadStream.java
new file mode 100644
index 000000000..c9096ae7b
--- /dev/null
+++ 
b/ratis-netty/src/main/java/org/apache/ratis/netty/client/ClientReadStream.java
@@ -0,0 +1,80 @@
+/*
+ * 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.ratis.netty.client;
+
+import org.apache.ratis.datastream.DataStreamObserver;
+import org.apache.ratis.datastream.impl.DataStreamReplyByteBuf;
+import org.apache.ratis.protocol.DataStreamReply;
+import org.apache.ratis.protocol.DataStreamRequest;
+import org.apache.ratis.thirdparty.io.netty.util.concurrent.ScheduledFuture;
+import org.apache.ratis.util.NettyUtils;
+import org.apache.ratis.util.Preconditions;
+import org.apache.ratis.util.ReferenceCountedObject;
+
+import java.util.concurrent.CompletableFuture;
+import java.util.function.Supplier;
+
+class ClientReadStream {
+  private final NettyClientReplies.RequestEntry terminalEntry;
+  private final CompletableFuture<DataStreamReply> replyFuture;
+  private final DataStreamObserver<ReferenceCountedObject<DataStreamReply>> 
replyHandler;
+  private Supplier<ScheduledFuture<?>> timeoutScheduler;
+  private ScheduledFuture<?> timeoutFuture;
+
+  ClientReadStream(DataStreamRequest request, 
CompletableFuture<DataStreamReply> replyFuture,
+      DataStreamObserver<ReferenceCountedObject<DataStreamReply>> 
replyHandler) {
+    this.terminalEntry = new NettyClientReplies.RequestEntry(request);
+    this.replyFuture = replyFuture;
+    this.replyHandler = replyHandler;
+  }
+
+  synchronized boolean receiveReply(ReferenceCountedObject<DataStreamReply> 
ref) {
+    NettyUtils.cancel(timeoutFuture);
+    try {
+      replyHandler.onNext(ref);
+    } catch (Throwable t) {
+      completeExceptionally(t);
+      return true;
+    }
+
+    final DataStreamReplyByteBuf reply = 
Preconditions.assertInstanceOf(ref.get(), DataStreamReplyByteBuf.class);
+    final boolean terminal = !reply.isSuccess() || terminalEntry.equals(new 
NettyClientReplies.RequestEntry(reply));
+    if (terminal) {
+      replyFuture.complete(reply.copy());
+      return true;
+    }
+    scheduleTimeout();
+    return false;
+  }
+
+  synchronized void completeExceptionally(Throwable t) {
+    NettyUtils.cancel(timeoutFuture);
+    replyFuture.completeExceptionally(t);
+  }
+
+  synchronized void scheduleTimeout(Supplier<ScheduledFuture<?>> 
scheduleMethod) {
+    timeoutScheduler = scheduleMethod;
+    scheduleTimeout();
+  }
+
+  private void scheduleTimeout() {
+    if (!replyFuture.isDone() && timeoutScheduler != null) {
+      timeoutFuture = timeoutScheduler.get();
+    }
+  }
+}
diff --git 
a/ratis-netty/src/main/java/org/apache/ratis/netty/client/NettyClientReplies.java
 
b/ratis-netty/src/main/java/org/apache/ratis/netty/client/NettyClientReplies.java
index 121ef3c0f..0aa6bc772 100644
--- 
a/ratis-netty/src/main/java/org/apache/ratis/netty/client/NettyClientReplies.java
+++ 
b/ratis-netty/src/main/java/org/apache/ratis/netty/client/NettyClientReplies.java
@@ -18,7 +18,6 @@
 
 package org.apache.ratis.netty.client;
 
-import org.apache.ratis.datastream.impl.DataStreamReplyByteBuf;
 import org.apache.ratis.proto.RaftProtos.DataStreamPacketHeaderProto.Type;
 import org.apache.ratis.protocol.ClientInvocationId;
 import org.apache.ratis.protocol.DataStreamPacket;
@@ -65,7 +64,7 @@ public class NettyClientReplies {
       return map.computeIfAbsent(requestEntry, r -> new ReplyEntry(isClose, 
f));
     }
 
-    void receiveReply(DataStreamReplyByteBuf reply) {
+    void receiveReply(DataStreamReply reply) {
       final RequestEntry requestEntry = new RequestEntry(reply);
       final ReplyEntry replyEntry = map.remove(requestEntry);
       LOG.debug("remove: {}; replyEntry: {}; reply: {}", requestEntry, 
replyEntry, reply);
diff --git 
a/ratis-netty/src/main/java/org/apache/ratis/netty/client/NettyClientStreamRpc.java
 
b/ratis-netty/src/main/java/org/apache/ratis/netty/client/NettyClientStreamRpc.java
index ccb0b15da..4f4f0d1f0 100644
--- 
a/ratis-netty/src/main/java/org/apache/ratis/netty/client/NettyClientStreamRpc.java
+++ 
b/ratis-netty/src/main/java/org/apache/ratis/netty/client/NettyClientStreamRpc.java
@@ -19,8 +19,10 @@
 package org.apache.ratis.netty.client;
 
 import org.apache.ratis.client.DataStreamClientRpc;
+import org.apache.ratis.datastream.DataStreamObserver;
 import org.apache.ratis.client.RaftClientConfigKeys;
 import org.apache.ratis.conf.RaftProperties;
+import org.apache.ratis.datastream.impl.DataStreamReplyByteBuffer;
 import org.apache.ratis.datastream.impl.DataStreamRequestByteBuf;
 import org.apache.ratis.datastream.impl.DataStreamRequestByteBuffer;
 import org.apache.ratis.datastream.impl.DataStreamReplyByteBuf;
@@ -64,6 +66,7 @@ import org.apache.ratis.util.Preconditions;
 import org.apache.ratis.util.ReferenceCountedObject;
 import org.apache.ratis.util.SizeInBytes;
 import org.apache.ratis.util.TimeDuration;
+import org.apache.ratis.util.function.UncheckedAutoCloseableSupplier;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
@@ -74,6 +77,8 @@ import java.util.Collections;
 import java.util.List;
 import java.util.Optional;
 import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ConcurrentMap;
 import java.util.concurrent.TimeUnit;
 import java.util.concurrent.atomic.AtomicBoolean;
 import java.util.concurrent.atomic.AtomicInteger;
@@ -325,6 +330,7 @@ public class NettyClientStreamRpc implements 
DataStreamClientRpc {
   private final Connection connection;
 
   private final NettyClientReplies replies = new NettyClientReplies();
+  private final ConcurrentMap<ClientInvocationId, ClientReadStream> 
readStreams = new ConcurrentHashMap<>();
   private final TimeDuration requestTimeout;
   private final TimeDuration closeTimeout;
 
@@ -358,15 +364,33 @@ public class NettyClientStreamRpc implements 
DataStreamClientRpc {
           LOG.error("{}: unexpected message {}", name, msg.getClass());
           return;
         }
-        try (DataStreamReplyByteBuf reply = (DataStreamReplyByteBuf) msg) {
-          process(reply);
+        final DataStreamReplyByteBuf reply = (DataStreamReplyByteBuf) msg;
+        final ReferenceCountedObject<DataStreamReply> ref = 
DataStreamReplyByteBuf.asReferenceCounted(reply);
+        try (UncheckedAutoCloseableSupplier<DataStreamReply> ignored = 
ref.retainAndReleaseOnClose()) {
+          process(ref);
         }
       }
 
-      private void process(DataStreamReplyByteBuf reply) {
-        LOG.debug("{}: read {}", name, reply);
-        final ClientInvocationId clientInvocationId = 
ClientInvocationId.valueOf(
-            reply.getClientId(), reply.getStreamId());
+      private void process(ReferenceCountedObject<DataStreamReply> ref) {
+        final DataStreamReplyByteBuf replyByteBuf = (DataStreamReplyByteBuf) 
ref.get();
+        LOG.debug("{}: read {}", name, replyByteBuf);
+        final ClientInvocationId clientInvocationId = 
ClientInvocationId.valueOf(replyByteBuf);
+        final ClientReadStream clientReadStream = 
readStreams.get(clientInvocationId);
+        if (clientReadStream != null) {
+          try {
+            if (clientReadStream.receiveReply(ref)) {
+              readStreams.remove(clientInvocationId, clientReadStream);
+            }
+          } catch (Throwable cause) {
+            LOG.warn("{} : channelRead error:", name, cause);
+            readStreams.remove(clientInvocationId, clientReadStream);
+            clientReadStream.completeExceptionally(cause);
+          }
+          return;
+        }
+
+        // just copy it for write requests
+        final DataStreamReplyByteBuffer reply = replyByteBuf.copy();
         final NettyClientReplies.ReplyMap replyMap = 
replies.getReplyMap(clientInvocationId);
         if (replyMap == null) {
           LOG.error("{}: {} replyMap not found for reply: {}", name, 
clientInvocationId, reply);
@@ -376,7 +400,7 @@ public class NettyClientStreamRpc implements 
DataStreamClientRpc {
         try {
           replyMap.receiveReply(reply);
         } catch (Throwable cause) {
-          LOG.warn("{} : channelRead error for {}", name, reply, cause);
+          LOG.warn("{} : channelRead error for {}:", name, 
reply.getClass().getSimpleName(), cause);
           replyMap.completeExceptionally(cause);
         }
       }
@@ -513,6 +537,54 @@ public class NettyClientStreamRpc implements 
DataStreamClientRpc {
     return f;
   }
 
+  @Override
+  public CompletableFuture<DataStreamReply> streamAsync(DataStreamRequest 
request,
+      DataStreamObserver<ReferenceCountedObject<DataStreamReply>> 
replyHandler) {
+    final CompletableFuture<DataStreamReply> f = new CompletableFuture<>();
+    final ClientInvocationId clientInvocationId = 
ClientInvocationId.valueOf(request);
+    final ClientReadStream readStream = new ClientReadStream(request, f, 
replyHandler);
+    if (readStreams.putIfAbsent(clientInvocationId, readStream) != null) {
+      f.completeExceptionally(new AlreadyClosedException(this + ": Read stream 
already exists for "
+          + clientInvocationId + ", request=" + request));
+      return f;
+    }
+
+    final ChannelFuture channelFuture;
+    final Channel channel;
+    LOG.debug("{}: write read-only stream begin {}", this, request);
+    synchronized (readStream) {
+      channel = connection.getChannelUninterruptibly();
+      if (channel == null) {
+        readStreams.remove(clientInvocationId, readStream);
+        f.completeExceptionally(new AlreadyClosedException(this + ": Failed to 
getChannel for " + request));
+        return f;
+      }
+      final Function<DataStreamRequest, ChannelFuture> writeMethod = 
outstandingRequests.shouldFlush(
+          flushRequestCountMin, flushRequestBytesMin, request)? 
channel::writeAndFlush: channel::write;
+      channelFuture = writeMethod.apply(request);
+    }
+    channelFuture.addListener(future -> {
+      if (!future.isSuccess()) {
+        readStreams.remove(clientInvocationId, readStream);
+        final IOException e = new IOException(this + ": Failed to send " + 
request + " to " + channel.remoteAddress(),
+            future.cause());
+        readStream.completeExceptionally(e);
+        LOG.error("Channel write failed", e);
+      } else {
+        LOG.debug("{}: write read-only stream after {}", this, request);
+
+        readStream.scheduleTimeout(() -> channel.eventLoop().schedule(() -> {
+          if (!f.isDone()) {
+            readStreams.remove(clientInvocationId, readStream);
+            readStream.completeExceptionally(new TimeoutIOException(
+                "Timeout " + requestTimeout + ": Failed to send " + request + 
" via channel " + channel));
+          }
+        }, requestTimeout.getDuration(), requestTimeout.getUnit()));
+      }
+    });
+    return f;
+  }
+
   @Override
   public void close() {
     final boolean flush = outstandingRequests.shouldFlush(0, SizeInBytes.ZERO, 
null);
diff --git 
a/ratis-test/src/test/java/org/apache/ratis/client/impl/TestDataStreamClientImpl.java
 
b/ratis-test/src/test/java/org/apache/ratis/client/impl/TestDataStreamClientImpl.java
new file mode 100644
index 000000000..523c49f05
--- /dev/null
+++ 
b/ratis-test/src/test/java/org/apache/ratis/client/impl/TestDataStreamClientImpl.java
@@ -0,0 +1,146 @@
+/*
+ * 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.ratis.client.impl;
+
+import org.apache.ratis.client.DataStreamClient;
+import org.apache.ratis.client.DataStreamClientRpc;
+import org.apache.ratis.client.api.DataStreamInput;
+import org.apache.ratis.conf.RaftProperties;
+import org.apache.ratis.datastream.DataStreamObserver;
+import org.apache.ratis.datastream.impl.DataStreamReplyByteBuf;
+import org.apache.ratis.datastream.impl.DataStreamRequestByteBuffer;
+import org.apache.ratis.proto.RaftProtos.DataStreamPacketHeaderProto.Type;
+import org.apache.ratis.proto.RaftProtos.RaftClientRequestProto;
+import org.apache.ratis.protocol.ClientId;
+import org.apache.ratis.protocol.DataStreamReply;
+import org.apache.ratis.protocol.DataStreamRequest;
+import org.apache.ratis.protocol.RaftClientRequest;
+import org.apache.ratis.protocol.RaftGroupId;
+import org.apache.ratis.protocol.RaftPeer;
+import org.apache.ratis.thirdparty.io.netty.buffer.Unpooled;
+import org.apache.ratis.util.ReferenceCountedObject;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+import java.io.EOFException;
+import java.nio.ByteBuffer;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.atomic.AtomicReference;
+
+public class TestDataStreamClientImpl {
+  private static RaftPeer newPeer(String id) {
+    return RaftPeer.newBuilder().setId(id).build();
+  }
+
+  private static class RecordingDataStreamClientRpc implements 
DataStreamClientRpc {
+    private final AtomicReference<RaftClientRequest> request = new 
AtomicReference<>();
+    private final 
AtomicReference<DataStreamObserver<ReferenceCountedObject<DataStreamReply>>> 
replyHandler = new AtomicReference<>();
+    private final AtomicReference<CompletableFuture<DataStreamReply>> 
replyFuture = new AtomicReference<>();
+
+    @Override
+    public CompletableFuture<DataStreamReply> streamAsync(
+        DataStreamRequest dataStreamRequest,
+        DataStreamObserver<ReferenceCountedObject<DataStreamReply>> 
replyHandler) {
+      try {
+        final ByteBuffer buffer = ((DataStreamRequestByteBuffer) 
dataStreamRequest).slice();
+        
request.set(ClientProtoUtils.toRaftClientRequest(RaftClientRequestProto.parseFrom(buffer)));
+      } catch (Exception e) {
+        throw new IllegalStateException(e);
+      }
+      this.replyHandler.set(replyHandler);
+      final CompletableFuture<DataStreamReply> future = new 
CompletableFuture<>();
+      replyFuture.set(future);
+      return future;
+    }
+
+    RaftClientRequest getRequest() {
+      return request.get();
+    }
+
+    void receive(DataStreamReplyByteBuf reply) {
+      final ReferenceCountedObject<DataStreamReply> ref = 
DataStreamReplyByteBuf.asReferenceCounted(reply);
+      ref.retain();
+      try {
+        replyHandler.get().onNext(ref);
+      } finally {
+        ref.release();
+      }
+    }
+
+    void complete() {
+      replyHandler.get().onCompleted();
+      replyFuture.get().complete(null);
+    }
+
+    void completeExceptionally(Throwable cause) {
+      replyHandler.get().onError(cause);
+      replyFuture.get().completeExceptionally(cause);
+    }
+
+    @Override
+    public void close() {
+    }
+  }
+
+  private static DataStreamClient newDataStreamClient(
+      RaftPeer dataStreamServer, RecordingDataStreamClientRpc 
dataStreamClientRpc) {
+    final RaftProperties properties = new RaftProperties();
+    return new DataStreamClientImpl(
+        ClientId.randomId(), RaftGroupId.randomId(), dataStreamServer, 
dataStreamClientRpc, properties);
+  }
+
+  @Test
+  public void testReadOnlyInputCompletesPendingReadOnCompleted() throws 
Exception {
+    final RaftPeer follower = newPeer("follower");
+    final RecordingDataStreamClientRpc dataStreamClientRpc = new 
RecordingDataStreamClientRpc();
+
+    try (DataStreamClient dataStreamClient = newDataStreamClient(follower, 
dataStreamClientRpc);
+         DataStreamInput input = 
dataStreamClient.streamReadOnly(ByteBuffer.wrap(new byte[] {1}))) {
+      final CompletableFuture<ReferenceCountedObject<DataStreamReply>> pending 
= input.readAsync();
+
+      dataStreamClientRpc.complete();
+
+      assertFutureCause(pending, EOFException.class);
+      assertFutureCause(input.readAsync(), EOFException.class);
+    }
+  }
+
+  @Test
+  public void testReadOnlyInputNotifiesPendingReadOnError() throws Exception {
+    final RaftPeer follower = newPeer("follower");
+    final RecordingDataStreamClientRpc dataStreamClientRpc = new 
RecordingDataStreamClientRpc();
+
+    try (DataStreamClient dataStreamClient = newDataStreamClient(follower, 
dataStreamClientRpc);
+         DataStreamInput input = 
dataStreamClient.streamReadOnly(ByteBuffer.wrap(new byte[] {1}))) {
+      final CompletableFuture<ReferenceCountedObject<DataStreamReply>> pending 
= input.readAsync();
+      final Throwable cause = new IllegalStateException("test");
+
+      dataStreamClientRpc.completeExceptionally(cause);
+
+      Assertions.assertSame(cause, assertFutureCause(pending, 
IllegalStateException.class));
+      Assertions.assertSame(cause, assertFutureCause(input.readAsync(), 
IllegalStateException.class));
+    }
+  }
+
+  private static <T extends Throwable> Throwable assertFutureCause(
+      CompletableFuture<?> future, Class<T> expectedCauseClass) {
+    final ExecutionException exception = 
Assertions.assertThrows(ExecutionException.class, future::get);
+    return Assertions.assertInstanceOf(expectedCauseClass, 
exception.getCause());
+  }
+}
diff --git 
a/ratis-test/src/test/java/org/apache/ratis/datastream/DataStreamClusterTests.java
 
b/ratis-test/src/test/java/org/apache/ratis/datastream/DataStreamClusterTests.java
index dabc93dda..95e9bef49 100644
--- 
a/ratis-test/src/test/java/org/apache/ratis/datastream/DataStreamClusterTests.java
+++ 
b/ratis-test/src/test/java/org/apache/ratis/datastream/DataStreamClusterTests.java
@@ -18,33 +18,46 @@
 package org.apache.ratis.datastream;
 
 import org.apache.ratis.BaseTest;
+import org.apache.ratis.client.api.DataStreamInput;
 import org.apache.ratis.io.StandardWriteOption;
 import org.apache.ratis.protocol.RaftPeer;
 import org.apache.ratis.protocol.RoutingTable;
 import org.apache.ratis.server.impl.MiniRaftCluster;
 import org.apache.ratis.client.RaftClient;
+import org.apache.ratis.client.impl.ClientProtoUtils;
 import org.apache.ratis.client.impl.DataStreamClientImpl.DataStreamOutputImpl;
 import 
org.apache.ratis.datastream.DataStreamTestUtils.MultiDataStreamStateMachine;
 import org.apache.ratis.datastream.DataStreamTestUtils.SingleDataStream;
+import org.apache.ratis.datastream.impl.DataStreamReplyByteBuf;
 import org.apache.ratis.proto.RaftProtos.DataStreamPacketHeaderProto.Type;
 import org.apache.ratis.proto.RaftProtos.ReplicationLevel;
 import org.apache.ratis.protocol.DataStreamReply;
+import org.apache.ratis.protocol.DataStreamRequest;
+import org.apache.ratis.protocol.DataStreamRequestHeader;
+import org.apache.ratis.protocol.Message;
 import org.apache.ratis.protocol.RaftClientReply;
 import org.apache.ratis.protocol.RaftClientRequest;
+import org.apache.ratis.retry.RetryPolicies;
 import org.apache.ratis.server.RaftServer;
+import org.apache.ratis.server.RaftServerConfigKeys;
+import org.apache.ratis.thirdparty.com.google.protobuf.ByteString;
 import org.apache.ratis.util.CollectionUtils;
 import org.apache.ratis.util.FileUtils;
+import org.apache.ratis.util.ReferenceCountedObject;
 import org.apache.ratis.util.Timestamp;
 import org.apache.ratis.util.function.CheckedConsumer;
 import org.junit.jupiter.api.Assertions;
 import org.junit.jupiter.api.Test;
 
+import java.io.EOFException;
 import java.io.File;
 import java.nio.channels.FileChannel;
 import java.nio.file.StandardOpenOption;
 import java.util.Collection;
 import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ExecutionException;
 import java.util.concurrent.ThreadLocalRandom;
+import java.util.concurrent.TimeUnit;
 
 import static org.apache.ratis.RaftTestUtil.waitForLeader;
 
@@ -71,6 +84,17 @@ public abstract class DataStreamClusterTests<CLUSTER extends 
MiniRaftCluster> ex
     runWithNewCluster(NUM_SERVERS, this::runTestInvalidPrimaryInRoutingTable);
   }
 
+  @Test
+  public void testStreamReadOnly() throws Exception {
+    runWithNewCluster(NUM_SERVERS, this::runTestStreamReadOnly);
+  }
+
+  @Test
+  public void testStreamReadOnlyWithNonLeaderPrimary() throws Exception {
+    RaftServerConfigKeys.Read.setOption(getProperties(), 
RaftServerConfigKeys.Read.Option.LINEARIZABLE);
+    runWithNewCluster(NUM_SERVERS, 
this::runTestStreamReadOnlyWithNonLeaderPrimary);
+  }
+
   void testStreamWrites(CLUSTER cluster) throws Exception {
     waitForLeader(cluster);
     runTestDataStreamOutput(cluster);
@@ -105,6 +129,55 @@ public abstract class DataStreamClusterTests<CLUSTER 
extends MiniRaftCluster> ex
     assertLogEntry(cluster, request);
   }
 
+  void runTestStreamReadOnly(CLUSTER cluster) throws Exception {
+    final RaftPeer leader = waitForLeader(cluster).getPeer();
+    runTestStreamReadOnly(cluster, leader, leader);
+  }
+
+  void runTestStreamReadOnlyWithNonLeaderPrimary(CLUSTER cluster) throws 
Exception {
+    final RaftPeer leader = waitForLeader(cluster).getPeer();
+    RaftPeer nonLeaderPrimary = null;
+    for (RaftPeer peer : cluster.getGroup().getPeers()) {
+      if (!peer.getId().equals(leader.getId())) {
+        nonLeaderPrimary = peer;
+        break;
+      }
+    }
+    Assertions.assertNotNull(nonLeaderPrimary, "Cannot find non-leader peer");
+    runTestStreamReadOnly(cluster, leader, nonLeaderPrimary);
+  }
+
+  void runTestStreamReadOnly(CLUSTER cluster, RaftPeer leader, RaftPeer 
primaryServer) throws Exception {
+    final ByteString query = ByteString.copyFromUtf8("stream-read-only");
+    try (RaftClient client = cluster.createClient(leader.getId(), 
cluster.getGroup(), RetryPolicies.noRetry(), primaryServer);
+         DataStreamInput in = 
client.getDataStreamApi().streamReadOnly(query.asReadOnlyByteBuffer())) {
+      for (int i = 0; i < MultiDataStreamStateMachine.READ_ONLY_STREAM_CHUNKS; 
i++) {
+        final ByteString chunk = 
MultiDataStreamStateMachine.getReadOnlyStreamChunk(query, i);
+        final ReferenceCountedObject<DataStreamReply> ref = 
in.readAsync().join();
+        final DataStreamReplyByteBuf data = (DataStreamReplyByteBuf) ref.get();
+        DataStreamTestUtils.assertSuccessReply(Type.STREAM_DATA, chunk.size(), 
data);
+        Assertions.assertEquals(chunk, 
ByteString.copyFrom(data.slice().nioBuffer()));
+        ref.release();
+      }
+
+      final ReferenceCountedObject<DataStreamReply> ref = 
in.readAsync().join();
+      try {
+        final DataStreamReplyByteBuf reply = (DataStreamReplyByteBuf) 
ref.get();
+        DataStreamTestUtils.assertSuccessReply(Type.STREAM_HEADER, 0, reply);
+
+        final RaftClientReply clientReply = 
ClientProtoUtils.getRaftClientReply(reply);
+        Assertions.assertTrue(clientReply.isSuccess());
+        Assertions.assertEquals(primaryServer.getId(), 
clientReply.getServerId());
+      } finally {
+        ref.release();
+      }
+
+      final ExecutionException eof = 
Assertions.assertThrows(ExecutionException.class,
+          () -> in.readAsync().get(1, TimeUnit.SECONDS));
+      Assertions.assertInstanceOf(EOFException.class, eof.getCause());
+    }
+  }
+
   void runTestInvalidPrimaryInRoutingTable(CLUSTER cluster) throws Exception {
     final RaftPeer primaryServer = 
CollectionUtils.random(cluster.getGroup().getPeers());
 
diff --git 
a/ratis-test/src/test/java/org/apache/ratis/netty/server/TestDataStreamManagement.java
 
b/ratis-test/src/test/java/org/apache/ratis/netty/server/TestDataStreamManagement.java
index 516f8c171..188f119fc 100644
--- 
a/ratis-test/src/test/java/org/apache/ratis/netty/server/TestDataStreamManagement.java
+++ 
b/ratis-test/src/test/java/org/apache/ratis/netty/server/TestDataStreamManagement.java
@@ -42,7 +42,10 @@ import org.apache.ratis.thirdparty.io.netty.buffer.Unpooled;
 import org.apache.ratis.thirdparty.io.netty.channel.ChannelHandlerContext;
 import org.apache.ratis.thirdparty.io.netty.channel.ChannelId;
 import 
org.apache.ratis.thirdparty.io.netty.channel.ChannelInboundHandlerAdapter;
+import org.apache.ratis.thirdparty.io.netty.channel.EventLoop;
+import org.apache.ratis.thirdparty.io.netty.channel.EventLoopGroup;
 import org.apache.ratis.thirdparty.io.netty.channel.embedded.EmbeddedChannel;
+import org.apache.ratis.thirdparty.io.netty.channel.nio.NioEventLoopGroup;
 import org.apache.ratis.util.JavaUtils;
 import org.apache.ratis.util.TimeDuration;
 import org.apache.ratis.util.function.CheckedBiFunction;
@@ -56,7 +59,9 @@ import java.util.ArrayList;
 import java.util.Collections;
 import java.util.List;
 import java.util.Set;
+import java.util.concurrent.CountDownLatch;
 import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
 import java.util.concurrent.atomic.AtomicReference;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
@@ -82,6 +87,58 @@ class TestDataStreamManagement {
         streamRef.set(stream);
       }
     };
+    final ReadStreamManagement management = newReadStreamManagement(serverId, 
groupId, dataApi);
+    final EmbeddedChannel embeddedChannel = new EmbeddedChannel(new 
ChannelInboundHandlerAdapter());
+
+    final ReadOnlyRequest readOnlyRequest = newReadOnlyRequest(clientId, 
serverId, groupId, 1L, query);
+
+    try {
+      assertTrue(management.process(readOnlyRequest.request, 
embeddedChannel.pipeline().firstContext()));
+      assertEquals(0, readOnlyRequest.headerBuf.refCnt());
+
+      final WritableByteChannel stream = streamRef.get();
+      assertNotNull(stream);
+      stream.write(response.asReadOnlyByteBuffer());
+      stream.close();
+
+      final List<DataStreamReply> replies = new ArrayList<>();
+      JavaUtils.attempt(() -> {
+        for (Object outbound; (outbound = embeddedChannel.readOutbound()) != 
null;) {
+          replies.add((DataStreamReply) outbound);
+        }
+        assertEquals(2, replies.size());
+      }, 10, TimeDuration.valueOf(100, TimeUnit.MILLISECONDS), "read-only 
replies", null);
+
+      assertEquals(query, messageRef.get().getContent());
+      assertFalse(streamRef.get().isOpen(), "state machine should close the 
streaming query channel");
+      assertSuccessReply(Type.STREAM_DATA, response.size(), replies.get(0));
+      assertSuccessReply(Type.STREAM_HEADER, 0, replies.get(1));
+      
assertTrue(ClientProtoUtils.getRaftClientReply(replies.get(1)).isSuccess());
+    } finally {
+      embeddedChannel.finishAndReleaseAll();
+    }
+  }
+
+  @Test
+  void readOnlyQueryDoesNotRunOnNettyEventLoop() throws Exception {
+    final RaftPeerId serverId = RaftPeerId.valueOf("s1");
+    final ClientId clientId = ClientId.randomId();
+    final RaftGroupId groupId = RaftGroupId.randomId();
+    final CountDownLatch queryDone = new CountDownLatch(1);
+    final AtomicBoolean queryInEventLoop = new AtomicBoolean();
+    final EventLoopGroup eventLoopGroup = new NioEventLoopGroup(1);
+    final EventLoop eventLoop = eventLoopGroup.next();
+    final EmbeddedChannel embeddedChannel = new EmbeddedChannel(new 
ChannelInboundHandlerAdapter());
+    final ChannelHandlerContext ctx = 
embeddedChannel.pipeline().firstContext();
+    assertNotNull(ctx, "ChannelHandlerContext should be initialized");
+
+    final DataApi dataApi = new DataApi() {
+      @Override
+      public void query(Message request, WritableByteChannel stream) {
+        queryInEventLoop.set(eventLoop.inEventLoop());
+        queryDone.countDown();
+      }
+    };
     final StateMachine stateMachine = new BaseStateMachine() {
       @Override
       public DataApi data() {
@@ -90,14 +147,13 @@ class TestDataStreamManagement {
     };
     final RaftServer server = newRaftServer(serverId, new RaftProperties(), 
groupId, newDivision(stateMachine));
     final ReadStreamManagement management = new ReadStreamManagement(server);
-    final EmbeddedChannel embeddedChannel = new EmbeddedChannel(new 
ChannelInboundHandlerAdapter());
 
     final RaftClientRequest raftClientRequest = RaftClientRequest.newBuilder()
         .setClientId(clientId)
         .setServerId(serverId)
         .setGroupId(groupId)
         .setCallId(1L)
-        .setMessage(Message.valueOf(query))
+        .setMessage(Message.valueOf(ByteString.copyFromUtf8("query")))
         .setType(RaftClientRequest.readRequestType())
         .build();
     final ByteBuffer header = 
ClientProtoUtils.toRaftClientRequestProtoByteBuffer(raftClientRequest);
@@ -111,29 +167,15 @@ class TestDataStreamManagement {
         headerBuf);
 
     try {
-      assertTrue(management.process(request, 
embeddedChannel.pipeline().firstContext()));
-      assertEquals(0, headerBuf.refCnt());
+      eventLoop.submit(() -> assertTrue(management.process(request, 
ctx))).sync();
 
-      final WritableByteChannel stream = streamRef.get();
-      assertNotNull(stream);
-      stream.write(response.asReadOnlyByteBuffer());
-      stream.close();
-
-      final List<DataStreamReply> replies = new ArrayList<>();
-      JavaUtils.attempt(() -> {
-        for (Object outbound; (outbound = embeddedChannel.readOutbound()) != 
null;) {
-          replies.add((DataStreamReply) outbound);
-        }
-        assertEquals(2, replies.size());
-      }, 10, TimeDuration.valueOf(100, TimeUnit.MILLISECONDS), "read-only 
replies", null);
-
-      assertEquals(query, messageRef.get().getContent());
-      assertFalse(streamRef.get().isOpen(), "state machine should close the 
streaming query channel");
-      assertSuccessReply(Type.STREAM_DATA, response.size(), replies.get(0));
-      assertSuccessReply(Type.STREAM_HEADER, 0, replies.get(1));
-      
assertTrue(ClientProtoUtils.getRaftClientReply(replies.get(1)).isSuccess());
+      assertTrue(queryDone.await(10, TimeUnit.SECONDS));
+      assertEquals(0, headerBuf.refCnt());
+      assertFalse(queryInEventLoop.get(), "read-only state machine query 
should not run on Netty event loop");
     } finally {
       embeddedChannel.finishAndReleaseAll();
+      management.shutdown();
+      eventLoopGroup.shutdownGracefully(0, 100, TimeUnit.MILLISECONDS).sync();
     }
   }
 
@@ -177,6 +219,49 @@ class TestDataStreamManagement {
     }
   }
 
+  private static class ReadOnlyRequest {
+    private final DataStreamRequestByteBuf request;
+    private final ByteBuf headerBuf;
+
+    ReadOnlyRequest(DataStreamRequestByteBuf request, ByteBuf headerBuf) {
+      this.request = request;
+      this.headerBuf = headerBuf;
+    }
+  }
+
+  private static ReadStreamManagement newReadStreamManagement(
+      RaftPeerId serverId, RaftGroupId groupId, DataApi dataApi) {
+    final StateMachine stateMachine = new BaseStateMachine() {
+      @Override
+      public DataApi data() {
+        return dataApi;
+      }
+    };
+    final RaftServer server = newRaftServer(serverId, new RaftProperties(), 
groupId, newDivision(stateMachine));
+    return new ReadStreamManagement(server);
+  }
+
+  private static ReadOnlyRequest newReadOnlyRequest(ClientId clientId, 
RaftPeerId serverId, RaftGroupId groupId,
+      long callId, ByteString query) {
+    final RaftClientRequest raftClientRequest = RaftClientRequest.newBuilder()
+        .setClientId(clientId)
+        .setServerId(serverId)
+        .setGroupId(groupId)
+        .setCallId(callId)
+        .setMessage(Message.valueOf(query))
+        .setType(RaftClientRequest.readRequestType())
+        .build();
+    final ByteBuffer header = 
ClientProtoUtils.toRaftClientRequestProtoByteBuffer(raftClientRequest);
+    final ByteBuf headerBuf = Unpooled.wrappedBuffer(header);
+    return new ReadOnlyRequest(new DataStreamRequestByteBuf(
+        clientId,
+        Type.STREAM_HEADER,
+        raftClientRequest.getCallId(),
+        0L,
+        Collections.singletonList(StandardWriteOption.FLUSH),
+        headerBuf), headerBuf);
+  }
+
   private static void assertSuccessReply(Type expectedType, long 
expectedBytesWritten, DataStreamReply reply) {
     assertEquals(expectedType, reply.getType());
     assertTrue(reply.isSuccess());

Reply via email to