Skip to content

RATIS-2625. Streaming: support sending commands in Data Stream - #1534

Merged
szetszwo merged 14 commits into
apache:masterfrom
amaliujia:control_api
Aug 4, 2026
Merged

RATIS-2625. Streaming: support sending commands in Data Stream#1534
szetszwo merged 14 commits into
apache:masterfrom
amaliujia:control_api

Conversation

@amaliujia

@amaliujia amaliujia commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

What changes were proposed in this pull request?

This PR is AI-assisted

Adds a first-class mid-stream command plane to Ratis data streams. Callers can send opaque command payloads during an active stream via DataStreamOutput.commandAsync(). The server delivers them to StateMachine.DataStream.onCommand() instead of writing bytes to the data channel. Commands are ordered with data writes, replicated to remote peers like STREAM_DATA, and do not advance the stream byte offset.

Example usage:

// Client
out.getHeaderFuture().join();
out.writeAsync(dataBuffer).join();
out.commandAsync(myCommandBytes).join();  // opaque payload
out.writeAsync(moreData, StandardWriteOption.CLOSE).join();

// State machine
@Override
public CompletableFuture<?> onCommand(ByteBuffer command, long streamOffset) {
  // parse command, e.g. force(), metadata update, etc.
  return CompletableFuture.completedFuture(null);
}

What is the link to the Apache JIRA

https://issues.apache.org/jira/browse/RATIS-2625

How was this patch tested?

Unit tests

@amaliujia
amaliujia marked this pull request as draft July 29, 2026 02:48
@amaliujia amaliujia changed the title update [In Progress] Control Plane in Data Stream API Jul 29, 2026
@amaliujia amaliujia changed the title [In Progress] Control Plane in Data Stream API RATIS-2625. Control Plane in Data Stream API Jul 30, 2026
@amaliujia amaliujia changed the title RATIS-2625. Control Plane in Data Stream API RATIS-2625. Streaming: support control plane in Data Stream Jul 30, 2026
@amaliujia
amaliujia marked this pull request as ready for review July 30, 2026 03:28
@amaliujia

Copy link
Copy Markdown
Contributor Author

@szetszwo this is the follow up for apache/ozone#10823 (comment)

@szetszwo szetszwo left a comment

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.

@amaliujia , thanks for working on this! The change looks quite good.

The StateMachine may return a result, we should send it back to the client; see https://issues.apache.org/jira/secure/attachment/13083627/1534_review2.patch

