Skip to content

KAFKA-20803: Demultiplex batched persister responses once (WriteStateHandler) - #22835

Open
Shekharrajak wants to merge 4 commits into
apache:trunkfrom
Shekharrajak:KAFKA-20803-write-state-handler
Open

KAFKA-20803: Demultiplex batched persister responses once (WriteStateHandler)#22835
Shekharrajak wants to merge 4 commits into
apache:trunkfrom
Shekharrajak:KAFKA-20803-write-state-handler

Conversation

@Shekharrajak

@Shekharrajak Shekharrajak commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

fix: https://issues.apache.org/jira/browse/KAFKA-20803

PersisterStateManager coalesces many partitions into a single
WRITE_SHARE_GROUP_STATE RPC. On completion, every per-partition handler
re-scanned the full combined response to find its own slice — N handlers

  • O(N) scan = O(N ^ 2 ), all on the single send thread, stalling the
    send loop.

This PR demultiplexes the combined response once into a topicId ->
partition -> result index (PartitionResultIndex, built in the send loop
and shared across the handlers of one coalesced request) and converts
WriteStateHandler to an O(1) lookup.

Scope is deliberately limited to WRITE: it is the hot path, since every
acknowledgement/commit drives a write. readState runs once per
SharePartition lifetime, initializeState once per group-topic-partition,
and delete/read-summary are admin-invoked — for those, building an index
would cost more than the lookup it saves, so they keep the existing
scan.

Reviewers: Sushant Mahajan smahajan@confluent.io, Andrew Schofield
aschofield@confluent.io

@github-actions github-actions Bot added triage PRs from the community core Kafka Broker labels Jul 15, 2026
@AndrewJSchofield
AndrewJSchofield requested a review from smjn July 16, 2026 18:52
@github-actions

Copy link
Copy Markdown

A label of 'needs-attention' was automatically added to this PR in order to raise the
attention of the committers. Once this issue has been triaged, the triage label
should be removed to prevent this automation from happening again.

@Shekharrajak

Copy link
Copy Markdown
Contributor Author

CI check failure looking related to #22777

@Shekharrajak
Shekharrajak force-pushed the KAFKA-20803-write-state-handler branch from 70955e0 to 6d188ec Compare July 28, 2026 16:39
ToIntFunction<P> partition
) {
Map<Uuid, Map<Integer, P>> index = new HashMap<>();
for (T result : results) {

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.

Flattens results -> partitions into a nested HashMap for O(1) per-partition lookup. Generic so any RPC handler can reuse it.

}

@SuppressWarnings("unchecked")
protected final <R> R lookupPartitionResult(ClientResponse response) {

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.

the shared O(1) path: use the shared index if present, else build lazily

super.resetCoordinatorNode();
timer.add(new PersisterTimerTask(writeStateBackoff.backOff(), this));
return;
WriteShareGroupStateResponseData.PartitionResult partitionResult = lookupPartitionResult(response);

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.

the combined response is demultiplexed once into a topicId -> partition -> result map, and each
handler does an O(1) lookup -> O(N).

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.

About heap : lookupPartitionResult drops each handler's reference once it is consumed. so it will be alive for one callback only. No memory leak.

@github-actions github-actions Bot removed needs-attention triage PRs from the community labels Jul 29, 2026
@smjn
smjn requested a review from Copilot July 31, 2026 05:26

Copilot AI 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.

Pull request overview

This PR addresses the send-thread O(N²) cost when batched share-state RPC responses are completed by introducing shared, one-time demultiplexing of combined responses and converting WriteStateHandler to use O(1) per-partition lookup.

Changes:

  • Added a generic indexByTopicPartition helper and per-handler plumbing (buildResultIndex / lookupPartitionResult) to support indexing combined responses once.
  • Updated WriteStateHandler to use the shared index instead of re-scanning the combined response.
  • Updated the send-loop completion path to build the index once per combined response and share it across handlers.
Suppressed comments (1)

server-common/src/main/java/org/apache/kafka/server/share/persister/PersisterStateManager.java:1600

  • The batched-request completion callback now calls buildResultIndex(response) before invoking handler.onComplete(response). If the client ever completes a request with response == null (which PersisterStateManagerHandler.onComplete explicitly handles), this will throw a NullPointerException and prevent all per-partition handlers from completing/failing their futures.
                                        // Demux the combined response once and share it across handlers (KAFKA-20803).
                                        Object sharedResultIndex = handlersPerGroup.isEmpty()
                                            ? null
                                            : handlersPerGroup.get(0).buildResultIndex(response);
                                        handlersPerGroup.forEach(handler1 -> {

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +268 to +277
protected final <R> R lookupPartitionResult(ClientResponse response) {
Object index = sharedResultIndex;
sharedResultIndex = null;
Map<Uuid, Map<Integer, R>> resultIndex =
(Map<Uuid, Map<Integer, R>>) (index != null ? index : buildResultIndex(response));
if (resultIndex == null) {
return null;
}
Map<Integer, R> byPartition = resultIndex.get(partitionKey().topicId());
return byPartition == null ? null : byPartition.get(partitionKey().partition());

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

looks legit

smjn

This comment was marked as outdated.

@smjn smjn left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

@Shekharrajak
Overall looks good.

Though I would not be too keen on modifying other RPCs to use the lookup as their frequency is extremely small(for instance a SharePartition calls readState once in its lifetime). Delete/ReadSummary are admin invoked and Initialize is once per group-topic-partition.

@smjn
smjn self-requested a review July 31, 2026 05:52

@smjn smjn left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Slightly tangential but related - could you also add trace log in coalesceRequests to the tune of:

log.trace("Received coalesce request for {} handlers of RPC type {}", handlers.size(), rpcType);

It will help in spot checking.

@smjn smjn left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Since most of the changes are within static context, do you think the cache can be refactored into its self contained inner class?

@AndrewJSchofield
AndrewJSchofield self-requested a review July 31, 2026 10:22

@AndrewJSchofield AndrewJSchofield left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Thanks for the PR.

private Consumer<ClientResponse> onCompleteCallback;
protected final SharePartitionKey partitionKey;
// Combined-response index shared across handlers; set before onComplete, read-and-cleared in lookupPartitionResult (KAFKA-20803).
protected Object sharedResultIndex;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Hmm. Object here is not ideal.

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.

Updated, thanks!

@Shekharrajak

Copy link
Copy Markdown
Contributor Author

Thanks @smjn @AndrewJSchofield few updates

  • Trace log - added in coalesceRequests: LOG.trace("Received coalesce request for {} handlers of RPC type {}", handlers.size(), rpcType).
  • the index is now PartitionResultIndex

    with build/get

  • Keeping this PR to WRITE only and updated the description

writeStateResult(topicId, writePartitionResult(0, Errors.NONE))))));

assertWriteStateResult(present, topicId, 0, Errors.NONE);
assertWriteStateResult(missing, topicId, 1, Errors.UNKNOWN_SERVER_ERROR);

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.

the missing partition fails

assertEquals(1, requests.size());

// a null response must still fail every handler in the batch rather than throwing
assertDoesNotThrow(() -> requests.iterator().next().handler.onComplete(null));

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.

null response

writePartitionResult(0, Errors.FENCED_STATE_EPOCH),
writePartitionResult(1, Errors.NONE))))));

assertWriteStateResult(topic1Partition0, topicId1, 0, Errors.NONE);

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.

topic1 p0 and topic2 p0 are both in the same batch. Here the index key on topicId and partition helping.

for (T result : results) {
Map<Integer, P> byPartition = index.computeIfAbsent(topicId.apply(result), t -> new HashMap<>());
for (P p : partitions.apply(result)) {
byPartition.put(partition.applyAsInt(p), p);

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.

map inserts once and O(1) lookup

// that index rather than rescanning the response
verify(handlers.get(0), times(1)).buildResultIndex(any());
for (int i = 1; i < handlers.size(); i++) {
verify(handlers.get(i), never()).buildResultIndex(any());

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.

If sharedResultIndex was not handed to it, it is forced into that fallback and builds its own index. Here https://github.com/apache/kafka/pull/22835/changes#diff-14030e8d09d19bdb8e5b37a07657bef62db5cb9d76dce9bb0ebcf3058f6fd002R319

So one call on handler 0, zero across the other handlers

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci-approved core Kafka Broker KIP-932 Queues for Kafka

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants