KAFKA-20803: Demultiplex batched persister responses once (WriteStateHandler) - #22835
KAFKA-20803: Demultiplex batched persister responses once (WriteStateHandler)#22835Shekharrajak wants to merge 4 commits into
Conversation
|
A label of 'needs-attention' was automatically added to this PR in order to raise the |
|
CI check failure looking related to #22777 |
70955e0 to
6d188ec
Compare
| ToIntFunction<P> partition | ||
| ) { | ||
| Map<Uuid, Map<Integer, P>> index = new HashMap<>(); | ||
| for (T result : results) { |
There was a problem hiding this comment.
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) { |
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
the combined response is demultiplexed once into a topicId -> partition -> result map, and each
handler does an O(1) lookup -> O(N).
There was a problem hiding this comment.
About heap : lookupPartitionResult drops each handler's reference once it is consumed. so it will be alive for one callback only. No memory leak.
There was a problem hiding this comment.
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
indexByTopicPartitionhelper and per-handler plumbing (buildResultIndex/lookupPartitionResult) to support indexing combined responses once. - Updated
WriteStateHandlerto 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.
| 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()); |
There was a problem hiding this comment.
@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
left a comment
There was a problem hiding this comment.
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.
| 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; |
There was a problem hiding this comment.
Hmm. Object here is not ideal.
There was a problem hiding this comment.
Updated, thanks!
|
Thanks @smjn @AndrewJSchofield few updates
|
| writeStateResult(topicId, writePartitionResult(0, Errors.NONE)))))); | ||
|
|
||
| assertWriteStateResult(present, topicId, 0, Errors.NONE); | ||
| assertWriteStateResult(missing, topicId, 1, Errors.UNKNOWN_SERVER_ERROR); |
There was a problem hiding this comment.
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)); |
| writePartitionResult(0, Errors.FENCED_STATE_EPOCH), | ||
| writePartitionResult(1, Errors.NONE)))))); | ||
|
|
||
| assertWriteStateResult(topic1Partition0, topicId1, 0, Errors.NONE); |
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
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()); |
There was a problem hiding this comment.
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
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
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