* @param streamOffset the current stream byte offset
* @return a future for the command task
*/
default CompletableFuture<?> onCommand(ByteBuffer command, long streamOffset) {

@szetszwo szetszwo Jul 30, 2026

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.

Since this is a commnad, it should return CompletableFuture<ByteBuffer>.

@szetszwo szetszwo left a comment

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.

@amaliujia , thanks for the update! Please see the comments inlined.


@Override
public CompletableFuture<DataStreamReply> commandAsync(ByteBuffer src) {
return commandAsyncImpl(src, src.remaining());

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.

commandAsyncImpl is used just once. Let's inline the code.

    public CompletableFuture<DataStreamReply> commandAsync(ByteBuffer src) {
      if (isClosed()) {
        return JavaUtils.completeExceptionally(new AlreadyClosedException(
            clientId + ": stream already closed, request=" + header));
      }
      return combineHeader(send(Type.STREAM_COMMAND, src, src.remaining(),
          Collections.singleton(StandardWriteOption.FLUSH)));
    }

private final CompletableFuture<DataStream> streamFuture;
private final AtomicReference<CompletableFuture<Long>> writeFuture;
private final RequestMetrics metrics;
private final AtomicReference<CompletableFuture<LocalResult>> writeFuture;

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.

Let's rename it to resultFuture.

Comment on lines 134 to 135
CompletableFuture<LocalResult> write(ByteBuf buf, Iterable<WriteOption> options,
Executor executor) {

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.

Make it a single line.

final ByteBuffer command = copyBuffer(request.slice());
localResult = info.getLocal().command(command, request.getStreamOffset(), writeExecutor);
remoteWrites = info.applyToRemotes(out -> out.command(
copyBuffer(request.slice()), requestExecutor));

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.

duplicate() and reuse command, i.e.

      final ByteBuffer command = copyBuffer(request.slice());
      localResult = info.getLocal().command(command.duplicate(), request.getStreamOffset(), writeExecutor);
      remoteWrites = info.applyToRemotes(out -> out.command(command, requestExecutor));

copy.put(buffer);
}
copy.flip();
return copy;

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.

Make it readonly

    return copy.asReadOnlyBuffer();

Comment on lines +365 to +366
return CompletableFuture.runAsync(() -> {}, e)
.thenCompose(ignored -> stream.onCommand(command, streamOffset));

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.

Using completedFuture(null) is more efficient since it doesn't have to run an empty task.

    return CompletableFuture.completedFuture(null)
        .thenComposeAsync(ignored -> stream.onCommand(command, streamOffset), e);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Thanks. I really learned something from this comment.

reply = replyFuture.get(requestTimeout.getDuration(), requestTimeout.getUnit());
} catch (Exception e) {
throw new CompletionException("Failed to get reply for bytesWritten=" + bytesWritten + ", " + request, e);
throw new CompletionException("Failed to get reply for " + localResult + ", " + request, e);

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.

Add toString()

    @Override
    public String toString() {
      return commandReply != null ? "commandReply:" + StringUtils.bytes2HexString(commandReply)
          : "byteWritten:" + byteWritten;
    }

}

@Override
public CompletableFuture<DataStreamReply> commandAsync(ByteBuffer src) {

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.

What would happen on the client side if it issues two commands without data in between? As far as I can see, NettyClientReply, when it maps requests, considers only the stream offset and type, so the second command with the same offset wouldn't have a ReplyEntry.

@amaliujia amaliujia Aug 3, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Very good call. It seems to me that the first command's ReplyEntry will be used for the second command and the second command's RequestEntry won't be in the map. Anyway it looks like a mess without a handling.

So I instead fail the commands if there is already one at the same stream offset. After all, we do not expect the caller issue multiple commands at the same stream offset for now.

@szetszwo szetszwo left a comment

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.

@ss77892 , Good catch one the problem!

@amaliujia , thanks for the update! Please see the comment inlined.

Comment on lines 63 to 77
if (requestEntry.type == Type.STREAM_COMMAND) {
if (map.containsKey(requestEntry)) {
final IllegalStateException exception = new IllegalStateException(
"A STREAM_COMMAND is already pending for " + requestEntry
+ " for " + clientInvocationId
+ "; wait for the previous command reply before sending another");
f.completeExceptionally(exception);
return null;
}
final ReplyEntry entry = new ReplyEntry(isClose, f);
map.put(requestEntry, entry);
return entry;
}
// ConcurrentHashMap.computeIfAbsent javadoc: the function is applied at most once per key.
return map.computeIfAbsent(requestEntry, r -> new ReplyEntry(isClose, f));

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.

    ReplyEntry submitRequest(RequestEntry requestEntry, boolean isClose, CompletableFuture<DataStreamReply> f) {
      LOG.debug("put {} to the map for {}", requestEntry, clientInvocationId);
      final MemoizedSupplier<ReplyEntry> supplier = MemoizedSupplier.valueOf(() -> new ReplyEntry(isClose, f));
      final ReplyEntry reply = map.computeIfAbsent(requestEntry, r -> supplier.get());
      if (requestEntry.type == Type.STREAM_COMMAND && !supplier.isInitialized()) {
        final IllegalStateException exception = new IllegalStateException(
            "STREAM_COMMAND already exist: " + requestEntry + " for " + clientInvocationId);
        f.completeExceptionally(exception);
        return null;
      }
      return reply;
    }
  • We should only make one call to the map as above; otherwise, it is not atomic. In this case, we may use MemoizedSupplier.
  • Let's also make the exception message shorter; see below
Image

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done!

Thanks for the hint. The map is concurrent map so two accesses (one containsKey, one put) are not atomic and will cause concurrency issue.

@szetszwo

szetszwo commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

RATIS-2625. Streaming: support control plane in Data Stream

Control plane seems not the right term. How about revising the title to below?

  • Streaming: support sending commands in Data Stream

@amaliujia amaliujia changed the title RATIS-2625. Streaming: support control plane in Data Stream RATIS-2625. Streaming: support sending commands in Data Stream Aug 4, 2026

@szetszwo szetszwo left a comment

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.

+1 the change looks good.

@szetszwo
szetszwo merged commit 64e5b34 into apache:master Aug 4, 2026
16 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants