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

AndrewJSchofield pushed a commit to branch trunk
in repository https://gitbox.apache.org/repos/asf/kafka.git


The following commit(s) were added to refs/heads/trunk by this push:
     new 2cc6ef65e5f KAFKA-20736: Close empty share sessions safely (#22888)
2cc6ef65e5f is described below

commit 2cc6ef65e5f34aa97ecf5d013fb7e5ff7942af07
Author: Andrew Schofield <[email protected]>
AuthorDate: Thu Jul 30 07:56:09 2026 +0100

    KAFKA-20736: Close empty share sessions safely (#22888)
    
    When all of the partitions have been removed from a share session
    because the share partitions assigned to a share consumer are no longer
    on the broker, the share session can be closed. It is important to wait
    until the share consumer has no outstanding acknowledgements to send to
    the broker (these can be completed even after a partition has been
    revoked from the consumer). This PR completes the share session
    improvements in AK 4.4.
    
    Reviewers: Shivsundar R <[email protected]>, Apoorv Mittal
     <[email protected]>
---
 checkstyle/suppressions.xml                        |   2 +-
 .../consumer/internals/Acknowledgements.java       |   8 +-
 .../consumer/internals/ShareCompletedFetch.java    |  18 +
 .../internals/ShareConsumeRequestManager.java      | 262 +++++++-----
 .../consumer/internals/ShareFetchBuffer.java       |  48 ++-
 .../consumer/internals/ShareSessionHandler.java    |  11 +-
 .../internals/ShareCompletedFetchTest.java         |  72 ++++
 .../internals/ShareConsumeRequestManagerTest.java  | 457 +++++++++++++++++----
 .../consumer/internals/ShareFetchBufferTest.java   | 149 ++++++-
 .../internals/ShareSessionHandlerTest.java         |  40 ++
 10 files changed, 871 insertions(+), 196 deletions(-)

diff --git a/checkstyle/suppressions.xml b/checkstyle/suppressions.xml
index 4f2c312f248..5ebf937d7c9 100644
--- a/checkstyle/suppressions.xml
+++ b/checkstyle/suppressions.xml
@@ -98,7 +98,7 @@
               files="(OffsetFetcher|RequestResponse)Test.java"/>
 
     <suppress checks="JavaNCSS"
-              
files="RequestResponseTest.java|FetcherTest.java|FetchRequestManagerTest.java|KafkaAdminClientTest.java|ConsumerMembershipManagerTest.java"/>
+              
files="RequestResponseTest.java|FetcherTest.java|FetchRequestManagerTest.java|KafkaAdminClientTest.java|ConsumerMembershipManagerTest.java|ShareConsumeRequestManagerTest.java"/>
 
     <suppress checks="NPathComplexity"
               
files="MemoryRecordsTest|MetricsTest|RequestResponseTest|TestSslUtils"/>
diff --git 
a/clients/src/main/java/org/apache/kafka/clients/consumer/internals/Acknowledgements.java
 
b/clients/src/main/java/org/apache/kafka/clients/consumer/internals/Acknowledgements.java
index a60d24520d0..2dfd4e3388c 100644
--- 
a/clients/src/main/java/org/apache/kafka/clients/consumer/internals/Acknowledgements.java
+++ 
b/clients/src/main/java/org/apache/kafka/clients/consumer/internals/Acknowledgements.java
@@ -130,8 +130,12 @@ public class Acknowledgements {
      * @param acknowledgeException the exception (will be null if successful)
      */
     public void complete(KafkaException acknowledgeException) {
-        this.acknowledgeException = acknowledgeException;
-        completed = true;
+        if (!completed) {
+            if (acknowledgeException != null) {
+                this.acknowledgeException = acknowledgeException;
+            }
+            completed = true;
+        }
     }
 
     /**
diff --git 
a/clients/src/main/java/org/apache/kafka/clients/consumer/internals/ShareCompletedFetch.java
 
b/clients/src/main/java/org/apache/kafka/clients/consumer/internals/ShareCompletedFetch.java
index f172949ec3a..ec0e7c7db0b 100644
--- 
a/clients/src/main/java/org/apache/kafka/clients/consumer/internals/ShareCompletedFetch.java
+++ 
b/clients/src/main/java/org/apache/kafka/clients/consumer/internals/ShareCompletedFetch.java
@@ -75,6 +75,7 @@ public class ShareCompletedFetch {
     private KafkaException cachedRecordException = null;
     private boolean isConsumed = false;
     private boolean initialized = false;
+    private ShareInFlightBatch<?, ?> deliveredBatch;
     private final List<OffsetAndDeliveryCount> acquiredRecordList;
     private ListIterator<OffsetAndDeliveryCount> acquiredRecordIterator;
     private OffsetAndDeliveryCount nextAcquired;
@@ -136,6 +137,22 @@ public class ShareCompletedFetch {
         return isConsumed;
     }
 
+    /**
+     * Track the batch that carries records delivered to the application so we 
can tell when their acknowledgements
+     * are still outstanding. We only advance to a new batch once the previous 
one has been drained of its
+     * in-flight records and its acknowledgements have been taken by the 
background thread to be sent.
+     */
+    void trackDeliveredBatch(ShareInFlightBatch<?, ?> inFlightBatch) {
+        if (deliveredBatch == null || (deliveredBatch.numRecords() == 0 && 
!deliveredBatch.hasRenewals())) {
+            deliveredBatch = inFlightBatch;
+        }
+    }
+
+    public boolean hasPendingAcknowledgements() {
+        // Records being renewed are removed and readded to the in-flight set 
as the renewals are confirmed.
+        return deliveredBatch != null && (deliveredBatch.numRecords() > 0 || 
deliveredBatch.hasRenewals());
+    }
+
     /**
      * Draining a {@link ShareCompletedFetch} will signal that the data has 
been consumed and the underlying resources
      * are closed. This is somewhat analogous to {@link Closeable#close() 
closing}, though no error will result if a
@@ -177,6 +194,7 @@ public class ShareCompletedFetch {
                                                  final boolean checkCrcs) {
         // Creating an empty ShareInFlightBatch
         ShareInFlightBatch<K, V> inFlightBatch = new 
ShareInFlightBatch<>(nodeId, partition, acquisitionLockTimeoutMs);
+        trackDeliveredBatch(inFlightBatch);
 
         if (cachedBatchException != null) {
             // In the event that a CRC check fails, reject the entire record 
batch because it is corrupt.
diff --git 
a/clients/src/main/java/org/apache/kafka/clients/consumer/internals/ShareConsumeRequestManager.java
 
b/clients/src/main/java/org/apache/kafka/clients/consumer/internals/ShareConsumeRequestManager.java
index d9399148784..5547531bc96 100644
--- 
a/clients/src/main/java/org/apache/kafka/clients/consumer/internals/ShareConsumeRequestManager.java
+++ 
b/clients/src/main/java/org/apache/kafka/clients/consumer/internals/ShareConsumeRequestManager.java
@@ -40,6 +40,7 @@ import 
org.apache.kafka.common.requests.ShareAcknowledgeRequest;
 import org.apache.kafka.common.requests.ShareAcknowledgeResponse;
 import org.apache.kafka.common.requests.ShareFetchRequest;
 import org.apache.kafka.common.requests.ShareFetchResponse;
+import org.apache.kafka.common.requests.ShareRequestMetadata;
 import org.apache.kafka.common.utils.Time;
 import org.apache.kafka.common.utils.Utils;
 import org.apache.kafka.common.utils.internals.BufferSupplier;
@@ -246,10 +247,17 @@ public class ShareConsumeRequestManager implements 
RequestManager, MemberStateLi
             }
         }
 
+        // Calculate the set of nodes which have records buffered for the 
consumer to consume and acknowledge.
+        // Even when assignments change, we do not close the share sessions 
which have buffered records.
+        // This also does bookkeeping of the nodes which had pending 
acknowledgements in case they have become
+        // eligible for cleaning up.
+        Set<Integer> bufferedNodes = shareFetchBuffer.bufferedNodes();
+
         // Iterate over the session handlers to see if there are 
acknowledgements to be sent for partitions
         // which are no longer part of the current subscription or which have 
disappeared from the metadata.
         // Also include session handlers which have no fetchable partitions so 
the share sessions can be
-        // brought up to date.
+        // brought up to date. Finally, close share sessions for nodes which 
no longer have any share partitions
+        // and which do not have buffered data.
         sessionHandlers.forEach((nodeId, sessionHandler) -> {
             Node node = cluster.nodeById(nodeId);
             if (node == null) {
@@ -258,6 +266,10 @@ public class ShareConsumeRequestManager implements 
RequestManager, MemberStateLi
             }
             if (nodesWithPendingRequests.contains(nodeId)) {
                 log.trace("Skipping fetch because previous request to {} has 
not been processed", nodeId);
+            } else if (isSessionCloseCandidate(node, sessionHandler, 
handlerMap, bufferedNodes)) {
+                log.debug("Closing share session for node {} which is now 
empty", nodeId);
+                sessionHandler.notifyClose();
+                handlerMap.put(node, sessionHandler);
             } else {
                 prepareRemainingSessionHandlerForRequest(node, sessionHandler, 
handlerMap);
             }
@@ -340,7 +352,8 @@ public class ShareConsumeRequestManager implements 
RequestManager, MemberStateLi
         Map<TopicIdPartition, Acknowledgements> nodeAcksFromFetchMap = 
fetchAcknowledgementsToSend.get(node.id());
         if (nodeAcksFromFetchMap != null) {
             nodeAcksFromFetchMap.forEach((tip, acks) -> {
-                if (!isLeaderKnownToHaveChanged(node.id(), tip)) {
+                LeaderIdAndEpoch leader = shareSessionLeaderMap.get(tip);
+                if (leader != null && leader.leaderId == node.id()) {
                     // Check if the share session epoch is valid for sending 
acknowledgements.
                     if (!maybeAddAcknowledgements(sessionHandler, node, tip, 
acks)) {
                         return;
@@ -352,7 +365,7 @@ public class ShareConsumeRequestManager implements 
RequestManager, MemberStateLi
                     log.debug("Added fetch request for previously subscribed 
partition {} to node {}", tip, node.id());
                 } else {
                     log.debug("Leader for the partition is down or has 
changed, failing acknowledgements for partition {}", tip);
-                    acks.complete(Errors.NETWORK_EXCEPTION.exception());
+                    acks.complete(acknowledgementsCannotBeSentError(node.id(), 
tip).exception());
                     maybeSendShareAcknowledgementEvent(Map.of(tip, acks), 
true, Optional.empty());
                 }
             });
@@ -364,6 +377,49 @@ public class ShareConsumeRequestManager implements 
RequestManager, MemberStateLi
         }
     }
 
+    /**
+     * A share session can be closed if it's empty and has no buffered data.
+     *
+     * @return True if the node's share session can be closed.
+     */
+    private boolean isSessionCloseCandidate(Node node,
+                                            ShareSessionHandler sessionHandler,
+                                            Map<Node, ShareSessionHandler> 
handlerMap,
+                                            Set<Integer> bufferedNodes) {
+        final int nodeId = node.id();
+        return !closing                                 // the close path 
already managed closing share sessions
+            && sessionHandler.isSessionEmpty()          // no share partitions 
remain in the share session
+            && !sessionHandler.isNewSession()           // the session is 
established on the broker
+            && !handlerMap.containsKey(node)            // the handler is 
already being used in this poll
+            && !bufferedNodes.contains(nodeId)          // no records for this 
node remain in the fetch buffer
+            && !nodeHasPendingAcknowledgements(nodeId); // the node has 
pending acknowledgements
+    }
+
+    /**
+     * Returns whether there are acknowledgements still being delivered for 
the node, whether waiting to be sent,
+     * in flight, or being processed as part of an acknowledge request state.
+     */
+    private boolean nodeHasPendingAcknowledgements(int nodeId) {
+        Map<TopicIdPartition, Acknowledgements> acksToSend = 
fetchAcknowledgementsToSend.get(nodeId);
+        if (acksToSend != null && !acksToSend.isEmpty()) {
+            return true;
+        }
+
+        Map<TopicIdPartition, Acknowledgements> acksInFlight = 
fetchAcknowledgementsInFlight.get(nodeId);
+        if (acksInFlight != null && !acksInFlight.isEmpty()) {
+            return true;
+        }
+
+        Tuple<AcknowledgeRequestState> requestStates = 
acknowledgeRequestStates.get(nodeId);
+        if (requestStates != null) {
+            return 
areRequestStatesInProgress(requestStates.getSyncRequestQueue())
+                || isRequestStateInProgress(requestStates.getAsyncRequest())
+                || isRequestStateInProgress(requestStates.getCloseRequest());
+        }
+
+        return false;
+    }
+
     /**
      * Remove session handlers for nodes which are now missing from the 
cluster metadata. Only nodes with no
      * in-flight requests are removed, since those requests will complete. 
Piggyback acknowledgements for the
@@ -620,35 +676,21 @@ public class ShareConsumeRequestManager implements 
RequestManager, MemberStateLi
     public CompletableFuture<Map<TopicIdPartition, Acknowledgements>> 
commitSync(
             final Map<TopicIdPartition, NodeAcknowledgements> 
acknowledgementsMap,
             final long deadlineMs) {
-        final Cluster cluster = metadata.fetch();
         final AtomicInteger resultCount = new AtomicInteger();
         final CompletableFuture<Map<TopicIdPartition, Acknowledgements>> 
future = new CompletableFuture<>();
         final ResultHandler resultHandler = new ResultHandler(resultCount, 
Optional.of(future));
 
         Map<Integer, Map<TopicIdPartition, Acknowledgements>> 
acknowledgementsMapAllNodes = new HashMap<>();
-        Map<TopicIdPartition, Acknowledgements> acknowledgementsMapCannotSend 
= new HashMap<>();
-        Map<TopicIdPartition, Errors> acknowledgementsMapCannotSendErrors = 
new HashMap<>();
         acknowledgementsMap.forEach((tip, nodeAcks) -> {
-            if ((cluster.nodeById(nodeAcks.nodeId()) == null) || 
isLeaderKnownToHaveChanged(nodeAcks.nodeId(), tip)) {
-                Acknowledgements prevAcks = 
acknowledgementsMapCannotSend.putIfAbsent(tip, nodeAcks.acknowledgements());
-                if (prevAcks != null) {
-                    prevAcks.merge(nodeAcks.acknowledgements());
-                } else {
-                    acknowledgementsMapCannotSendErrors.put(tip, 
acknowledgementsCannotBeSentError(nodeAcks.nodeId(), tip));
-                }
-            } else {
-                Map<TopicIdPartition, Acknowledgements> acksMap = 
acknowledgementsMapAllNodes.computeIfAbsent(nodeAcks.nodeId(), k -> new 
HashMap<>());
-                Acknowledgements prevAcks = acksMap.putIfAbsent(tip, 
nodeAcks.acknowledgements());
-                if (prevAcks != null) {
-                    prevAcks.merge(nodeAcks.acknowledgements());
-                }
+            Map<TopicIdPartition, Acknowledgements> acksMap = 
acknowledgementsMapAllNodes.computeIfAbsent(nodeAcks.nodeId(), k -> new 
HashMap<>());
+            Acknowledgements prevAcks = acksMap.putIfAbsent(tip, 
nodeAcks.acknowledgements());
+            if (prevAcks != null) {
+                prevAcks.merge(nodeAcks.acknowledgements());
             }
         });
 
-        resultCount.addAndGet(acknowledgementsMapCannotSend.size());
-
         sessionHandlers.forEach((nodeId, sessionHandler) -> {
-            Map<TopicIdPartition, Acknowledgements> nodeAcknowledgements = 
acknowledgementsMapAllNodes.get(nodeId);
+            Map<TopicIdPartition, Acknowledgements> nodeAcknowledgements = 
acknowledgementsMapAllNodes.remove(nodeId);
             if (nodeAcknowledgements == null)
                 return;
 
@@ -657,21 +699,14 @@ public class ShareConsumeRequestManager implements 
RequestManager, MemberStateLi
             acknowledgeRequestStates.putIfAbsent(nodeId, new Tuple<>(null, 
null, null));
 
             // Add the incoming commitSync() request to the queue.
-            for (TopicIdPartition tip : sessionHandler.sessionPartitions()) {
-                Acknowledgements acknowledgements = 
nodeAcknowledgements.remove(tip);
-                if (acknowledgements != null) {
-                    acknowledgementsMapToSend.put(tip, acknowledgements);
+            nodeAcknowledgements.forEach((tip, acks) -> {
+                if (acks != null) {
+                    acknowledgementsMapToSend.put(tip, acks);
                     resultCount.incrementAndGet();
 
-                    
metricsManager.recordAcknowledgementSent(acknowledgements.size());
+                    metricsManager.recordAcknowledgementSent(acks.size());
                     log.debug("Added sync acknowledge request for partition {} 
to node {}", tip.topicPartition(), nodeId);
                 }
-            }
-
-            resultCount.addAndGet(nodeAcknowledgements.size());
-            nodeAcknowledgements.forEach((tip, acks) -> {
-                acks.complete(Errors.NOT_LEADER_OR_FOLLOWER.exception());
-                resultHandler.complete(tip, acks, 
AcknowledgeRequestType.COMMIT_SYNC, true, Optional.empty());
             });
 
             if (!acknowledgementsMapToSend.isEmpty()) {
@@ -689,10 +724,17 @@ public class ShareConsumeRequestManager implements 
RequestManager, MemberStateLi
             }
         });
 
-        acknowledgementsMapCannotSend.forEach((tip, acks) -> {
-            
acks.complete(acknowledgementsMapCannotSendErrors.get(tip).exception());
-            resultHandler.complete(tip, acks, 
AcknowledgeRequestType.COMMIT_SYNC, true, Optional.empty());
-        });
+        if (!acknowledgementsMapAllNodes.isEmpty()) {
+            acknowledgementsMapAllNodes.forEach((nodeId, nodeAcknowledgements) 
-> resultCount.addAndGet(nodeAcknowledgements.size()));
+
+            acknowledgementsMapAllNodes.forEach((nodeId, nodeAcknowledgements) 
->
+                nodeAcknowledgements.forEach((tip, acks) -> {
+                    log.debug("No share session handler for node {}, failing 
acknowledgements for partition {}", nodeId, tip);
+                    acks.complete(acknowledgementsCannotBeSentError(nodeId, 
tip).exception());
+                    resultHandler.complete(tip, acks, 
AcknowledgeRequestType.COMMIT_SYNC, true, Optional.empty());
+                })
+            );
+        }
 
         resultHandler.completeIfEmpty();
         return future;
@@ -708,26 +750,19 @@ public class ShareConsumeRequestManager implements 
RequestManager, MemberStateLi
     public void commitAsync(
             final Map<TopicIdPartition, NodeAcknowledgements> 
acknowledgementsMap,
             final long deadlineMs) {
-        final Cluster cluster = metadata.fetch();
         final ResultHandler resultHandler = new 
ResultHandler(Optional.empty());
 
         Map<Integer, Map<TopicIdPartition, Acknowledgements>> 
acknowledgementsMapAllNodes = new HashMap<>();
         acknowledgementsMap.forEach((tip, nodeAcks) -> {
-            if ((cluster.nodeById(nodeAcks.nodeId()) == null) || 
isLeaderKnownToHaveChanged(nodeAcks.nodeId(), tip)) {
-                log.debug("Leader for the partition is down or has changed, 
failing acknowledgements for partition {}", tip);
-                
nodeAcks.acknowledgements().complete(acknowledgementsCannotBeSentError(nodeAcks.nodeId(),
 tip).exception());
-                maybeSendShareAcknowledgementEvent(Map.of(tip, 
nodeAcks.acknowledgements()), true, Optional.empty());
-            } else {
-                Map<TopicIdPartition, Acknowledgements> acksMap = 
acknowledgementsMapAllNodes.computeIfAbsent(nodeAcks.nodeId(), k -> new 
HashMap<>());
-                Acknowledgements prevAcks = acksMap.putIfAbsent(tip, 
nodeAcks.acknowledgements());
-                if (prevAcks != null) {
-                    prevAcks.merge(nodeAcks.acknowledgements());
-                }
+            Map<TopicIdPartition, Acknowledgements> acksMap = 
acknowledgementsMapAllNodes.computeIfAbsent(nodeAcks.nodeId(), k -> new 
HashMap<>());
+            Acknowledgements prevAcks = acksMap.putIfAbsent(tip, 
nodeAcks.acknowledgements());
+            if (prevAcks != null) {
+                prevAcks.merge(nodeAcks.acknowledgements());
             }
         });
 
         sessionHandlers.forEach((nodeId, sessionHandler) -> {
-            Map<TopicIdPartition, Acknowledgements> nodeAcknowledgements = 
acknowledgementsMapAllNodes.get(nodeId);
+            Map<TopicIdPartition, Acknowledgements> nodeAcknowledgements = 
acknowledgementsMapAllNodes.remove(nodeId);
             if (nodeAcknowledgements == null)
                 return;
 
@@ -735,13 +770,13 @@ public class ShareConsumeRequestManager implements 
RequestManager, MemberStateLi
 
             acknowledgeRequestStates.putIfAbsent(nodeId, new Tuple<>(null, 
null, null));
 
-            for (TopicIdPartition tip : sessionHandler.sessionPartitions()) {
-                Acknowledgements acknowledgements = 
nodeAcknowledgements.remove(tip);
-                if (acknowledgements != null) {
-                    acknowledgementsMapForNode.put(tip, acknowledgements);
+            nodeAcknowledgements.forEach((tip, acks) -> {
+                if (acks != null) {
+                    acknowledgementsMapForNode.put(tip, acks);
 
-                    
metricsManager.recordAcknowledgementSent(acknowledgements.size());
+                    metricsManager.recordAcknowledgementSent(acks.size());
                     log.debug("Added async acknowledge request for partition 
{} to node {}", tip.topicPartition(), nodeId);
+
                     AcknowledgeRequestState asyncRequestState = 
acknowledgeRequestStates.get(nodeId).getAsyncRequest();
                     if (asyncRequestState == null) {
                         
acknowledgeRequestStates.get(nodeId).setAsyncRequest(new 
AcknowledgeRequestState(logContext,
@@ -756,19 +791,22 @@ public class ShareConsumeRequestManager implements 
RequestManager, MemberStateLi
                             AcknowledgeRequestType.COMMIT_ASYNC
                         ));
                     } else {
-                        Acknowledgements prevAcks = 
asyncRequestState.acknowledgementsToSend.putIfAbsent(tip, acknowledgements);
+                        Acknowledgements prevAcks = 
asyncRequestState.acknowledgementsToSend.putIfAbsent(tip, acks);
                         if (prevAcks != null) {
-                            
asyncRequestState.acknowledgementsToSend.get(tip).merge(acknowledgements);
+                            
asyncRequestState.acknowledgementsToSend.get(tip).merge(acks);
                         }
                     }
                 }
-            }
+            });
+        });
 
+        acknowledgementsMapAllNodes.forEach((nodeId, nodeAcknowledgements) ->
             nodeAcknowledgements.forEach((tip, acks) -> {
-                acks.complete(Errors.NOT_LEADER_OR_FOLLOWER.exception());
+                log.debug("No share session handler for node {}, failing 
acknowledgements for partition {}", nodeId, tip);
+                acks.complete(acknowledgementsCannotBeSentError(nodeId, 
tip).exception());
                 maybeSendShareAcknowledgementEvent(Map.of(tip, acks), true, 
Optional.empty());
-            });
-        });
+            })
+        );
 
         resultHandler.completeIfEmpty();
     }
@@ -786,7 +824,6 @@ public class ShareConsumeRequestManager implements 
RequestManager, MemberStateLi
     public CompletableFuture<Void> acknowledgeOnClose(
             final Map<TopicIdPartition, NodeAcknowledgements> 
acknowledgementsMap,
             final long deadlineMs) {
-        final Cluster cluster = metadata.fetch();
         final AtomicInteger resultCount = new AtomicInteger();
         final ResultHandler resultHandler = new ResultHandler(resultCount, 
Optional.empty());
 
@@ -794,36 +831,26 @@ public class ShareConsumeRequestManager implements 
RequestManager, MemberStateLi
 
         Map<Integer, Map<TopicIdPartition, Acknowledgements>> 
acknowledgementsMapAllNodes = new HashMap<>();
         acknowledgementsMap.forEach((tip, nodeAcks) -> {
-            if ((cluster.nodeById(nodeAcks.nodeId()) == null) || 
isLeaderKnownToHaveChanged(nodeAcks.nodeId(), tip)) {
-                
nodeAcks.acknowledgements().complete(acknowledgementsCannotBeSentError(nodeAcks.nodeId(),
 tip).exception());
-                maybeSendShareAcknowledgementEvent(Map.of(tip, 
nodeAcks.acknowledgements()), true, Optional.empty());
-            } else {
-                Map<TopicIdPartition, Acknowledgements> acksMap = 
acknowledgementsMapAllNodes.computeIfAbsent(nodeAcks.nodeId(), k -> new 
HashMap<>());
-                Acknowledgements prevAcks = acksMap.putIfAbsent(tip, 
nodeAcks.acknowledgements());
-                if (prevAcks != null) {
-                    prevAcks.merge(nodeAcks.acknowledgements());
-                }
+            Map<TopicIdPartition, Acknowledgements> acksMap = 
acknowledgementsMapAllNodes.computeIfAbsent(nodeAcks.nodeId(), k -> new 
HashMap<>());
+            Acknowledgements prevAcks = acksMap.putIfAbsent(tip, 
nodeAcks.acknowledgements());
+            if (prevAcks != null) {
+                prevAcks.merge(nodeAcks.acknowledgements());
             }
         });
 
         // Add any waiting piggyback acknowledgements.
         fetchAcknowledgementsToSend.forEach((nodeId, nodeAcks) ->
             nodeAcks.forEach((tip, acks) -> {
-                if ((cluster.nodeById(nodeId) == null) || 
isLeaderKnownToHaveChanged(nodeId, tip)) {
-                    acks.complete(acknowledgementsCannotBeSentError(nodeId, 
tip).exception());
-                    maybeSendShareAcknowledgementEvent(Map.of(tip, acks), 
true, Optional.empty());
-                } else {
-                    Map<TopicIdPartition, Acknowledgements> acksMap = 
acknowledgementsMapAllNodes.computeIfAbsent(nodeId, k -> new HashMap<>());
-                    Acknowledgements prevAcks = acksMap.putIfAbsent(tip, acks);
-                    if (prevAcks != null) {
-                        prevAcks.merge(acks);
-                    }
+                Map<TopicIdPartition, Acknowledgements> acksMap = 
acknowledgementsMapAllNodes.computeIfAbsent(nodeId, k -> new HashMap<>());
+                Acknowledgements prevAcks = acksMap.putIfAbsent(tip, acks);
+                if (prevAcks != null) {
+                    prevAcks.merge(acks);
                 }
             })
         );
 
         sessionHandlers.forEach((nodeId, sessionHandler) -> {
-            Map<TopicIdPartition, Acknowledgements> nodeAcknowledgements = 
acknowledgementsMapAllNodes.get(nodeId);
+            Map<TopicIdPartition, Acknowledgements> nodeAcknowledgements = 
acknowledgementsMapAllNodes.remove(nodeId);
             if (nodeAcknowledgements != null) {
                 nodeAcknowledgements.forEach((tip, acknowledgements) -> {
                     resultCount.incrementAndGet();
@@ -863,33 +890,18 @@ public class ShareConsumeRequestManager implements 
RequestManager, MemberStateLi
             }
         });
 
+        acknowledgementsMapAllNodes.forEach((nodeId, nodeAcknowledgements) ->
+            nodeAcknowledgements.forEach((tip, acks) -> {
+                log.debug("No share session handler for node {}, failing 
acknowledgements for partition {}", nodeId, tip);
+                acks.complete(acknowledgementsCannotBeSentError(nodeId, 
tip).exception());
+                maybeSendShareAcknowledgementEvent(Map.of(tip, acks), true, 
Optional.empty());
+            })
+        );
+
         resultHandler.completeIfEmpty();
         return closeFuture;
     }
 
-    /**
-     * The method checks whether the leader for a topicIdPartition has changed.
-     *
-     * @param nodeId The previous leader for the partition.
-     * @param topicIdPartition The TopicIdPartition to check.
-     * @return Returns true if leader information is available and leader has 
changed.
-     * If the leader information is not available or if the leader has not 
changed, it returns false.
-     */
-    private boolean isLeaderKnownToHaveChanged(int nodeId, TopicIdPartition 
topicIdPartition) {
-        Optional<Node> leaderNode = 
metadata.currentLeader(topicIdPartition.topicPartition()).leader;
-        if (leaderNode.isPresent()) {
-            if (leaderNode.get().id() != nodeId) {
-                log.debug("Node {} is no longer the leader for partition {}, 
failing acknowledgements", nodeId, topicIdPartition);
-                return true;
-            }
-        } else {
-            log.debug("No leader found for partition {}", topicIdPartition);
-            metadata.requestUpdate(false);
-            return false;
-        }
-        return false;
-    }
-
     private void handleShareFetchSuccess(Node fetchTarget,
                                          ShareFetchRequestData requestData,
                                          ClientResponse resp) {
@@ -1028,6 +1040,8 @@ public class ShareConsumeRequestManager implements 
RequestManager, MemberStateLi
                 fetchRecordsNodeId.compareAndSet(fetchTarget.id(), -1);
             }
             nodesWithPendingRequests.remove(fetchTarget.id());
+
+            maybeRemoveSessionHandlerOnClose(fetchTarget.id(), requestData);
         }
     }
 
@@ -1068,6 +1082,19 @@ public class ShareConsumeRequestManager implements 
RequestManager, MemberStateLi
                 fetchRecordsNodeId.compareAndSet(fetchTarget.id(), -1);
             }
             nodesWithPendingRequests.remove(fetchTarget.id());
+
+            maybeRemoveSessionHandlerOnClose(fetchTarget.id(), requestData);
+        }
+    }
+
+    /**
+     * If the completed ShareFetch was closing the share session with a final 
epoch, the session handler
+     * is no longer quired. A subsequent poll will establish a new session if 
partitions return to the node.
+     */
+    private void maybeRemoveSessionHandlerOnClose(int nodeId, 
ShareFetchRequestData requestData) {
+        if (requestData.shareSessionEpoch() == 
ShareRequestMetadata.FINAL_EPOCH) {
+            log.debug("Removing session handler for node {} after closing 
empty share session", nodeId);
+            sessionHandlers.remove(nodeId);
         }
     }
 
@@ -1269,7 +1296,27 @@ public class ShareConsumeRequestManager implements 
RequestManager, MemberStateLi
      * Otherwise, the error is {@link Errors#NETWORK_EXCEPTION}.
      */
     private Errors acknowledgementsCannotBeSentError(int nodeId, 
TopicIdPartition tip) {
-        return isLeaderKnownToHaveChanged(nodeId, tip) ? 
Errors.NOT_LEADER_OR_FOLLOWER : Errors.NETWORK_EXCEPTION;
+        LeaderIdAndEpoch leaderAndEpoch = shareSessionLeaderMap.get(tip);
+        if (leaderAndEpoch != null && 
metadata.fetch().nodeById(leaderAndEpoch.leaderId) != null) {
+            if (leaderAndEpoch.leaderId != nodeId) {
+                log.debug("Node {} is no longer the leader for partition {}, 
failing acknowledgements", nodeId, tip);
+                return Errors.NOT_LEADER_OR_FOLLOWER;
+            }
+            return Errors.NETWORK_EXCEPTION;
+        }
+
+        Optional<Node> leaderNode = 
metadata.currentLeader(tip.topicPartition()).leader;
+        if (leaderNode.isPresent()) {
+            if (leaderNode.get().id() != nodeId) {
+                log.debug("Node {} is no longer the leader for partition {}, 
failing acknowledgements", nodeId, tip);
+                return Errors.NOT_LEADER_OR_FOLLOWER;
+            }
+        } else {
+            log.debug("No leader found for partition {}", tip);
+            metadata.requestUpdate(false);
+        }
+
+        return Errors.NETWORK_EXCEPTION;
     }
 
     private TopicIdPartition lookupTopicId(Uuid topicId, int partitionIndex) {
@@ -1403,7 +1450,7 @@ public class ShareConsumeRequestManager implements 
RequestManager, MemberStateLi
                 incompleteAcknowledgements.isEmpty() ? acknowledgementsToSend 
: incompleteAcknowledgements);
 
             for (Map.Entry<TopicIdPartition, Acknowledgements> entry : 
finalAcknowledgementsToSend.entrySet()) {
-                sessionHandler.addPartitionToFetch(entry.getKey(), 
entry.getValue());
+                sessionHandler.addPartitionToAcknowledgeOnly(entry.getKey(), 
entry.getValue());
             }
 
             ShareAcknowledgeRequest.Builder requestBuilder = 
sessionHandler.newShareAcknowledgeBuilder(groupId);
@@ -1694,6 +1741,11 @@ public class ShareConsumeRequestManager implements 
RequestManager, MemberStateLi
         return acknowledgeRequestStates.get(nodeId);
     }
 
+    int shareSessionNodeId(TopicIdPartition tip) {
+        LeaderIdAndEpoch leader = shareSessionLeaderMap.get(tip);
+        return leader == null ? -1 : leader.leaderId;
+    }
+
     static class IdAndPartition {
         private final Uuid topicId;
         private final int partitionIndex;
diff --git 
a/clients/src/main/java/org/apache/kafka/clients/consumer/internals/ShareFetchBuffer.java
 
b/clients/src/main/java/org/apache/kafka/clients/consumer/internals/ShareFetchBuffer.java
index a7fbaddd781..c0ed8275dec 100644
--- 
a/clients/src/main/java/org/apache/kafka/clients/consumer/internals/ShareFetchBuffer.java
+++ 
b/clients/src/main/java/org/apache/kafka/clients/consumer/internals/ShareFetchBuffer.java
@@ -52,6 +52,10 @@ public class ShareFetchBuffer implements AutoCloseable {
     private final AtomicBoolean wokenUp = new AtomicBoolean(false);
     private ShareCompletedFetch nextInLineFetch;
 
+    // Fetches which have been consumed but still have acknowledgements 
outstanding, retained here after they leave
+    // the next-in-line slot so that the share session for their node is not 
closed prematurely.
+    private final Set<ShareCompletedFetch> pendingAcknowledgementFetches = new 
HashSet<>();
+
     public ShareFetchBuffer(final LogContext logContext) {
         this.log = logContext.logger(ShareFetchBuffer.class);
         this.completedFetches = new ConcurrentLinkedQueue<>();
@@ -95,6 +99,11 @@ public class ShareFetchBuffer implements AutoCloseable {
     void setNextInLineFetch(ShareCompletedFetch nextInLineFetch) {
         lock.lock();
         try {
+            // If the outgoing next-in-line fetch still has outstanding 
acknowledgements,
+            // retain it until the acknowledgements are sent.
+            if (this.nextInLineFetch != null && 
this.nextInLineFetch.hasPendingAcknowledgements()) {
+                pendingAcknowledgementFetches.add(this.nextInLineFetch);
+            }
             this.nextInLineFetch = nextInLineFetch;
         } finally {
             lock.unlock();
@@ -169,7 +178,7 @@ public class ShareFetchBuffer implements AutoCloseable {
     }
 
     /**
-     * Return the set of {@link TopicIdPartition partitions} for which we have 
data in the buffer.
+     * Return the set of {@link TopicIdPartition partitions} for which we have 
data in the buffer or pending acknowledgements.
      *
      * @return {@link TopicIdPartition Partition} set
      */
@@ -178,17 +187,50 @@ public class ShareFetchBuffer implements AutoCloseable {
         try {
             final Set<TopicIdPartition> partitions = new HashSet<>();
 
-            if (nextInLineFetch != null && !nextInLineFetch.isConsumed()) {
+            if (nextInLineFetch != null && (!nextInLineFetch.isConsumed() || 
nextInLineFetch.hasPendingAcknowledgements())) {
                 partitions.add(nextInLineFetch.partition);
             }
 
             completedFetches.forEach(cf -> partitions.add(cf.partition));
+
+            prunePendingAcknowledgementFetches();
+            pendingAcknowledgementFetches.forEach(cf -> 
partitions.add(cf.partition));
+
             return partitions;
         } finally {
             lock.unlock();
         }
     }
 
+    /**
+     * Return the set of node IDs for which we have data in the buffer.
+     *
+     * @return Node ID set
+     */
+    Set<Integer> bufferedNodes() {
+        lock.lock();
+        try {
+            final Set<Integer> nodes = new HashSet<>();
+
+            if (nextInLineFetch != null && (!nextInLineFetch.isConsumed() || 
nextInLineFetch.hasPendingAcknowledgements())) {
+                nodes.add(nextInLineFetch.nodeId);
+            }
+
+            completedFetches.forEach(cf -> nodes.add(cf.nodeId));
+
+            prunePendingAcknowledgementFetches();
+            pendingAcknowledgementFetches.forEach(cf -> nodes.add(cf.nodeId));
+
+            return nodes;
+        } finally {
+            lock.unlock();
+        }
+    }
+
+    private void prunePendingAcknowledgementFetches() {
+        pendingAcknowledgementFetches.removeIf(cf -> 
!cf.hasPendingAcknowledgements());
+    }
+
     private void drainAll() {
         lock.lock();
         try {
@@ -198,6 +240,8 @@ public class ShareFetchBuffer implements AutoCloseable {
                 nextInLineFetch.drain();
                 nextInLineFetch = null;
             }
+            pendingAcknowledgementFetches.forEach(ShareCompletedFetch::drain);
+            pendingAcknowledgementFetches.clear();
         } finally {
             lock.unlock();
         }
diff --git 
a/clients/src/main/java/org/apache/kafka/clients/consumer/internals/ShareSessionHandler.java
 
b/clients/src/main/java/org/apache/kafka/clients/consumer/internals/ShareSessionHandler.java
index c89716ad622..59caf25c083 100644
--- 
a/clients/src/main/java/org/apache/kafka/clients/consumer/internals/ShareSessionHandler.java
+++ 
b/clients/src/main/java/org/apache/kafka/clients/consumer/internals/ShareSessionHandler.java
@@ -41,7 +41,6 @@ import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.Map.Entry;
-import java.util.Set;
 import java.util.stream.Collectors;
 
 /**
@@ -94,10 +93,6 @@ public class ShareSessionHandler {
         return sessionPartitions;
     }
 
-    public Collection<TopicIdPartition> sessionPartitions() {
-        return Set.copyOf(sessionPartitions.values());
-    }
-
     public void addPartitionToFetch(TopicIdPartition topicIdPartition, 
Acknowledgements partitionAcknowledgements) {
         nextPartitions.put(topicIdPartition.topicPartition(), 
topicIdPartition);
         if (partitionAcknowledgements != null) {
@@ -113,6 +108,10 @@ public class ShareSessionHandler {
         return nextMetadata.isNewSession();
     }
 
+    public boolean isSessionEmpty() {
+        return sessionPartitions.isEmpty();
+    }
+
     public ShareFetchRequest.Builder newShareFetchBuilder(String groupId, 
ShareFetchConfig shareFetchConfig, boolean canSkipIfRequestEmpty) {
         List<TopicIdPartition> added = new ArrayList<>();
         List<TopicIdPartition> removed = new ArrayList<>();
@@ -190,7 +189,7 @@ public class ShareSessionHandler {
         nextAcknowledgements = new LinkedHashMap<>();
 
         // If there are no changes to the share session and no 
acknowledgements, we can sometimes skip sending an empty request
-        if (added.isEmpty() && removed.isEmpty() && 
acknowledgementBatches.isEmpty()) {
+        if (added.isEmpty() && removed.isEmpty() && 
acknowledgementBatches.isEmpty() && !nextMetadata.isFinalEpoch()) {
             // If the share session is empty, there are no partitions to fetch 
from and we can always skip
             if (sessionPartitions.isEmpty()) {
                 log.debug("Skipping sending empty ShareFetch because share 
partitions empty");
diff --git 
a/clients/src/test/java/org/apache/kafka/clients/consumer/internals/ShareCompletedFetchTest.java
 
b/clients/src/test/java/org/apache/kafka/clients/consumer/internals/ShareCompletedFetchTest.java
index a6bc4943821..4bbf8ea08cf 100644
--- 
a/clients/src/test/java/org/apache/kafka/clients/consumer/internals/ShareCompletedFetchTest.java
+++ 
b/clients/src/test/java/org/apache/kafka/clients/consumer/internals/ShareCompletedFetchTest.java
@@ -23,6 +23,7 @@ import org.apache.kafka.common.TopicPartition;
 import org.apache.kafka.common.Uuid;
 import org.apache.kafka.common.compress.Compression;
 import org.apache.kafka.common.errors.CorruptRecordException;
+import org.apache.kafka.common.errors.NetworkException;
 import org.apache.kafka.common.errors.RecordDeserializationException;
 import org.apache.kafka.common.header.Headers;
 import org.apache.kafka.common.header.internals.RecordHeaders;
@@ -58,6 +59,7 @@ import java.util.UUID;
 
 import static org.junit.jupiter.api.Assertions.assertArrayEquals;
 import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertNotNull;
 import static org.junit.jupiter.api.Assertions.assertNull;
 import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -110,6 +112,76 @@ public class ShareCompletedFetchTest {
         assertEquals(DEFAULT_ACQUISITION_LOCK_TIMEOUT_MS, 
batch.getAcquisitionLockTimeoutMs());
     }
 
+    @Test
+    public void testHasPendingAcknowledgements() {
+        long startingOffset = 10L;
+        int numRecords = 5;
+        ShareFetchResponseData.PartitionData partitionData = new 
ShareFetchResponseData.PartitionData()
+            .setRecords(newRecords(startingOffset, numRecords, 1))
+            .setAcquiredRecords(acquiredRecords(startingOffset, numRecords));
+
+        Deserializers<String, String> deserializers = newStringDeserializers();
+        ShareCompletedFetch completedFetch = 
newShareCompletedFetch(partitionData);
+
+        // No records have been delivered to the application yet.
+        assertFalse(completedFetch.hasPendingAcknowledgements());
+
+        // Once the records have been delivered, their acknowledgements are 
outstanding even though the fetch is consumed.
+        ShareInFlightBatch<String, String> batch = 
completedFetch.fetchRecords(deserializers, 10, true);
+        assertEquals(numRecords, batch.getInFlightRecords().size());
+        assertTrue(completedFetch.isConsumed());
+        assertTrue(completedFetch.hasPendingAcknowledgements());
+
+        // Acknowledging the records and taking them to be sent by the 
background thread, clears the outstanding acknowledgements.
+        batch.acknowledgeAll(AcknowledgeType.ACCEPT);
+        batch.takeAcknowledgedRecords();
+        assertFalse(completedFetch.hasPendingAcknowledgements());
+    }
+
+    @Test
+    public void testPendingRenewAcknowledgementsWithException() {
+        long startingOffset = 10L;
+        int numRecords = 5;
+        ShareFetchResponseData.PartitionData partitionData = new 
ShareFetchResponseData.PartitionData()
+            .setRecords(newRecords(startingOffset, numRecords, 1))
+            .setAcquiredRecords(acquiredRecords(startingOffset, numRecords));
+
+        Deserializers<String, String> deserializers = newStringDeserializers();
+        ShareCompletedFetch completedFetch = 
newShareCompletedFetch(partitionData);
+
+        // No records have been delivered to the application yet.
+        assertFalse(completedFetch.hasPendingAcknowledgements());
+
+        // Once the record have been delivered, their acknowledgements are 
outstanding even though the fetch is consumed.
+        ShareInFlightBatch<String, String> batch = 
completedFetch.fetchRecords(deserializers, 10, true);
+        assertEquals(numRecords, batch.getInFlightRecords().size());
+        assertTrue(completedFetch.isConsumed());
+        assertTrue(completedFetch.hasPendingAcknowledgements());
+
+        // Acknowledging the records and taking them to be sent by the 
background thread, leaves the acknowledgements outstanding.
+        batch.acknowledgeAll(AcknowledgeType.RENEW);
+        Acknowledgements renewAcknowledgements = 
batch.takeAcknowledgedRecords();
+        assertTrue(completedFetch.hasPendingAcknowledgements());
+
+        renewAcknowledgements.complete(null);
+        batch.renew(renewAcknowledgements);
+        assertTrue(completedFetch.isConsumed());
+        assertTrue(completedFetch.hasPendingAcknowledgements());
+
+        batch.takeRenewals();
+        assertTrue(completedFetch.isConsumed());
+        assertTrue(completedFetch.hasPendingAcknowledgements());
+
+        batch.acknowledgeAll(AcknowledgeType.RENEW);
+        renewAcknowledgements = batch.takeAcknowledgedRecords();
+        assertTrue(completedFetch.hasPendingAcknowledgements());
+
+        renewAcknowledgements.complete(new NetworkException());
+        batch.renew(renewAcknowledgements);
+        assertTrue(completedFetch.isConsumed());
+        assertFalse(completedFetch.hasPendingAcknowledgements());
+    }
+
     @Test
     public void testSoftMaxPollRecordLimit() {
         long startingOffset = 10L;
diff --git 
a/clients/src/test/java/org/apache/kafka/clients/consumer/internals/ShareConsumeRequestManagerTest.java
 
b/clients/src/test/java/org/apache/kafka/clients/consumer/internals/ShareConsumeRequestManagerTest.java
index ce756395a37..76e71f86837 100644
--- 
a/clients/src/test/java/org/apache/kafka/clients/consumer/internals/ShareConsumeRequestManagerTest.java
+++ 
b/clients/src/test/java/org/apache/kafka/clients/consumer/internals/ShareConsumeRequestManagerTest.java
@@ -65,9 +65,11 @@ import org.apache.kafka.common.record.internal.SimpleRecord;
 import org.apache.kafka.common.requests.MetadataResponse;
 import org.apache.kafka.common.requests.RequestHeader;
 import org.apache.kafka.common.requests.RequestTestUtils;
+import org.apache.kafka.common.requests.ShareAcknowledgeRequest;
 import org.apache.kafka.common.requests.ShareAcknowledgeResponse;
 import org.apache.kafka.common.requests.ShareFetchRequest;
 import org.apache.kafka.common.requests.ShareFetchResponse;
+import org.apache.kafka.common.requests.ShareRequestMetadata;
 import org.apache.kafka.common.serialization.ByteArrayDeserializer;
 import org.apache.kafka.common.serialization.Deserializer;
 import org.apache.kafka.common.serialization.StringDeserializer;
@@ -86,6 +88,7 @@ import org.junit.jupiter.params.ParameterizedTest;
 import org.junit.jupiter.params.provider.Arguments;
 import org.junit.jupiter.params.provider.EnumSource;
 import org.junit.jupiter.params.provider.MethodSource;
+import org.junit.jupiter.params.provider.ValueSource;
 
 import java.nio.ByteBuffer;
 import java.nio.charset.StandardCharsets;
@@ -965,6 +968,100 @@ public class ShareConsumeRequestManagerTest {
         assertTrue(shareConsumeRequestManager.hasCompletedFetches());
     }
 
+    @ParameterizedTest
+    @ValueSource(booleans = {true, false})
+    public void testCommitWithRenewAcknowledgements(boolean commitSync) {
+        buildRequestManager();
+        
shareConsumeRequestManager.setAcknowledgementCommitCallbackRegistered(true);
+
+        assignFromSubscribed(Set.of(tp0));
+        sendFetchAndVerifyResponse(records, acquiredRecords, Errors.NONE);
+        ShareFetch<byte[], byte[]> fetch = collectFetch();
+        assertEquals(3, fetch.numRecords());
+
+        int nodeId0 = metadata.fetch().leaderFor(tp0).id();
+
+        // The application renews the delivered records. They are still 
logically held by the consumer.
+        fetch.acknowledgeAll(AcknowledgeType.RENEW);
+        Map<TopicIdPartition, NodeAcknowledgements> renewAcknowledgements = 
fetch.takeAcknowledgedRecords();
+        Acknowledgements acknowledgements = 
renewAcknowledgements.get(tip0).acknowledgements();
+
+        // Renew acknowledgements are committed through a ShareAcknowledge 
request (not piggybacked on a ShareFetch).
+        CompletableFuture<Map<TopicIdPartition, Acknowledgements>> future = 
null;
+        if (commitSync) {
+            future = 
shareConsumeRequestManager.commitSync(renewAcknowledgements, 
calculateDeadlineMs(time.timer(2000)));
+        } else {
+            shareConsumeRequestManager.commitAsync(renewAcknowledgements, 
calculateDeadlineMs(time.timer(defaultApiTimeoutMs)));
+        }
+
+        // The partition is no longer assigned, so the share session would 
normally be tidied up and eventually closed.
+        // However, the delivery of the records with renew acknowledgements 
must be completed before that can happen.
+        subscriptions.assignFromSubscribed(Set.of());
+
+        NetworkClientDelegate.PollResult pollResult = 
shareConsumeRequestManager.poll(time.milliseconds());
+        assertEquals(1, pollResult.unsentRequests.size());
+        ShareAcknowledgeRequest.Builder builder = 
(ShareAcknowledgeRequest.Builder) 
pollResult.unsentRequests.get(0).requestBuilder();
+        assertTrue(builder.data().isRenewAck());
+        networkClientDelegate.addAll(pollResult.unsentRequests);
+        assertEquals(0, renewedRecords.size());
+
+        // While the renew acknowledgements are in flight, the share session 
is not closed.
+        assertEquals(0, sendFetches());
+        assertNotNull(shareConsumeRequestManager.sessionHandler(nodeId0));
+
+        // The broker responds to the renew acknowledgements and the records 
are reported to the application.
+        client.prepareResponse(fullAcknowledgeResponse(tip0, Errors.NONE));
+        networkClientDelegate.poll(time.timer(0));
+        assertEquals(Map.of(tip0, acknowledgements), 
completedAcknowledgements.get(0));
+        assertEquals(Set.of(1L, 2L, 3L), renewedRecords);
+        if (commitSync) {
+            assertTrue(future.isDone());
+        }
+
+        // The revoked partition is removed from the share session, but the 
session cannot be closed while the records are still held.
+        NetworkClientDelegate.PollResult removeResult = 
shareConsumeRequestManager.sendFetchesReturnPollResult();
+        assertEquals(1, removeResult.unsentRequests.size());
+        ShareFetchRequest.Builder removeBuilder = (ShareFetchRequest.Builder) 
removeResult.unsentRequests.get(0).requestBuilder();
+        assertNotEquals(ShareRequestMetadata.FINAL_EPOCH, 
removeBuilder.data().shareSessionEpoch());
+        assertEquals(1, removeBuilder.data().forgottenTopicsData().size());
+        assertEquals(tip0.topicId(), 
removeBuilder.data().forgottenTopicsData().get(0).topicId());
+        client.prepareResponse(ShareFetchResponse.of(Errors.NONE, 0, new 
LinkedHashMap<>(), List.of(), 0));
+        networkClientDelegate.poll(time.timer(0));
+
+        assertEquals(0, 
shareConsumeRequestManager.sendFetchesReturnPollResult().unsentRequests.size());
+        assertNotNull(shareConsumeRequestManager.sessionHandler(nodeId0));
+
+        fetch.renew(Map.of(tip0, acknowledgements), Optional.empty());
+        fetch.takeRenewedRecords();
+        assertEquals(3, fetch.numRecords());
+        assertEquals(0, 
shareConsumeRequestManager.sendFetchesReturnPollResult().unsentRequests.size());
+        assertNotNull(shareConsumeRequestManager.sessionHandler(nodeId0));
+
+        // The application finally accepts the records and the 
acknowledgements are sent.
+        fetch.acknowledgeAll(AcknowledgeType.ACCEPT);
+        shareConsumeRequestManager.fetch(fetch.takeAcknowledgedRecords());
+        NetworkClientDelegate.PollResult ackResult = 
shareConsumeRequestManager.sendFetchesReturnPollResult();
+        assertEquals(1, ackResult.unsentRequests.size());
+
+        ShareFetchRequest.Builder ackBuilder = (ShareFetchRequest.Builder) 
ackResult.unsentRequests.get(0).requestBuilder();
+        assertNotEquals(ShareRequestMetadata.FINAL_EPOCH, 
ackBuilder.data().shareSessionEpoch());
+        client.prepareResponse(fullFetchResponse(tip0, MemoryRecords.EMPTY, 
emptyAcquiredRecords, Errors.NONE));
+        networkClientDelegate.poll(time.timer(0));
+
+        // The application consumes the fetch response corresponding to the 
piggybacked acknowledgements.
+        fetchRecords();
+
+        // With the record finally acknowledged, the empty share session is 
closed.
+        NetworkClientDelegate.PollResult closeResult = 
shareConsumeRequestManager.sendFetchesReturnPollResult();
+        assertEquals(1, closeResult.unsentRequests.size());
+        ShareFetchRequest.Builder closeBuilder = (ShareFetchRequest.Builder) 
closeResult.unsentRequests.get(0).requestBuilder();
+        assertEquals(ShareRequestMetadata.FINAL_EPOCH, 
closeBuilder.data().shareSessionEpoch());
+        client.prepareResponse(ShareFetchResponse.of(Errors.NONE, 0, new 
LinkedHashMap<>(), List.of(), 0));
+        networkClientDelegate.poll(time.timer(0));
+
+        assertNull(shareConsumeRequestManager.sessionHandler(nodeId0));
+    }
+
     @Test
     public void testCloseWithSubscriptionChange() {
         buildRequestManager();
@@ -1179,6 +1276,123 @@ public class ShareConsumeRequestManagerTest {
         assertEquals(0, shareConsumeRequestManager.sendFetches());
     }
 
+    @Test
+    public void testCloseSessionWhenNoSharePartitions() {
+        buildRequestManager();
+
+        assignFromSubscribed(Set.of(tp0));
+
+        // Establish the share session by fetching from tp0 and consume the 
(empty) buffered fetch.
+        sendFetchAndVerifyResponse(records, emptyAcquiredRecords, Errors.NONE);
+        fetchRecords();
+
+        Node node0 = metadata.fetch().leaderFor(tp0);
+        int nodeId0 = node0.id();
+        assertNotNull(shareConsumeRequestManager.sessionHandler(nodeId0));
+
+        // The partition is no longer assigned, so a ShareFetch is built to 
remove tip0 from the share session,
+        // leaving it empty.
+        subscriptions.assignFromSubscribed(Set.of());
+        assertEquals(1, sendFetches());
+        client.prepareResponse(ShareFetchResponse.of(Errors.NONE, 0, new 
LinkedHashMap<>(), List.of(), 0));
+        networkClientDelegate.poll(time.timer(0));
+
+        // The next poll closes the now-empty share session using a 
final-epoch ShareFetch.
+        NetworkClientDelegate.PollResult pollResult = 
shareConsumeRequestManager.sendFetchesReturnPollResult();
+        assertEquals(1, pollResult.unsentRequests.size());
+        assertEquals(node0, pollResult.unsentRequests.get(0).node().get());
+        ShareFetchRequest.Builder builder = (ShareFetchRequest.Builder) 
pollResult.unsentRequests.get(0).requestBuilder();
+        assertEquals(ShareRequestMetadata.FINAL_EPOCH, 
builder.data().shareSessionEpoch());
+        assertTrue(builder.data().topics().isEmpty());
+        assertTrue(builder.data().forgottenTopicsData().isEmpty());
+
+        // The session handle is only removed once the close response is 
received.
+        assertNotNull(shareConsumeRequestManager.sessionHandler(nodeId0));
+        client.prepareResponse(ShareFetchResponse.of(Errors.NONE, 0, new 
LinkedHashMap<>(), List.of(), 0));
+        networkClientDelegate.poll(time.timer(0));
+        assertNull(shareConsumeRequestManager.sessionHandler(nodeId0));
+
+        // With the session closed, there is nothing left to send.
+        assertEquals(0, shareConsumeRequestManager.sendFetches());
+    }
+
+    @Test
+    public void testDoesNotCloseSessionWhileRecordsBuffered() {
+        buildRequestManager();
+
+        assignFromSubscribed(Set.of(tp0));
+
+        // Establish the share session, leaving the fetched records unconsumed 
in the buffer.
+        sendFetchAndVerifyResponse(records, acquiredRecords, Errors.NONE);
+        Node node0 = metadata.fetch().leaderFor(tp0);
+        int nodeId0 = node0.id();
+
+        // Remove the partition from the session so the session becomes empty.
+        subscriptions.assignFromSubscribed(Set.of());
+        assertEquals(1, sendFetches());
+        client.prepareResponse(ShareFetchResponse.of(Errors.NONE, 0, new 
LinkedHashMap<>(), List.of(), 0));
+        networkClientDelegate.poll(time.timer(0));
+
+        // The session is empty, but records for the node remain in the 
buffer, so the session is not closed.
+        assertEquals(0, 
shareConsumeRequestManager.sendFetchesReturnPollResult().unsentRequests.size());
+        assertNotNull(shareConsumeRequestManager.sessionHandler(nodeId0));
+
+        // Consuming the records hands them to the application, but leaves the 
acknowledgements outstanding and the session
+        // is not closed.
+        ShareFetch<byte[], byte[]> fetch = collectFetch();
+        assertEquals(0, 
shareConsumeRequestManager.sendFetchesReturnPollResult().unsentRequests.size());
+        assertNotNull(shareConsumeRequestManager.sessionHandler(nodeId0));
+
+        // Acknowledge the records and send the acknowledgements. This is not 
a close request.
+        fetch.acknowledgeAll(AcknowledgeType.ACCEPT);
+        shareConsumeRequestManager.fetch(fetch.takeAcknowledgedRecords());
+        NetworkClientDelegate.PollResult ackResult = 
shareConsumeRequestManager.sendFetchesReturnPollResult();
+        assertEquals(1, ackResult.unsentRequests.size());
+        ShareFetchRequest.Builder ackBuilder = (ShareFetchRequest.Builder) 
ackResult.unsentRequests.get(0).requestBuilder();
+        assertNotEquals(ShareRequestMetadata.FINAL_EPOCH, 
ackBuilder.data().shareSessionEpoch());
+        client.prepareResponse(ShareFetchResponse.of(Errors.NONE, 0, new 
LinkedHashMap<>(), List.of(), 0));
+        networkClientDelegate.poll(time.timer(0));
+
+        // Once the acknowledgements have been sent, the empty session is 
closed.
+        NetworkClientDelegate.PollResult closeResult = 
shareConsumeRequestManager.sendFetchesReturnPollResult();
+        assertEquals(1, closeResult.unsentRequests.size());
+        assertEquals(node0, closeResult.unsentRequests.get(0).node().get());
+        ShareFetchRequest.Builder builder = (ShareFetchRequest.Builder) 
closeResult.unsentRequests.get(0).requestBuilder();
+        assertEquals(ShareRequestMetadata.FINAL_EPOCH, 
builder.data().shareSessionEpoch());
+    }
+
+    @Test
+    public void testDoesNotCloseSessionWhileAcknowledgementsPending() {
+        buildRequestManager();
+
+        assignFromSubscribed(Set.of(tp0));
+
+        // Establish the share session by fetching from tp0 and consume the 
(empty) buffered fetch.
+        sendFetchAndVerifyResponse(records, emptyAcquiredRecords, Errors.NONE);
+        fetchRecords();
+
+        Node node0 = metadata.fetch().leaderFor(tp0);
+        int nodeId0 = node0.id();
+        assertNotNull(shareConsumeRequestManager.sessionHandler(nodeId0));
+
+        // Remove the partition from the session so the session becomes empty.
+        subscriptions.assignFromSubscribed(Set.of());
+        assertEquals(1, sendFetches());
+        client.prepareResponse(ShareFetchResponse.of(Errors.NONE, 0, new 
LinkedHashMap<>(), List.of(), 0));
+        networkClientDelegate.poll(time.timer(0));
+
+        // There are acknowledgements still to be delivered for the node, so 
the session is not closed.
+        Acknowledgements acknowledgements = getAcknowledgements(0, 
AcknowledgeType.ACCEPT, AcknowledgeType.ACCEPT, AcknowledgeType.ACCEPT);
+        shareConsumeRequestManager.fetch(Map.of(tip0, new 
NodeAcknowledgements(nodeId0, acknowledgements)));
+
+        NetworkClientDelegate.PollResult pollResult = 
shareConsumeRequestManager.poll(time.milliseconds());
+        assertEquals(1, pollResult.unsentRequests.size());
+        assertEquals(node0, pollResult.unsentRequests.get(0).node().get());
+        ShareFetchRequest.Builder builder = (ShareFetchRequest.Builder) 
pollResult.unsentRequests.get(0).requestBuilder();
+        assertNotEquals(ShareRequestMetadata.FINAL_EPOCH, 
builder.data().shareSessionEpoch());
+        assertNotNull(shareConsumeRequestManager.sessionHandler(nodeId0));
+    }
+
     @Test
     public void testShareFetchAndCloseMultipleNodes() {
         buildRequestManager();
@@ -1929,7 +2143,7 @@ public class ShareConsumeRequestManagerTest {
         LinkedHashMap<TopicIdPartition, ShareFetchResponseData.PartitionData> 
partitionData =
                 buildPartitionDataMap(tip0, records, 
ShareCompletedFetchTest.acquiredRecords(1L, 1), Errors.NONE, Errors.NONE);
         client.prepareResponseFrom(ShareFetchResponse.of(Errors.NONE, 0, 
partitionData, List.of(), 0), nodeId0);
-        partitionData.clear();
+        partitionData = new LinkedHashMap<>();
         partitionData.put(tip1,
             new ShareFetchResponseData.PartitionData()
                 .setPartitionIndex(tip1.topicPartition().partition())
@@ -1996,7 +2210,7 @@ public class ShareConsumeRequestManagerTest {
      */
     @ParameterizedTest
     @EnumSource(value = Errors.class, names = {"FENCED_LEADER_EPOCH", 
"NOT_LEADER_OR_FOLLOWER"})
-    public void 
testWhenFetchResponseReturnsWithALeadershipChangeErrorAndNewLeaderInformation(Errors
 error) {
+    public void 
testWhenShareFetchResponseReturnsWithALeadershipChangeErrorAndNewLeaderInformation(Errors
 error) {
         buildRequestManager();
 
         subscriptions.subscribeToShareGroup(Set.of(topicName));
@@ -2062,12 +2276,12 @@ public class ShareConsumeRequestManagerTest {
                 
.setAcquiredRecords(ShareCompletedFetchTest.acquiredRecords(1L, 1))
                 .setAcknowledgeErrorCode(Errors.NONE.code()));
         client.prepareResponseFrom(ShareFetchResponse.of(Errors.NONE, 0, 
partitionData, List.of(), 0), nodeId0);
-        networkClientDelegate.poll(time.timer(0));
         partitionData = new LinkedHashMap<>();
         partitionData.put(tip1,
             new ShareFetchResponseData.PartitionData()
                 .setPartitionIndex(tip1.topicPartition().partition()));
         client.prepareResponseFrom(ShareFetchResponse.of(Errors.NONE, 0, 
partitionData, List.of(), 0), nodeId1);
+        networkClientDelegate.poll(time.timer(0));
         assertTrue(shareConsumeRequestManager.hasCompletedFetches());
 
         partitionRecords = fetchRecords();
@@ -2226,26 +2440,33 @@ public class ShareConsumeRequestManagerTest {
         commitAcks.put(tip0, new NodeAcknowledgements(0, acknowledgementsTp0));
         commitAcks.put(tip1, new NodeAcknowledgements(1, acknowledgementsTp1));
 
-        // Move the leadership of tp0 onto node 1
+        // Move the leadership of tp0 onto node 1.
         metadata.updatePartitionLeadership(Map.of(tp0, new 
Metadata.LeaderIdAndEpoch(Optional.of(nodeId1.id()), 
Optional.of(validLeaderEpoch + 1))), List.of());
 
         assertNotEquals(startingClusterMetadata, metadata.fetch());
 
-        // We fail the acknowledgements for records which were received from 
node0 with NOT_LEADER_OR_FOLLOWER exception.
+        // The acknowledgements are sent to the nodes the records were fetched 
from.
         shareConsumeRequestManager.commitAsync(commitAcks, 
calculateDeadlineMs(time.timer(defaultApiTimeoutMs)));
-        assertEquals(1, completedAcknowledgements.get(0).size());
-        assertEquals(acknowledgementsTp0, 
completedAcknowledgements.get(0).get(tip0));
-        assertEquals(Errors.NOT_LEADER_OR_FOLLOWER.exception(), 
completedAcknowledgements.get(0).get(tip0).getAcknowledgeException());
-
-        // We only send acknowledgements for tip1 to node1.
-        assertEquals(1, shareConsumeRequestManager.sendAcknowledgements());
+        assertTrue(completedAcknowledgements.isEmpty());
+        assertEquals(2, shareConsumeRequestManager.sendAcknowledgements());
 
-        client.prepareResponse(fullAcknowledgeResponse(tip1, Errors.NONE));
+        // The former leader for tp0 rejects the acknowledgements with 
NOT_LEADER_OR_FOLLOWER and points to the new leader.
+        client.prepareResponseFrom(fullAcknowledgeResponse(tip0,
+            Errors.NOT_LEADER_OR_FOLLOWER,
+            new 
ShareAcknowledgeResponseData.LeaderIdAndEpoch().setLeaderId(nodeId1.id()).setLeaderEpoch(validLeaderEpoch
 + 1),
+            List.of(nodeId0, nodeId1)), nodeId0);
+        client.prepareResponseFrom(fullAcknowledgeResponse(tip1, Errors.NONE), 
nodeId1);
         networkClientDelegate.poll(time.timer(0));
 
-        assertEquals(1, completedAcknowledgements.get(1).size());
-        assertEquals(acknowledgementsTp1, 
completedAcknowledgements.get(1).get(tip1));
-        
assertNull(completedAcknowledgements.get(1).get(tip1).getAcknowledgeException());
+        Map<TopicIdPartition, Acknowledgements> completed = new HashMap<>();
+        completedAcknowledgements.forEach(completed::putAll);
+        assertEquals(2, completed.size());
+        assertEquals(acknowledgementsTp0, completed.get(tip0));
+        assertInstanceOf(NotLeaderOrFollowerException.class, 
completed.get(tip0).getAcknowledgeException());
+        assertEquals(acknowledgementsTp1, completed.get(tip1));
+        assertNull(completed.get(tip1).getAcknowledgeException());
+
+        assertEquals(nodeId1.id(), 
shareConsumeRequestManager.shareSessionNodeId(tip0));
     }
 
     @Test
@@ -2296,26 +2517,32 @@ public class ShareConsumeRequestManagerTest {
         commitAcks.put(tip0, new NodeAcknowledgements(0, acknowledgementsTp0));
         commitAcks.put(tip1, new NodeAcknowledgements(1, acknowledgementsTp1));
 
-        // Move the leadership of tp0 onto node 1
+        // Move the leadership of tp0 onto node 1.
         metadata.updatePartitionLeadership(Map.of(tp0, new 
Metadata.LeaderIdAndEpoch(Optional.of(nodeId1.id()), 
Optional.of(validLeaderEpoch + 1))), List.of());
 
         assertNotEquals(startingClusterMetadata, metadata.fetch());
 
-        // We fail the acknowledgements for records which were received from 
node0 with NOT_LEADER_OR_FOLLOWER exception.
+        // The acknowledgements are sent to the nodes the records were fetched 
from.
         shareConsumeRequestManager.commitSync(commitAcks, 
calculateDeadlineMs(time.timer(100)));
+        assertEquals(2, shareConsumeRequestManager.sendAcknowledgements());
 
-        // We only send acknowledgements for tip1 to node1.
-        assertEquals(1, shareConsumeRequestManager.sendAcknowledgements());
-
-        client.prepareResponse(fullAcknowledgeResponse(tip1, Errors.NONE));
+        // The former leader for tp0 rejects the acknowledgements with 
NOT_LEADER_OR_FOLLOWER and points to the new leader.
+        client.prepareResponseFrom(fullAcknowledgeResponse(tip0,
+            Errors.NOT_LEADER_OR_FOLLOWER,
+            new 
ShareAcknowledgeResponseData.LeaderIdAndEpoch().setLeaderId(nodeId1.id()).setLeaderEpoch(validLeaderEpoch
 + 1),
+            List.of(nodeId0, nodeId1)), nodeId0);
+        client.prepareResponseFrom(fullAcknowledgeResponse(tip1, Errors.NONE), 
nodeId1);
         networkClientDelegate.poll(time.timer(0));
 
-        // Verify if the callback was invoked with the failed 
acknowledgements. The callback is called with the commitSync processing.
-        assertEquals(2, completedAcknowledgements.get(0).size());
-        assertEquals(acknowledgementsTp0, 
completedAcknowledgements.get(0).get(tip0));
-        assertEquals(Errors.NOT_LEADER_OR_FOLLOWER.exception(), 
completedAcknowledgements.get(0).get(tip0).getAcknowledgeException());
-        assertEquals(acknowledgementsTp1, 
completedAcknowledgements.get(0).get(tip1));
-        
assertNull(completedAcknowledgements.get(0).get(tip1).getAcknowledgeException());
+        Map<TopicIdPartition, Acknowledgements> completed = new HashMap<>();
+        completedAcknowledgements.forEach(completed::putAll);
+        assertEquals(2, completed.size());
+        assertEquals(acknowledgementsTp0, completed.get(tip0));
+        assertInstanceOf(NotLeaderOrFollowerException.class, 
completed.get(tip0).getAcknowledgeException());
+        assertEquals(acknowledgementsTp1, completed.get(tip1));
+        assertNull(completed.get(tip1).getAcknowledgeException());
+
+        assertEquals(nodeId1.id(), 
shareConsumeRequestManager.shareSessionNodeId(tip0));
     }
 
     @Test
@@ -2367,33 +2594,32 @@ public class ShareConsumeRequestManagerTest {
 
         shareConsumeRequestManager.fetch(Map.of(tip1, new 
NodeAcknowledgements(1, acknowledgementsTp1)));
 
-        // Move the leadership of tp0 onto node 1
+        // Move the leadership of tp0 onto node 1.
         metadata.updatePartitionLeadership(Map.of(tp0, new 
Metadata.LeaderIdAndEpoch(Optional.of(nodeId1.id()), 
Optional.of(validLeaderEpoch + 1))), List.of());
 
         assertNotEquals(startingClusterMetadata, metadata.fetch());
 
-        // We fail the acknowledgements for records which were received from 
node0 with NOT_LEADER_OR_FOLLOWER exception.
+        // The acknowledgements are sent to the nodes the records were fetched 
from.
         shareConsumeRequestManager.acknowledgeOnClose(Map.of(tip0, new 
NodeAcknowledgements(0, acknowledgementsTp0)),
                 calculateDeadlineMs(time.timer(100)));
-
-        // Verify if the callback was invoked with the failed acknowledgements.
-        assertEquals(1, completedAcknowledgements.get(0).size());
-        assertEquals(acknowledgementsTp0.getAcknowledgementsTypeMap(), 
completedAcknowledgements.get(0).get(tip0).getAcknowledgementsTypeMap());
-        assertEquals(Errors.NOT_LEADER_OR_FOLLOWER.exception(), 
completedAcknowledgements.get(0).get(tip0).getAcknowledgeException());
-        completedAcknowledgements.clear();
-
-        // As we are closing, we still send the request to both the nodes, but 
with empty acknowledgements to node0, as it is no longer the leader.
+        assertTrue(completedAcknowledgements.isEmpty());
         assertEquals(2, shareConsumeRequestManager.sendAcknowledgements());
 
+        // The former leader for tp0 rejects the acknowledgements with 
NOT_LEADER_OR_FOLLOWER and points to the new leader.
+        client.prepareResponseFrom(fullAcknowledgeResponse(tip0,
+            Errors.NOT_LEADER_OR_FOLLOWER,
+            new 
ShareAcknowledgeResponseData.LeaderIdAndEpoch().setLeaderId(nodeId1.id()).setLeaderEpoch(validLeaderEpoch
 + 1),
+            List.of(nodeId0, nodeId1)), nodeId0);
         client.prepareResponseFrom(fullAcknowledgeResponse(tip1, Errors.NONE), 
nodeId1);
         networkClientDelegate.poll(time.timer(0));
 
-        client.prepareResponseFrom(emptyAcknowledgeResponse(), nodeId0);
-        networkClientDelegate.poll(time.timer(0));
-
-        assertEquals(1, completedAcknowledgements.get(0).size());
-        assertEquals(acknowledgementsTp1, 
completedAcknowledgements.get(0).get(tip1));
-        
assertNull(completedAcknowledgements.get(0).get(tip1).getAcknowledgeException());
+        Map<TopicIdPartition, Acknowledgements> completed = new HashMap<>();
+        completedAcknowledgements.forEach(completed::putAll);
+        assertEquals(2, completed.size());
+        assertEquals(acknowledgementsTp0, completed.get(tip0));
+        assertInstanceOf(NotLeaderOrFollowerException.class, 
completed.get(tip0).getAcknowledgeException());
+        assertEquals(acknowledgementsTp1, completed.get(tip1));
+        assertNull(completed.get(tip1).getAcknowledgeException());
     }
 
     @Test
@@ -2445,33 +2671,32 @@ public class ShareConsumeRequestManagerTest {
 
         shareConsumeRequestManager.fetch(Map.of(tip1, new 
NodeAcknowledgements(1, acknowledgementsTp1)));
 
-        // Move the leadership of tp1 onto node 0
+        // Move the leadership of tp1 onto node 0.
         metadata.updatePartitionLeadership(Map.of(tp1, new 
Metadata.LeaderIdAndEpoch(Optional.of(nodeId0.id()), 
Optional.of(validLeaderEpoch + 1))), List.of());
 
         assertNotEquals(startingClusterMetadata, metadata.fetch());
 
-        // We fail the acknowledgements for records which were received from 
node0 with NOT_LEADER_OR_FOLLOWER exception.
+        // The acknowledgements are sent to the nodes the records were fetched 
from.
         shareConsumeRequestManager.acknowledgeOnClose(Map.of(tip0, new 
NodeAcknowledgements(0, acknowledgementsTp0)),
             calculateDeadlineMs(time.timer(100)));
-
-        // Verify if the callback was invoked with the failed acknowledgements.
-        assertEquals(1, completedAcknowledgements.get(0).size());
-        assertEquals(acknowledgementsTp1.getAcknowledgementsTypeMap(), 
completedAcknowledgements.get(0).get(tip1).getAcknowledgementsTypeMap());
-        assertEquals(Errors.NOT_LEADER_OR_FOLLOWER.exception(), 
completedAcknowledgements.get(0).get(tip1).getAcknowledgeException());
-        completedAcknowledgements.clear();
-
-        // As we are closing, we still send the request to both the nodes, but 
with empty acknowledgements to node1, as it is no longer the leader.
+        assertTrue(completedAcknowledgements.isEmpty());
         assertEquals(2, shareConsumeRequestManager.sendAcknowledgements());
 
+        // The former leader for tp0 rejects the acknowledgements with 
NOT_LEADER_OR_FOLLOWER and points to the new leader.
         client.prepareResponseFrom(fullAcknowledgeResponse(tip0, Errors.NONE), 
nodeId0);
+        client.prepareResponseFrom(fullAcknowledgeResponse(tip1,
+            Errors.NOT_LEADER_OR_FOLLOWER,
+            new 
ShareAcknowledgeResponseData.LeaderIdAndEpoch().setLeaderId(nodeId0.id()).setLeaderEpoch(validLeaderEpoch
 + 1),
+            List.of(nodeId0, nodeId1)), nodeId1);
         networkClientDelegate.poll(time.timer(0));
 
-        client.prepareResponseFrom(emptyAcknowledgeResponse(), nodeId1);
-        networkClientDelegate.poll(time.timer(0));
-
-        assertEquals(1, completedAcknowledgements.get(0).size());
-        assertEquals(acknowledgementsTp0, 
completedAcknowledgements.get(0).get(tip0));
-        
assertNull(completedAcknowledgements.get(0).get(tip0).getAcknowledgeException());
+        Map<TopicIdPartition, Acknowledgements> completed = new HashMap<>();
+        completedAcknowledgements.forEach(completed::putAll);
+        assertEquals(2, completed.size());
+        assertEquals(acknowledgementsTp0, completed.get(tip0));
+        assertNull(completed.get(tip0).getAcknowledgeException());
+        assertEquals(acknowledgementsTp1.getAcknowledgementsTypeMap(), 
completed.get(tip1).getAcknowledgementsTypeMap());
+        assertInstanceOf(NotLeaderOrFollowerException.class, 
completed.get(tip1).getAcknowledgeException());
     }
 
     @Test
@@ -2523,35 +2748,109 @@ public class ShareConsumeRequestManagerTest {
 
         shareConsumeRequestManager.fetch(Map.of(tip1, new 
NodeAcknowledgements(1, acknowledgementsTp1)));
 
-        // Move the leadership of tp1 onto node 0, and tp0 onto node 1
+        // Move the leadership of tp1 onto node 0, and tp0 onto node 1.
         metadata.updatePartitionLeadership(Map.of(tp1, new 
Metadata.LeaderIdAndEpoch(Optional.of(nodeId0.id()), 
Optional.of(validLeaderEpoch + 1))), List.of());
         metadata.updatePartitionLeadership(Map.of(tp0, new 
Metadata.LeaderIdAndEpoch(Optional.of(nodeId1.id()), 
Optional.of(validLeaderEpoch + 1))), List.of());
 
         assertNotEquals(startingClusterMetadata, metadata.fetch());
 
-        // We fail the acknowledgements for records which were received from 
node0 and node 1 with NOT_LEADER_OR_FOLLOWER exception.
+        // The acknowledgements are sent to the nodes the records were fetched 
from.
         shareConsumeRequestManager.acknowledgeOnClose(Map.of(tip0, new 
NodeAcknowledgements(0, acknowledgementsTp0)),
             calculateDeadlineMs(time.timer(100)));
-
-        // Verify if the callback was invoked with the failed acknowledgements.
-        assertEquals(1, completedAcknowledgements.get(0).size());
-        assertEquals(acknowledgementsTp0.getAcknowledgementsTypeMap(), 
completedAcknowledgements.get(0).get(tip0).getAcknowledgementsTypeMap());
-        assertEquals(Errors.NOT_LEADER_OR_FOLLOWER.exception(), 
completedAcknowledgements.get(0).get(tip0).getAcknowledgeException());
-        assertEquals(1, completedAcknowledgements.get(1).size());
-        assertEquals(acknowledgementsTp1.getAcknowledgementsTypeMap(), 
completedAcknowledgements.get(1).get(tip1).getAcknowledgementsTypeMap());
-        assertEquals(Errors.NOT_LEADER_OR_FOLLOWER.exception(), 
completedAcknowledgements.get(1).get(tip1).getAcknowledgeException());
-        completedAcknowledgements.clear();
-
-        // As we are closing, we still send the request to both the nodes, but 
with empty acknowledgements to node 0 and node1, as they are no longer the 
leader.
         assertEquals(2, shareConsumeRequestManager.sendAcknowledgements());
 
-        client.prepareResponseFrom(emptyAcknowledgeResponse(), nodeId0);
+        // The former leaders reject the acknowledgements with 
NOT_LEADER_OR_FOLLOWER and point to the new leaders.
+        client.prepareResponseFrom(fullAcknowledgeResponse(tip0,
+            Errors.NOT_LEADER_OR_FOLLOWER,
+            new 
ShareAcknowledgeResponseData.LeaderIdAndEpoch().setLeaderId(nodeId1.id()).setLeaderEpoch(validLeaderEpoch
 + 1),
+            List.of(nodeId0, nodeId1)), nodeId0);
+        client.prepareResponseFrom(fullAcknowledgeResponse(tip1,
+            Errors.NOT_LEADER_OR_FOLLOWER,
+            new 
ShareAcknowledgeResponseData.LeaderIdAndEpoch().setLeaderId(nodeId0.id()).setLeaderEpoch(validLeaderEpoch
 + 1),
+            List.of(nodeId0, nodeId1)), nodeId1);
         networkClientDelegate.poll(time.timer(0));
 
-        client.prepareResponseFrom(emptyAcknowledgeResponse(), nodeId1);
+        Map<TopicIdPartition, Acknowledgements> completed = new HashMap<>();
+        completedAcknowledgements.forEach(completed::putAll);
+        assertEquals(2, completed.size());
+        assertEquals(acknowledgementsTp0.getAcknowledgementsTypeMap(), 
completed.get(tip0).getAcknowledgementsTypeMap());
+        assertInstanceOf(NotLeaderOrFollowerException.class, 
completed.get(tip0).getAcknowledgeException());
+        assertEquals(acknowledgementsTp1.getAcknowledgementsTypeMap(), 
completed.get(tip1).getAcknowledgementsTypeMap());
+        assertInstanceOf(NotLeaderOrFollowerException.class, 
completed.get(tip1).getAcknowledgeException());
+    }
+
+    @Test
+    void testStaleMetadataLeadershipChangeDoesNotFailAcknowledgements() {
+        buildRequestManager();
+        
shareConsumeRequestManager.setAcknowledgementCommitCallbackRegistered(true);
+
+        subscriptions.subscribeToShareGroup(Set.of(topicName));
+        Set<TopicPartition> partitions = new HashSet<>();
+        partitions.add(tp0);
+        partitions.add(tp1);
+        subscriptions.assignFromSubscribed(partitions);
+
+        client.updateMetadata(
+            RequestTestUtils.metadataUpdateWithIds(2, Map.of(topicName, 2),
+                tp -> validLeaderEpoch, topicIds, false));
+        Node nodeId0 = metadata.fetch().nodeById(0);
+        Node nodeId1 = metadata.fetch().nodeById(1);
+
+        Cluster startingClusterMetadata = metadata.fetch();
+        assertFalse(metadata.updateRequested());
+
+        assertEquals(2, sendFetches());
+        assertFalse(shareConsumeRequestManager.hasCompletedFetches());
+
+        LinkedHashMap<TopicIdPartition, ShareFetchResponseData.PartitionData> 
partitionData =
+            buildPartitionDataMap(tip0, records, 
ShareCompletedFetchTest.acquiredRecords(1L, 1), Errors.NONE, Errors.NONE);
+        client.prepareResponseFrom(ShareFetchResponse.of(Errors.NONE, 0, 
partitionData, List.of(), 0), nodeId0);
+        partitionData = buildPartitionDataMap(tip1, records, 
ShareCompletedFetchTest.acquiredRecords(1L, 2), Errors.NONE, Errors.NONE);
+        client.prepareResponseFrom(ShareFetchResponse.of(Errors.NONE, 0, 
partitionData, List.of(), 0), nodeId1);
         networkClientDelegate.poll(time.timer(0));
+        assertTrue(shareConsumeRequestManager.hasCompletedFetches());
 
+        Map<TopicPartition, List<ConsumerRecord<byte[], byte[]>>> 
partitionRecords = fetchRecords();
+        assertTrue(partitionRecords.containsKey(tp0));
+        assertTrue(partitionRecords.containsKey(tp1));
+
+        Acknowledgements acknowledgementsTp0 = Acknowledgements.empty();
+        acknowledgementsTp0.add(1L, AcknowledgeType.ACCEPT);
+
+        Acknowledgements acknowledgementsTp1 = getAcknowledgements(1,
+            AcknowledgeType.ACCEPT, AcknowledgeType.ACCEPT);
+
+        Map<TopicIdPartition, NodeAcknowledgements> commitAcks = new 
HashMap<>();
+        commitAcks.put(tip0, new NodeAcknowledgements(0, acknowledgementsTp0));
+        commitAcks.put(tip1, new NodeAcknowledgements(1, acknowledgementsTp1));
+
+        // The metadata reports tp0 has moved to node 1, but the cached leader 
used for fetching (node 0) is unchanged.
+        metadata.updatePartitionLeadership(Map.of(tp0, new 
Metadata.LeaderIdAndEpoch(Optional.of(nodeId1.id()), 
Optional.of(validLeaderEpoch + 1))), List.of());
+
+        assertNotEquals(startingClusterMetadata, metadata.fetch());
+
+        // The acknowledgements are sent to the nodes the records were fetched 
from, so nothing fails synchronously.
+        shareConsumeRequestManager.commitAsync(commitAcks, 
calculateDeadlineMs(time.timer(defaultApiTimeoutMs)));
         assertTrue(completedAcknowledgements.isEmpty());
+
+        // We send acknowledgements for tip0 to node0 and tip1 to node1.
+        assertEquals(2, shareConsumeRequestManager.sendAcknowledgements());
+
+        // Both cached leaders accept the acknowledgements despite the stale 
metadata for tp0.
+        client.prepareResponseFrom(fullAcknowledgeResponse(tip0, Errors.NONE), 
nodeId0);
+        client.prepareResponseFrom(fullAcknowledgeResponse(tip1, Errors.NONE), 
nodeId1);
+        networkClientDelegate.poll(time.timer(0));
+
+        Map<TopicIdPartition, Acknowledgements> completed = new HashMap<>();
+        completedAcknowledgements.forEach(completed::putAll);
+        assertEquals(2, completed.size());
+        assertEquals(acknowledgementsTp0, completed.get(tip0));
+        assertNull(completed.get(tip0).getAcknowledgeException());
+        assertEquals(acknowledgementsTp1, completed.get(tip1));
+        assertNull(completed.get(tip1).getAcknowledgeException());
+
+        // The cached leader for tp0 is unchanged - the broker success did not 
repoint it to the stale metadata leader.
+        assertEquals(nodeId0.id(), 
shareConsumeRequestManager.shareSessionNodeId(tip0));
     }
 
     @Test
@@ -2604,9 +2903,9 @@ public class ShareConsumeRequestManagerTest {
 
         assertEquals(startingClusterMetadata, metadata.fetch());
 
-        Acknowledgements acknowledgements = Acknowledgements.empty();
-        acknowledgements.add(1, AcknowledgeType.ACCEPT);
-        shareConsumeRequestManager.fetch(Map.of(tip0, new 
NodeAcknowledgements(0, acknowledgements)));
+        Acknowledgements acknowledgements1 = Acknowledgements.empty();
+        acknowledgements1.add(1, AcknowledgeType.ACCEPT);
+        shareConsumeRequestManager.fetch(Map.of(tip0, new 
NodeAcknowledgements(0, acknowledgements1)));
 
         assertEquals(2, sendFetches());
         assertFalse(shareConsumeRequestManager.hasCompletedFetches());
@@ -2645,7 +2944,9 @@ public class ShareConsumeRequestManagerTest {
 
         assertNotEquals(startingClusterMetadata, metadata.fetch());
 
-        shareConsumeRequestManager.fetch(Map.of(tip1, new 
NodeAcknowledgements(1, acknowledgements)));
+        Acknowledgements acknowledgements2 = Acknowledgements.empty();
+        acknowledgements2.add(1, AcknowledgeType.ACCEPT);
+        shareConsumeRequestManager.fetch(Map.of(tip1, new 
NodeAcknowledgements(1, acknowledgements2)));
 
         assertEquals(2, sendFetches());
         assertFalse(shareConsumeRequestManager.hasCompletedFetches());
diff --git 
a/clients/src/test/java/org/apache/kafka/clients/consumer/internals/ShareFetchBufferTest.java
 
b/clients/src/test/java/org/apache/kafka/clients/consumer/internals/ShareFetchBufferTest.java
index 0e76c37e11a..de757599cb0 100644
--- 
a/clients/src/test/java/org/apache/kafka/clients/consumer/internals/ShareFetchBufferTest.java
+++ 
b/clients/src/test/java/org/apache/kafka/clients/consumer/internals/ShareFetchBufferTest.java
@@ -16,12 +16,19 @@
  */
 package org.apache.kafka.clients.consumer.internals;
 
+import org.apache.kafka.clients.consumer.AcknowledgeType;
 import org.apache.kafka.clients.consumer.ConsumerConfig;
 import org.apache.kafka.common.TopicIdPartition;
 import org.apache.kafka.common.Uuid;
+import org.apache.kafka.common.compress.Compression;
 import org.apache.kafka.common.message.ShareFetchResponseData;
 import org.apache.kafka.common.metrics.Metrics;
 import org.apache.kafka.common.protocol.ApiKeys;
+import org.apache.kafka.common.record.TimestampType;
+import org.apache.kafka.common.record.internal.MemoryRecords;
+import org.apache.kafka.common.record.internal.MemoryRecordsBuilder;
+import org.apache.kafka.common.record.internal.Records;
+import org.apache.kafka.common.serialization.StringDeserializer;
 import org.apache.kafka.common.serialization.StringSerializer;
 import org.apache.kafka.common.utils.MockTime;
 import org.apache.kafka.common.utils.Time;
@@ -32,6 +39,7 @@ import org.apache.kafka.common.utils.internals.LogContext;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 
+import java.nio.ByteBuffer;
 import java.time.Duration;
 import java.util.List;
 import java.util.Optional;
@@ -65,6 +73,7 @@ public class ShareFetchBufferTest {
     private final TopicIdPartition topicAPartition1 = new 
TopicIdPartition(Uuid.randomUuid(), 1, "topic-a");
     private final TopicIdPartition topicAPartition2 = new 
TopicIdPartition(Uuid.randomUuid(), 2, "topic-a");
     private final Set<TopicIdPartition> allPartitions = 
partitions(topicAPartition0, topicAPartition1, topicAPartition2);
+    private final Deserializers<String, String> deserializers = new 
Deserializers<>(new StringDeserializer(), new StringDeserializer(), null);
     private LogContext logContext;
     private ShareFetchMetricsManager shareFetchMetricsManager;
 
@@ -136,18 +145,121 @@ public class ShareFetchBufferTest {
             fetchBuffer.setNextInLineFetch(completedFetch(topicAPartition0));
             fetchBuffer.add(List.of(completedFetch(topicAPartition1), 
completedFetch(topicAPartition2)));
             assertEquals(allPartitions, fetchBuffer.bufferedPartitions());
+            assertEquals(Set.of(0), fetchBuffer.bufferedNodes());
 
             fetchBuffer.setNextInLineFetch(null);
             assertEquals(partitions(topicAPartition1, topicAPartition2), 
fetchBuffer.bufferedPartitions());
+            assertEquals(Set.of(0), fetchBuffer.bufferedNodes());
 
             fetchBuffer.poll();
             assertEquals(partitions(topicAPartition2), 
fetchBuffer.bufferedPartitions());
+            assertEquals(Set.of(0), fetchBuffer.bufferedNodes());
 
             fetchBuffer.poll();
             assertEquals(partitions(), fetchBuffer.bufferedPartitions());
+            assertEquals(Set.of(), fetchBuffer.bufferedNodes());
         }
     }
 
+    /**
+     * Tests that a fetch which has been consumed but whose acknowledgements 
are still outstanding continues to be
+     * reported as buffered, thus preventing the share session for its node 
being closed prematurely.
+     */
+    @Test
+    public void testBufferedIncludesConsumedFetchWithPendingAcknowledgements() 
{
+        try (ShareFetchBuffer fetchBuffer = new ShareFetchBuffer(logContext)) {
+            ShareCompletedFetch completedFetch = 
completedFetchWithAcquiredRecords(topicAPartition0, 0, 5);
+            ShareInFlightBatch<String, String> batch = 
consumeFetchWithAcquiredRecords(completedFetch);
+            fetchBuffer.setNextInLineFetch(completedFetch);
+
+            // Even though the fetch has been consumed, its acknowledgements 
are still outstanding, so it is still buffered.
+            assertEquals(partitions(topicAPartition0), 
fetchBuffer.bufferedPartitions());
+            assertEquals(Set.of(0), fetchBuffer.bufferedNodes());
+            assertTrue(completedFetch.hasPendingAcknowledgements());
+
+            // Acknowledging and taking the records clears the outstanding 
acknowledgements, so it is no longer buffered.
+            batch.acknowledgeAll(AcknowledgeType.ACCEPT);
+            batch.takeAcknowledgedRecords();
+            assertFalse(completedFetch.hasPendingAcknowledgements());
+            assertEquals(partitions(), fetchBuffer.bufferedPartitions());
+            assertEquals(Set.of(), fetchBuffer.bufferedNodes());
+        }
+    }
+
+    @Test
+    public void testRetainsEvictedFetchWithPendingAcknowledgements() {
+        try (ShareFetchBuffer fetchBuffer = new ShareFetchBuffer(logContext)) {
+            ShareCompletedFetch fetchForNode0 = 
completedFetchWithAcquiredRecords(topicAPartition0, 0, 5);
+            ShareInFlightBatch<String, String> batch = 
consumeFetchWithAcquiredRecords(fetchForNode0);
+            fetchBuffer.setNextInLineFetch(fetchForNode0);
+
+            // Replacing the next-in-line fetch with one for a different node 
still retains the node 0 fetch because
+            // its acknowledgements are outstanding.
+            
fetchBuffer.setNextInLineFetch(completedFetchWithAcquiredRecords(topicAPartition1,
 1, 5));
+            assertEquals(partitions(topicAPartition0, topicAPartition1), 
fetchBuffer.bufferedPartitions());
+            assertEquals(Set.of(0, 1), fetchBuffer.bufferedNodes());
+
+            // Once node 0's acknowledgements are taken, the retained fetch is 
pruned.
+            batch.acknowledgeAll(AcknowledgeType.ACCEPT);
+            batch.takeAcknowledgedRecords();
+            assertEquals(partitions(topicAPartition1), 
fetchBuffer.bufferedPartitions());
+            assertEquals(Set.of(1), fetchBuffer.bufferedNodes());
+        }
+    }
+
+    @Test
+    public void testRetainsEvictedFetchWithPendingRenewAcknowledgements() {
+        try (ShareFetchBuffer fetchBuffer = new ShareFetchBuffer(logContext)) {
+            ShareCompletedFetch completedFetch = 
completedFetchWithAcquiredRecords(topicAPartition0, 0, 5);
+            ShareInFlightBatch<String, String> batch = 
consumeFetchWithAcquiredRecords(completedFetch);
+            fetchBuffer.setNextInLineFetch(completedFetch);
+
+            // Renewing the records moves them out of the in-flight set, but 
they are still held and the fetch remains buffered.
+            batch.acknowledgeAll(AcknowledgeType.RENEW);
+            Acknowledgements renewAcknowledgements = 
batch.takeAcknowledgedRecords();
+            assertTrue(completedFetch.hasPendingAcknowledgements());
+            assertEquals(partitions(topicAPartition0), 
fetchBuffer.bufferedPartitions());
+            assertEquals(Set.of(0), fetchBuffer.bufferedNodes());
+
+            // The renewal completed and the records move to the renewed 
state, and they are still held.
+            renewAcknowledgements.complete(null);
+            batch.renew(renewAcknowledgements);
+            assertTrue(completedFetch.hasPendingAcknowledgements());
+            assertEquals(Set.of(0), fetchBuffer.bufferedNodes());
+
+            // The renewed records return to the in-flight set, so still 
buffered.
+            batch.takeRenewals();
+            assertTrue(completedFetch.hasPendingAcknowledgements());
+            assertEquals(Set.of(0), fetchBuffer.bufferedNodes());
+
+            // Finally the records accepted and the fetch is no longer 
buffered.
+            batch.acknowledgeAll(AcknowledgeType.ACCEPT);
+            batch.takeAcknowledgedRecords();
+            assertFalse(completedFetch.hasPendingAcknowledgements());
+            assertEquals(partitions(), fetchBuffer.bufferedPartitions());
+            assertEquals(Set.of(), fetchBuffer.bufferedNodes());
+        }
+    }
+
+    @Test
+    public void testCloseClearsPendingAcknowledgementFetches() {
+        ShareFetchBuffer fetchBuffer = new ShareFetchBuffer(logContext);
+        try {
+            ShareCompletedFetch fetchForNode0 = 
completedFetchWithAcquiredRecords(topicAPartition0, 0, 5);
+            consumeFetchWithAcquiredRecords(fetchForNode0);
+            fetchBuffer.setNextInLineFetch(fetchForNode0);
+
+            // Replace the next-in-line fetch with one for a different node
+            
fetchBuffer.setNextInLineFetch(completedFetchWithAcquiredRecords(topicAPartition1,
 1, 5));
+            assertEquals(Set.of(0, 1), fetchBuffer.bufferedNodes());
+        } finally {
+            fetchBuffer.close();
+        }
+
+        assertEquals(partitions(), fetchBuffer.bufferedPartitions());
+        assertEquals(Set.of(), fetchBuffer.bufferedNodes());
+    }
+
     @Test
     public void testWakeup() throws Exception {
         try (ShareFetchBuffer fetchBuffer = new ShareFetchBuffer(logContext)) {
@@ -162,14 +274,31 @@ public class ShareFetchBufferTest {
         }
     }
 
+    private ShareInFlightBatch<String, String> 
consumeFetchWithAcquiredRecords(ShareCompletedFetch completedFetch) {
+        ShareInFlightBatch<String, String> batch = 
completedFetch.fetchRecords(deserializers, 100, false);
+        assertTrue(completedFetch.isConsumed());
+        assertTrue(completedFetch.hasPendingAcknowledgements());
+        return batch;
+    }
+
     private ShareCompletedFetch completedFetch(TopicIdPartition tp) {
+        return completedFetch(tp, 0, new 
ShareFetchResponseData.PartitionData());
+    }
+
+    private ShareCompletedFetch 
completedFetchWithAcquiredRecords(TopicIdPartition tp, int nodeId, int 
numRecords) {
+        ShareFetchResponseData.PartitionData partitionData = new 
ShareFetchResponseData.PartitionData()
+                .setRecords(newRecords(0, numRecords))
+                .setAcquiredRecords(acquiredRecords(0, numRecords));
+        return completedFetch(tp, nodeId, partitionData);
+    }
+
+    private ShareCompletedFetch completedFetch(TopicIdPartition tp, int 
nodeId, ShareFetchResponseData.PartitionData partitionData) {
         ShareFetchMetricsAggregator shareFetchMetricsAggregator = new 
ShareFetchMetricsAggregator(shareFetchMetricsManager,
                 
allPartitions.stream().map(TopicIdPartition::topicPartition).collect(Collectors.toSet()));
-        ShareFetchResponseData.PartitionData partitionData = new 
ShareFetchResponseData.PartitionData();
         return new ShareCompletedFetch(
                 logContext,
                 BufferSupplier.create(),
-                0,
+                nodeId,
                 tp,
                 partitionData,
                 DEFAULT_ACQUISITION_LOCK_TIMEOUT_MS,
@@ -177,6 +306,22 @@ public class ShareFetchBufferTest {
                 ApiKeys.SHARE_FETCH.latestVersion());
     }
 
+    private static Records newRecords(long baseOffset, int numRecords) {
+        try (MemoryRecordsBuilder builder = 
MemoryRecords.builder(ByteBuffer.allocate(1024), Compression.NONE, 
TimestampType.CREATE_TIME, baseOffset)) {
+            for (int i = 0; i < numRecords; i++) {
+                builder.append(0L, "key".getBytes(), "value".getBytes());
+            }
+            return builder.build();
+        }
+    }
+
+    private static List<ShareFetchResponseData.AcquiredRecords> 
acquiredRecords(long firstOffset, int numRecords) {
+        return List.of(new ShareFetchResponseData.AcquiredRecords()
+                .setFirstOffset(firstOffset)
+                .setLastOffset(firstOffset + numRecords - 1)
+                .setDeliveryCount((short) 1));
+    }
+
     /**
      * This is a handy utility method for returning a set from a varargs array.
      */
diff --git 
a/clients/src/test/java/org/apache/kafka/clients/consumer/internals/ShareSessionHandlerTest.java
 
b/clients/src/test/java/org/apache/kafka/clients/consumer/internals/ShareSessionHandlerTest.java
index 405adff250e..de997920a2e 100644
--- 
a/clients/src/test/java/org/apache/kafka/clients/consumer/internals/ShareSessionHandlerTest.java
+++ 
b/clients/src/test/java/org/apache/kafka/clients/consumer/internals/ShareSessionHandlerTest.java
@@ -44,8 +44,10 @@ import java.util.List;
 import java.util.Map;
 import java.util.stream.Stream;
 
+import static 
org.apache.kafka.common.requests.ShareRequestMetadata.FINAL_EPOCH;
 import static 
org.apache.kafka.common.requests.ShareRequestMetadata.INITIAL_EPOCH;
 import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertNotNull;
 import static org.junit.jupiter.api.Assertions.assertNull;
 import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -619,6 +621,44 @@ public class ShareSessionHandlerTest {
         assertEquals(1, barPartition.acknowledgementBatches().size());
     }
 
+    @ParameterizedTest
+    @MethodSource("shareFetchConfigProvider")
+    public void 
testCloseEmptySessionBuildsFinalEpochShareFetch(ShareFetchConfig 
shareFetchConfig) {
+        String groupId = "G1";
+        Uuid memberId = Uuid.randomUuid();
+        ShareSessionHandler handler = new ShareSessionHandler(LOG_CONTEXT, 1, 
memberId);
+
+        Map<Uuid, String> topicNames = new HashMap<>();
+        Uuid fooId = addTopicId(topicNames, "foo");
+        TopicIdPartition foo0 = new TopicIdPartition(fooId, 0, "foo");
+
+        // Establish the session with foo0.
+        handler.addPartitionToFetch(foo0, null);
+        assertNotNull(handler.newShareFetchBuilder(groupId, shareFetchConfig, 
false));
+        assertFalse(handler.isSessionEmpty());
+        ShareFetchResponse resp = ShareFetchResponse.of(Errors.NONE,
+            0,
+            buildResponseData(new RespEntry("foo", 0, fooId)),
+            List.of(),
+            0);
+        handler.handleResponse(resp, ApiKeys.SHARE_FETCH.latestVersion());
+
+        // Remove the only partition by building without adding the partition, 
emptying it.
+        assertNotNull(handler.newShareFetchBuilder(groupId, shareFetchConfig, 
false));
+        handler.handleResponse(ShareFetchResponse.of(Errors.NONE, 0, new 
LinkedHashMap<>(), List.of(), 0), ApiKeys.SHARE_FETCH.latestVersion());
+        assertTrue(handler.isSessionEmpty());
+
+        // Closing the empty session builds a ShareFetch request with the 
final epoch to close it on the broker.
+        handler.notifyClose();
+        ShareFetchRequest.Builder builder = 
handler.newShareFetchBuilder(groupId, shareFetchConfig, true);
+        assertNotNull(builder);
+        ShareFetchRequestData requestData = builder.build().data();
+        assertEquals(memberId.toString(), requestData.memberId());
+        assertEquals(FINAL_EPOCH, requestData.shareSessionEpoch());
+        assertTrue(requestData.topics().isEmpty());
+        assertTrue(requestData.forgottenTopicsData().isEmpty());
+    }
+
     private Uuid addTopicId(Map<Uuid, String> topicNames, String name) {
         Uuid id = Uuid.randomUuid();
         topicNames.put(id, name);

Reply via email to