[improve][broker] Reduce PendingAcksMap memory by replacing IntIntPair values with packed longs (#26030)
diff --git a/microbench/build.gradle.kts b/microbench/build.gradle.kts
index 9cea5eb..2164862 100644
--- a/microbench/build.gradle.kts
+++ b/microbench/build.gradle.kts
@@ -29,6 +29,7 @@
     implementation(project(":pulsar-common"))
     api(project(":pulsar-broker"))
     implementation(libs.bookkeeper.server)
+    implementation(libs.fastutil)
     api(libs.guava)
     api("org.openjdk.jmh:jmh-core:1.37")
     annotationProcessor("org.openjdk.jmh:jmh-generator-annprocess:1.37")
diff --git a/microbench/src/main/java/org/apache/pulsar/broker/service/PendingAcksMapPackingBenchmark.java b/microbench/src/main/java/org/apache/pulsar/broker/service/PendingAcksMapPackingBenchmark.java
new file mode 100644
index 0000000..70e711c
--- /dev/null
+++ b/microbench/src/main/java/org/apache/pulsar/broker/service/PendingAcksMapPackingBenchmark.java
@@ -0,0 +1,536 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.pulsar.broker.service;
+
+import it.unimi.dsi.fastutil.ints.IntIntPair;
+import it.unimi.dsi.fastutil.longs.Long2ObjectMap;
+import it.unimi.dsi.fastutil.longs.Long2ObjectRBTreeMap;
+import it.unimi.dsi.fastutil.longs.Long2ObjectSortedMap;
+import it.unimi.dsi.fastutil.objects.ObjectBidirectionalIterator;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.locks.Lock;
+import java.util.concurrent.locks.ReadWriteLock;
+import java.util.concurrent.locks.ReentrantReadWriteLock;
+import java.util.function.Supplier;
+import org.openjdk.jmh.annotations.Benchmark;
+import org.openjdk.jmh.annotations.BenchmarkMode;
+import org.openjdk.jmh.annotations.Fork;
+import org.openjdk.jmh.annotations.Level;
+import org.openjdk.jmh.annotations.Measurement;
+import org.openjdk.jmh.annotations.Mode;
+import org.openjdk.jmh.annotations.OutputTimeUnit;
+import org.openjdk.jmh.annotations.Param;
+import org.openjdk.jmh.annotations.Scope;
+import org.openjdk.jmh.annotations.Setup;
+import org.openjdk.jmh.annotations.State;
+import org.openjdk.jmh.annotations.Warmup;
+
+@OutputTimeUnit(TimeUnit.NANOSECONDS)
+@BenchmarkMode(Mode.AverageTime)
+@Fork(1)
+@Warmup(iterations = 2, time = 1, timeUnit = TimeUnit.SECONDS)
+@Measurement(iterations = 3, time = 1, timeUnit = TimeUnit.SECONDS)
+public class PendingAcksMapPackingBenchmark {
+    private static final int PENDING_ACK_NOT_FOUND = -1;
+
+    @Benchmark
+    public boolean addOrReplace(MapState state, CursorState cursor) {
+        int index = cursor.next(state.entries);
+        return state.store.addOrReplace(state.ledgerIds[index], state.entryIds[index],
+                remainingUnacked(index), stickyKeyHash(index));
+    }
+
+    @Benchmark
+    public boolean containsHit(MapState state, CursorState cursor) {
+        int index = cursor.next(state.entries);
+        return state.store.contains(state.ledgerIds[index], state.entryIds[index]);
+    }
+
+    @Benchmark
+    public long forEachScan(MapState state) {
+        return state.store.forEachScan();
+    }
+
+    @Benchmark
+    public int getRemainingUnackedHit(MapState state, CursorState cursor) {
+        int index = cursor.next(state.entries);
+        return state.store.getRemainingUnacked(state.ledgerIds[index], state.entryIds[index]);
+    }
+
+    @Benchmark
+    public long removeAllUpToBeforeFirstEntry(MapState state) {
+        return state.store.removeAllUpTo(0, -1);
+    }
+
+    @Benchmark
+    public long removeAllUpToSmallPrefixAndRefill(PrefixRemoveState state) {
+        long removed = state.store.removeAllUpTo(0, state.prefixEntries - 1L);
+        for (int i = 0; i < state.prefixEntries; i++) {
+            state.store.addOrReplace(0, i, remainingUnacked(i), stickyKeyHash(i));
+        }
+        return removed;
+    }
+
+    @Benchmark
+    public int removeAndGetRemainingAndAdd(MapState state, CursorState cursor) {
+        int index = cursor.next(state.entries);
+        long ledgerId = state.ledgerIds[index];
+        long entryId = state.entryIds[index];
+        int remainingUnacked = state.store.removeAndGetRemainingUnacked(ledgerId, entryId);
+        state.store.addOrReplace(ledgerId, entryId,
+                remainingUnacked == PENDING_ACK_NOT_FOUND ? remainingUnacked(index) : remainingUnacked,
+                stickyKeyHash(index));
+        return remainingUnacked;
+    }
+
+    @Benchmark
+    public boolean removeWithValueAndAdd(MapState state, CursorState cursor) {
+        int index = cursor.next(state.entries);
+        long ledgerId = state.ledgerIds[index];
+        long entryId = state.entryIds[index];
+        int remainingUnacked = remainingUnacked(index);
+        int stickyKeyHash = stickyKeyHash(index);
+        boolean removed = state.store.remove(ledgerId, entryId, remainingUnacked, stickyKeyHash);
+        state.store.addOrReplace(ledgerId, entryId, remainingUnacked, stickyKeyHash);
+        return removed;
+    }
+
+    @Benchmark
+    public boolean updateRemainingUnacked(MapState state, CursorState cursor) {
+        int index = cursor.next(state.entries);
+        return state.store.updateRemainingUnacked(state.ledgerIds[index], state.entryIds[index], 1);
+    }
+
+    @State(Scope.Benchmark)
+    public static class MapState {
+        @Param({"FORMER_PENDING_ACKS_MAP", "CURRENT_PENDING_ACKS_MAP"})
+        private Implementation implementation;
+
+        @Param({"50000"})
+        private int entries;
+
+        @Param({"1"})
+        private int ledgers;
+
+        private PendingAcksStore store;
+        private long[] ledgerIds;
+        private long[] entryIds;
+
+        @Setup(Level.Trial)
+        public void setup() {
+            store = implementation.createStore();
+            ledgerIds = new long[entries];
+            entryIds = new long[entries];
+            populate(store, entries, ledgers, ledgerIds, entryIds);
+        }
+    }
+
+    @State(Scope.Thread)
+    public static class PrefixRemoveState {
+        @Param({"FORMER_PENDING_ACKS_MAP", "CURRENT_PENDING_ACKS_MAP"})
+        private Implementation implementation;
+
+        @Param({"50000"})
+        private int entries;
+
+        private PendingAcksStore store;
+        private int prefixEntries;
+
+        @Setup(Level.Trial)
+        public void setup() {
+            store = implementation.createStore();
+            prefixEntries = Math.max(1, entries / 50);
+            populate(store, entries, 1, null, null);
+        }
+    }
+
+    @State(Scope.Thread)
+    public static class CursorState {
+        private int cursor;
+
+        int next(int bound) {
+            int next = cursor++;
+            if (cursor == bound) {
+                cursor = 0;
+            }
+            return next;
+        }
+    }
+
+    public enum Implementation {
+        FORMER_PENDING_ACKS_MAP {
+            @Override
+            PendingAcksStore createStore() {
+                return new FormerPendingAcksMapStore();
+            }
+        },
+        CURRENT_PENDING_ACKS_MAP {
+            @Override
+            PendingAcksStore createStore() {
+                return new CurrentPendingAcksMapStore();
+            }
+        };
+
+        abstract PendingAcksStore createStore();
+    }
+
+    private interface PendingAcksStore {
+        boolean addOrReplace(long ledgerId, long entryId, int remainingUnacked, int stickyKeyHash);
+
+        boolean contains(long ledgerId, long entryId);
+
+        int getRemainingUnacked(long ledgerId, long entryId);
+
+        boolean remove(long ledgerId, long entryId, int remainingUnacked, int stickyKeyHash);
+
+        int removeAndGetRemainingUnacked(long ledgerId, long entryId);
+
+        boolean updateRemainingUnacked(long ledgerId, long entryId, int ackedDelta);
+
+        long forEachScan();
+
+        long removeAllUpTo(long markDeleteLedgerId, long markDeleteEntryId);
+    }
+
+    private static final class FormerPendingAcksMapStore implements PendingAcksStore {
+        private final Consumer consumer = null;
+        private final Long2ObjectSortedMap<Long2ObjectSortedMap<IntIntPair>> pendingAcks =
+                new Long2ObjectRBTreeMap<>();
+        private final Supplier<PendingAcksMap.PendingAcksAddHandler> pendingAcksAddHandlerSupplier = () -> null;
+        private final Supplier<PendingAcksMap.PendingAcksRemoveHandler> pendingAcksRemoveHandlerSupplier = () -> null;
+        private final Lock readLock;
+        private final Lock writeLock;
+        private boolean closed = false;
+        private volatile long size;
+        private long forEachSum;
+        private long removedCount;
+        private final PendingAcksMap.PendingAcksConsumer pendingAckScanner =
+                (ledgerId, entryId, remainingUnacked, stickyKeyHash) ->
+                        forEachSum += ledgerId + entryId + remainingUnacked + stickyKeyHash;
+        private final PendingAcksMap.PendingAcksConsumer removedCounter =
+                (ledgerId, entryId, remainingUnacked, stickyKeyHash) -> removedCount++;
+
+        private FormerPendingAcksMapStore() {
+            ReadWriteLock readWriteLock = new ReentrantReadWriteLock();
+            this.writeLock = readWriteLock.writeLock();
+            this.readLock = readWriteLock.readLock();
+        }
+
+        @Override
+        public boolean addOrReplace(long ledgerId, long entryId, int remainingUnacked, int stickyKeyHash) {
+            try {
+                writeLock.lock();
+                if (closed) {
+                    return false;
+                }
+                PendingAcksMap.PendingAcksAddHandler pendingAcksAddHandler = pendingAcksAddHandlerSupplier.get();
+                if (pendingAcksAddHandler != null
+                        && !pendingAcksAddHandler.handleAdding(consumer, ledgerId, entryId, stickyKeyHash)) {
+                    return false;
+                }
+                Long2ObjectSortedMap<IntIntPair> ledgerPendingAcks =
+                        pendingAcks.computeIfAbsent(ledgerId, k -> new Long2ObjectRBTreeMap<>());
+                IntIntPair previous = ledgerPendingAcks.put(entryId, IntIntPair.of(remainingUnacked, stickyKeyHash));
+                if (previous == null) {
+                    size++;
+                }
+                return true;
+            } finally {
+                writeLock.unlock();
+            }
+        }
+
+        @Override
+        public boolean contains(long ledgerId, long entryId) {
+            try {
+                readLock.lock();
+                Long2ObjectSortedMap<IntIntPair> ledgerMap = pendingAcks.get(ledgerId);
+                return ledgerMap != null && ledgerMap.containsKey(entryId);
+            } finally {
+                readLock.unlock();
+            }
+        }
+
+        @Override
+        public int getRemainingUnacked(long ledgerId, long entryId) {
+            try {
+                readLock.lock();
+                Long2ObjectSortedMap<IntIntPair> ledgerMap = pendingAcks.get(ledgerId);
+                IntIntPair value = ledgerMap == null ? null : ledgerMap.get(entryId);
+                return value == null ? PENDING_ACK_NOT_FOUND : value.leftInt();
+            } finally {
+                readLock.unlock();
+            }
+        }
+
+        @Override
+        public boolean remove(long ledgerId, long entryId, int remainingUnacked, int stickyKeyHash) {
+            try {
+                writeLock.lock();
+                Long2ObjectSortedMap<IntIntPair> ledgerMap = pendingAcks.get(ledgerId);
+                if (ledgerMap == null) {
+                    return false;
+                }
+                boolean removed = ledgerMap.remove(entryId, IntIntPair.of(remainingUnacked, stickyKeyHash));
+                if (removed) {
+                    size--;
+                    handleRemovePendingAck(ledgerId, entryId, stickyKeyHash);
+                }
+                if (removed && ledgerMap.isEmpty()) {
+                    pendingAcks.remove(ledgerId);
+                }
+                return removed;
+            } finally {
+                writeLock.unlock();
+            }
+        }
+
+        @Override
+        public int removeAndGetRemainingUnacked(long ledgerId, long entryId) {
+            try {
+                writeLock.lock();
+                Long2ObjectSortedMap<IntIntPair> ledgerMap = pendingAcks.get(ledgerId);
+                if (ledgerMap == null) {
+                    return PENDING_ACK_NOT_FOUND;
+                }
+                IntIntPair removed = ledgerMap.remove(entryId);
+                if (removed == null) {
+                    return PENDING_ACK_NOT_FOUND;
+                }
+                size--;
+                handleRemovePendingAck(ledgerId, entryId, removed.rightInt());
+                if (ledgerMap.isEmpty()) {
+                    pendingAcks.remove(ledgerId);
+                }
+                return removed.leftInt();
+            } finally {
+                writeLock.unlock();
+            }
+        }
+
+        @Override
+        public boolean updateRemainingUnacked(long ledgerId, long entryId, int ackedDelta) {
+            try {
+                writeLock.lock();
+                Long2ObjectSortedMap<IntIntPair> ledgerMap = pendingAcks.get(ledgerId);
+                IntIntPair value = ledgerMap == null ? null : ledgerMap.get(entryId);
+                if (value == null) {
+                    return false;
+                }
+                ledgerMap.put(entryId, IntIntPair.of(value.leftInt() - ackedDelta, value.rightInt()));
+                return true;
+            } finally {
+                writeLock.unlock();
+            }
+        }
+
+        @Override
+        public long forEachScan() {
+            forEachSum = 0;
+            forEach(pendingAckScanner);
+            return forEachSum;
+        }
+
+        @Override
+        public long removeAllUpTo(long markDeleteLedgerId, long markDeleteEntryId) {
+            removedCount = 0;
+            internalRemoveAllUpTo(markDeleteLedgerId, markDeleteEntryId, false, removedCounter);
+            return removedCount;
+        }
+
+        private void forEach(PendingAcksMap.PendingAcksConsumer processor) {
+            try {
+                readLock.lock();
+                processPendingAcks(processor);
+            } finally {
+                readLock.unlock();
+            }
+        }
+
+        private void processPendingAcks(PendingAcksMap.PendingAcksConsumer processor) {
+            for (Long2ObjectMap.Entry<Long2ObjectSortedMap<IntIntPair>> entry : pendingAcks.long2ObjectEntrySet()) {
+                long ledgerId = entry.getLongKey();
+                Long2ObjectSortedMap<IntIntPair> ledgerPendingAcks = entry.getValue();
+                for (Long2ObjectMap.Entry<IntIntPair> e : ledgerPendingAcks.long2ObjectEntrySet()) {
+                    long entryId = e.getLongKey();
+                    IntIntPair batchSizeAndStickyKeyHash = e.getValue();
+                    processor.accept(ledgerId, entryId, batchSizeAndStickyKeyHash.leftInt(),
+                            batchSizeAndStickyKeyHash.rightInt());
+                }
+            }
+        }
+
+        private void internalRemoveAllUpTo(long markDeleteLedgerId, long markDeleteEntryId, boolean useWriteLock,
+                                          PendingAcksMap.PendingAcksConsumer removedEntryCallback) {
+            PendingAcksMap.PendingAcksRemoveHandler pendingAcksRemoveHandler =
+                    pendingAcksRemoveHandlerSupplier.get();
+            boolean acquiredWriteLock = false;
+            boolean batchStarted = false;
+            boolean retryWithWriteLock = false;
+            try {
+                if (useWriteLock) {
+                    writeLock.lock();
+                    acquiredWriteLock = true;
+                } else {
+                    readLock.lock();
+                }
+                ObjectBidirectionalIterator<Long2ObjectMap.Entry<Long2ObjectSortedMap<IntIntPair>>> ledgerIterator =
+                        pendingAcks.headMap(markDeleteLedgerId + 1).long2ObjectEntrySet().iterator();
+                while (ledgerIterator.hasNext()) {
+                    Long2ObjectMap.Entry<Long2ObjectSortedMap<IntIntPair>> entry = ledgerIterator.next();
+                    long ledgerId = entry.getLongKey();
+                    Long2ObjectSortedMap<IntIntPair> ledgerMap = entry.getValue();
+                    Long2ObjectSortedMap<IntIntPair> ledgerMapHead =
+                            ledgerId == markDeleteLedgerId ? ledgerMap.headMap(markDeleteEntryId + 1) : ledgerMap;
+                    ObjectBidirectionalIterator<Long2ObjectMap.Entry<IntIntPair>> entryIterator =
+                            ledgerMapHead.long2ObjectEntrySet().iterator();
+                    while (entryIterator.hasNext()) {
+                        Long2ObjectMap.Entry<IntIntPair> pendingAckEntry = entryIterator.next();
+                        long entryId = pendingAckEntry.getLongKey();
+                        if (!acquiredWriteLock) {
+                            retryWithWriteLock = true;
+                            return;
+                        }
+                        IntIntPair value = pendingAckEntry.getValue();
+                        int remainingUnacked = value.leftInt();
+                        int stickyKeyHash = value.rightInt();
+                        if (pendingAcksRemoveHandler != null) {
+                            if (!batchStarted) {
+                                pendingAcksRemoveHandler.startBatch();
+                                batchStarted = true;
+                            }
+                            pendingAcksRemoveHandler.handleRemoving(consumer, ledgerId, entryId, stickyKeyHash,
+                                    closed);
+                        }
+                        if (removedEntryCallback != null) {
+                            removedEntryCallback.accept(ledgerId, entryId, remainingUnacked, stickyKeyHash);
+                        }
+                        entryIterator.remove();
+                        size--;
+                    }
+                    if (ledgerMap.isEmpty()) {
+                        if (!acquiredWriteLock) {
+                            retryWithWriteLock = true;
+                            return;
+                        }
+                        ledgerIterator.remove();
+                    }
+                }
+            } finally {
+                if (batchStarted) {
+                    pendingAcksRemoveHandler.endBatch();
+                }
+                if (acquiredWriteLock) {
+                    writeLock.unlock();
+                } else {
+                    readLock.unlock();
+                    if (retryWithWriteLock) {
+                        internalRemoveAllUpTo(markDeleteLedgerId, markDeleteEntryId, true, removedEntryCallback);
+                    }
+                }
+            }
+        }
+
+        private void handleRemovePendingAck(long ledgerId, long entryId, int stickyKeyHash) {
+            PendingAcksMap.PendingAcksRemoveHandler pendingAcksRemoveHandler =
+                    pendingAcksRemoveHandlerSupplier.get();
+            if (pendingAcksRemoveHandler != null) {
+                pendingAcksRemoveHandler.handleRemoving(consumer, ledgerId, entryId, stickyKeyHash, closed);
+            }
+        }
+    }
+
+    private static final class CurrentPendingAcksMapStore implements PendingAcksStore {
+        private final PendingAcksMap pendingAcks = new PendingAcksMap(null, () -> null, () -> null);
+        private long forEachSum;
+        private long removedCount;
+        private final PendingAcksMap.PendingAcksConsumer pendingAckScanner =
+                (ledgerId, entryId, remainingUnacked, stickyKeyHash) ->
+                        forEachSum += ledgerId + entryId + remainingUnacked + stickyKeyHash;
+        private final PendingAcksMap.PendingAcksConsumer removedCounter =
+                (ledgerId, entryId, remainingUnacked, stickyKeyHash) -> removedCount++;
+
+        @Override
+        public boolean addOrReplace(long ledgerId, long entryId, int remainingUnacked, int stickyKeyHash) {
+            return pendingAcks.addPendingAckIfAllowed(ledgerId, entryId, remainingUnacked, stickyKeyHash);
+        }
+
+        @Override
+        public boolean contains(long ledgerId, long entryId) {
+            return pendingAcks.contains(ledgerId, entryId);
+        }
+
+        @Override
+        public int getRemainingUnacked(long ledgerId, long entryId) {
+            return pendingAcks.getRemainingUnacked(ledgerId, entryId);
+        }
+
+        @Override
+        public boolean remove(long ledgerId, long entryId, int remainingUnacked, int stickyKeyHash) {
+            return pendingAcks.remove(ledgerId, entryId, remainingUnacked, stickyKeyHash);
+        }
+
+        @Override
+        public int removeAndGetRemainingUnacked(long ledgerId, long entryId) {
+            return pendingAcks.removeAndGetRemainingUnacked(ledgerId, entryId);
+        }
+
+        @Override
+        public boolean updateRemainingUnacked(long ledgerId, long entryId, int ackedDelta) {
+            return pendingAcks.updateRemainingUnacked(ledgerId, entryId, ackedDelta);
+        }
+
+        @Override
+        public long forEachScan() {
+            forEachSum = 0;
+            pendingAcks.forEach(pendingAckScanner);
+            return forEachSum;
+        }
+
+        @Override
+        public long removeAllUpTo(long markDeleteLedgerId, long markDeleteEntryId) {
+            removedCount = 0;
+            pendingAcks.removeAllUpTo(markDeleteLedgerId, markDeleteEntryId, removedCounter);
+            return removedCount;
+        }
+    }
+
+    private static void populate(PendingAcksStore store, int entries, int ledgers, long[] ledgerIds, long[] entryIds) {
+        int index = 0;
+        for (int ledger = 0; ledger < ledgers; ledger++) {
+            int entriesInLedger = entries / ledgers + (ledger < entries % ledgers ? 1 : 0);
+            for (int entry = 0; entry < entriesInLedger; entry++) {
+                if (ledgerIds != null) {
+                    ledgerIds[index] = ledger;
+                    entryIds[index] = entry;
+                }
+                store.addOrReplace(ledger, entry, remainingUnacked(index), stickyKeyHash(index));
+                index++;
+            }
+        }
+    }
+
+    private static int remainingUnacked(int index) {
+        return 1_000_000_000 - (index & 1023);
+    }
+
+    private static int stickyKeyHash(int index) {
+        return index * 0x9E3779B9;
+    }
+}
diff --git a/microbench/src/main/java/org/apache/pulsar/broker/service/package-info.java b/microbench/src/main/java/org/apache/pulsar/broker/service/package-info.java
new file mode 100644
index 0000000..ee8034b
--- /dev/null
+++ b/microbench/src/main/java/org/apache/pulsar/broker/service/package-info.java
@@ -0,0 +1,22 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+/**
+ * Benchmarks for broker service internals.
+ */
+package org.apache.pulsar.broker.service;
diff --git a/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/Consumer.java b/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/Consumer.java
index fdad320..a7f2965 100644
--- a/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/Consumer.java
+++ b/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/Consumer.java
@@ -28,7 +28,6 @@
 import io.netty.util.concurrent.Future;
 import io.netty.util.concurrent.Promise;
 import io.opentelemetry.api.common.Attributes;
-import it.unimi.dsi.fastutil.ints.IntIntPair;
 import it.unimi.dsi.fastutil.objects.ObjectIntPair;
 import java.time.Instant;
 import java.util.ArrayList;
@@ -83,6 +82,7 @@
 public class Consumer {
 
     private static final Logger LOG = Logger.get(Consumer.class);
+    private static final int PENDING_ACK_NOT_FOUND = PendingAcksMap.PENDING_ACK_NOT_FOUND;
     private final Logger log;
 
     private final Subscription subscription;
@@ -649,10 +649,10 @@
                         addAndGetUnAckedMsgs(ackOwnerConsumer, -(int) ackedCount);
                     }
                 } else if (!hasAckSet) {
-                    IntIntPair removed = ackOwnerConsumer.removePendingAckAndGet(
+                    int removed = ackOwnerConsumer.removePendingAckAndGetRemainingUnacked(
                             position.getLedgerId(), position.getEntryId());
-                    if (removed != null) {
-                        addAndGetUnAckedMsgs(ackOwnerConsumer, -removed.leftInt());
+                    if (removed != PENDING_ACK_NOT_FOUND) {
+                        addAndGetUnAckedMsgs(ackOwnerConsumer, -removed);
                         updateBlockedConsumerOnUnackedMsgs(ackOwnerConsumer);
                     }
                 }
@@ -744,10 +744,10 @@
                     }
                 }
             } else {
-                IntIntPair removed = ackOwnerConsumer.removePendingAckAndGet(
+                int removed = ackOwnerConsumer.removePendingAckAndGetRemainingUnacked(
                         position.getLedgerId(), position.getEntryId());
-                if (removed != null) {
-                    addAndGetUnAckedMsgs(ackOwnerConsumer, -removed.leftInt());
+                if (removed != PENDING_ACK_NOT_FOUND) {
+                    addAndGetUnAckedMsgs(ackOwnerConsumer, -removed);
                 }
             }
             updateBlockedConsumerOnUnackedMsgs(ackOwnerConsumer);
@@ -841,16 +841,16 @@
      */
     private ObjectIntPair<Consumer> getAckOwnerConsumerAndBatchSize(long ledgerId, long entryId) {
         if (Subscription.isIndividualAckMode(subType)) {
-            IntIntPair pendingAck = getPendingAcks().get(ledgerId, entryId);
-            if (pendingAck != null) {
-                return ObjectIntPair.of(this, pendingAck.leftInt());
+            int remainingUnacked = getPendingAcks().getRemainingUnacked(ledgerId, entryId);
+            if (remainingUnacked != PENDING_ACK_NOT_FOUND) {
+                return ObjectIntPair.of(this, remainingUnacked);
             } else {
                 // If there are more consumers, this step will consume more CPU, and it should be optimized later.
                 for (Consumer consumer : subscription.getConsumers()) {
                     if (consumer != this) {
-                        pendingAck = consumer.getPendingAcks().get(ledgerId, entryId);
-                        if (pendingAck != null) {
-                            return ObjectIntPair.of(consumer, pendingAck.leftInt());
+                        remainingUnacked = consumer.getPendingAcks().getRemainingUnacked(ledgerId, entryId);
+                        if (remainingUnacked != PENDING_ACK_NOT_FOUND) {
+                            return ObjectIntPair.of(consumer, remainingUnacked);
                         }
                     }
                 }
@@ -1193,14 +1193,13 @@
      *
      * <p>No-op if {@code pendingAcks} is not initialized.
      *
-     * @return the removed {@link IntIntPair#leftInt() remainingUnacked} and
-     *         {@link IntIntPair#rightInt() stickyKeyHash}, or {@code null} if not found
+     * @return the remaining unacked count, or {@link PendingAcksMap#PENDING_ACK_NOT_FOUND} if not found
      */
-    public IntIntPair removePendingAckAndGet(long ledgerId, long entryId) {
+    public int removePendingAckAndGetRemainingUnacked(long ledgerId, long entryId) {
         if (pendingAcks != null) {
-            return pendingAcks.removeAndGet(ledgerId, entryId);
+            return pendingAcks.removeAndGetRemainingUnacked(ledgerId, entryId);
         }
-        return null;
+        return PENDING_ACK_NOT_FOUND;
     }
 
     /**
@@ -1269,9 +1268,10 @@
         List<Position> pendingPositions = new ArrayList<>();
         for (MessageIdData msg : messageIds) {
             Position position = PositionFactory.create(msg.getLedgerId(), msg.getEntryId());
-            IntIntPair pendingAck = pendingAcks.removeAndGet(position.getLedgerId(), position.getEntryId());
-            if (pendingAck != null) {
-                totalRedeliveryMessages += pendingAck.leftInt();
+            int remainingUnacked = pendingAcks.removeAndGetRemainingUnacked(
+                    position.getLedgerId(), position.getEntryId());
+            if (remainingUnacked != PENDING_ACK_NOT_FOUND) {
+                totalRedeliveryMessages += remainingUnacked;
                 pendingPositions.add(position);
             }
         }
diff --git a/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/PendingAckValues.java b/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/PendingAckValues.java
new file mode 100644
index 0000000..a27e937
--- /dev/null
+++ b/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/PendingAckValues.java
@@ -0,0 +1,77 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.pulsar.broker.service;
+
+/**
+ * Utility methods for storing pending ack values in primitive maps.
+ *
+ * <p>The high 32 bits contain the remaining unacked count and the low 32 bits contain the sticky key hash.
+ * A value with {@link #NOT_FOUND} as the remaining unacked count and 0 as the sticky key hash is reserved
+ * as the missing-entry sentinel.
+ */
+final class PendingAckValues {
+    /**
+     * Remaining unacked count reserved for missing entries.
+     */
+    static final int NOT_FOUND = -1;
+
+    /**
+     * Packed value reserved for missing entries in primitive maps.
+     */
+    static final long PACKED_NOT_FOUND = (long) NOT_FOUND << Integer.SIZE;
+
+    private static final long STICKY_KEY_HASH_MASK = 0xFFFF_FFFFL;
+
+    private PendingAckValues() {
+    }
+
+    /**
+     * Packs a pending ack's remaining unacked count and sticky key hash into one long value.
+     *
+     * @throws IllegalArgumentException if the remaining unacked count is negative
+     */
+    static long pack(int remainingUnacked, int stickyKeyHash) {
+        if (remainingUnacked < 0) {
+            throw new IllegalArgumentException("remainingUnacked must be non-negative");
+        }
+        return ((long) remainingUnacked << Integer.SIZE) | (stickyKeyHash & STICKY_KEY_HASH_MASK);
+    }
+
+    /**
+     * Returns true if the packed value is the missing-entry sentinel.
+     */
+    static boolean isNotFound(long packedValue) {
+        return packedValue == PACKED_NOT_FOUND;
+    }
+
+    /**
+     * Returns the remaining unacked count stored in the high 32 bits.
+     */
+    static int remainingUnacked(long packedValue) {
+        return (int) (packedValue >> Integer.SIZE);
+    }
+
+    /**
+     * Returns the sticky key hash stored in the low 32 bits.
+     */
+    static int stickyKeyHash(long packedValue) {
+        return (int) packedValue;
+    }
+
+}
diff --git a/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/PendingAcksMap.java b/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/PendingAcksMap.java
index 9eb43ef..7932106 100644
--- a/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/PendingAcksMap.java
+++ b/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/PendingAcksMap.java
@@ -18,7 +18,9 @@
  */
 package org.apache.pulsar.broker.service;
 
-import it.unimi.dsi.fastutil.ints.IntIntPair;
+import it.unimi.dsi.fastutil.longs.Long2LongMap;
+import it.unimi.dsi.fastutil.longs.Long2LongRBTreeMap;
+import it.unimi.dsi.fastutil.longs.Long2LongSortedMap;
 import it.unimi.dsi.fastutil.longs.Long2ObjectMap;
 import it.unimi.dsi.fastutil.longs.Long2ObjectRBTreeMap;
 import it.unimi.dsi.fastutil.longs.Long2ObjectSortedMap;
@@ -40,6 +42,8 @@
  * running.
  */
 public class PendingAcksMap {
+    static final int PENDING_ACK_NOT_FOUND = PendingAckValues.NOT_FOUND;
+
     /**
      * Callback interface for handling the addition of pending acknowledgments.
      */
@@ -98,7 +102,12 @@
     }
 
     private final Consumer consumer;
-    private final Long2ObjectSortedMap<Long2ObjectSortedMap<IntIntPair>> pendingAcks;
+
+    /**
+     * Pending acknowledgments grouped by ledger id. The outer map key is ledgerId and each inner map key is entryId.
+     * The inner map value is packed by {@link PendingAckValues} with the remaining unacked count and sticky key hash.
+     */
+    private final Long2ObjectSortedMap<Long2LongSortedMap> pendingAcks;
     private final Supplier<PendingAcksAddHandler> pendingAcksAddHandlerSupplier;
     private final Supplier<PendingAcksRemoveHandler> pendingAcksRemoveHandlerSupplier;
     private final Lock readLock;
@@ -145,10 +154,11 @@
                     && !pendingAcksAddHandler.handleAdding(consumer, ledgerId, entryId, stickyKeyHash)) {
                 return false;
             }
-            Long2ObjectSortedMap<IntIntPair> ledgerPendingAcks =
-                    pendingAcks.computeIfAbsent(ledgerId, k -> new Long2ObjectRBTreeMap<>());
-            IntIntPair previous = ledgerPendingAcks.put(entryId, IntIntPair.of(remainingUnacked, stickyKeyHash));
-            if (previous == null) {
+            Long2LongSortedMap ledgerPendingAcks =
+                    pendingAcks.computeIfAbsent(ledgerId, k -> newLedgerPendingAcks());
+            long packedValue = PendingAckValues.pack(remainingUnacked, stickyKeyHash);
+            long previous = ledgerPendingAcks.put(entryId, packedValue);
+            if (PendingAckValues.isNotFound(previous)) {
                 size++;
             }
             return true;
@@ -180,19 +190,14 @@
         }
     }
 
-    // iterate all pending acks and process them
+    // this code uses for loops intentionally, don't refactor to use forEach
     private void processPendingAcks(PendingAcksConsumer processor) {
-        // this code uses for loops intentionally, don't refactor to use forEach
-        // iterate the outer map
-        for (Long2ObjectMap.Entry<Long2ObjectSortedMap<IntIntPair>> entry : pendingAcks.long2ObjectEntrySet()) {
+        for (Long2ObjectMap.Entry<Long2LongSortedMap> entry : pendingAcks.long2ObjectEntrySet()) {
             long ledgerId = entry.getLongKey();
-            Long2ObjectSortedMap<IntIntPair> ledgerPendingAcks = entry.getValue();
-            // iterate the inner map
-            for (Long2ObjectMap.Entry<IntIntPair> e : ledgerPendingAcks.long2ObjectEntrySet()) {
-                long entryId = e.getLongKey();
-                IntIntPair batchSizeAndStickyKeyHash = e.getValue();
-                processor.accept(ledgerId, entryId, batchSizeAndStickyKeyHash.leftInt(),
-                        batchSizeAndStickyKeyHash.rightInt());
+            for (Long2LongMap.Entry pendingAckEntry : entry.getValue().long2LongEntrySet()) {
+                long packedValue = pendingAckEntry.getLongValue();
+                processor.accept(ledgerId, pendingAckEntry.getLongKey(),
+                        PendingAckValues.remainingUnacked(packedValue), PendingAckValues.stickyKeyHash(packedValue));
             }
         }
     }
@@ -255,7 +260,7 @@
     public boolean contains(long ledgerId, long entryId) {
         try {
             readLock.lock();
-            Long2ObjectSortedMap<IntIntPair> ledgerMap = pendingAcks.get(ledgerId);
+            Long2LongSortedMap ledgerMap = pendingAcks.get(ledgerId);
             if (ledgerMap == null) {
                 return false;
             }
@@ -266,20 +271,20 @@
     }
 
     /**
-     * Get the pending ack for the given ledger ID and entry ID.
+     * Get the remaining unacked count for the given ledger ID and entry ID.
      *
-     * @param ledgerId the ledger ID
-     * @param entryId the entry ID
-     * @return the pending ack, or null if not found
+     * @return the remaining unacked count, or {@link #PENDING_ACK_NOT_FOUND} if not found
      */
-    public IntIntPair get(long ledgerId, long entryId) {
+    int getRemainingUnacked(long ledgerId, long entryId) {
         try {
             readLock.lock();
-            Long2ObjectSortedMap<IntIntPair> ledgerMap = pendingAcks.get(ledgerId);
+            Long2LongSortedMap ledgerMap = pendingAcks.get(ledgerId);
             if (ledgerMap == null) {
-                return null;
+                return PENDING_ACK_NOT_FOUND;
             }
-            return ledgerMap.get(entryId);
+            long packedValue = ledgerMap.get(entryId);
+            return PendingAckValues.isNotFound(packedValue)
+                    ? PENDING_ACK_NOT_FOUND : PendingAckValues.remainingUnacked(packedValue);
         } finally {
             readLock.unlock();
         }
@@ -297,19 +302,21 @@
     public boolean remove(long ledgerId, long entryId, int batchSize, int stickyKeyHash) {
         try {
             writeLock.lock();
-            Long2ObjectSortedMap<IntIntPair> ledgerMap = pendingAcks.get(ledgerId);
-            if (ledgerMap == null) {
+            Long2LongSortedMap ledgerMap = pendingAcks.get(ledgerId);
+            if (batchSize < 0) {
                 return false;
             }
-            boolean removed = ledgerMap.remove(entryId, IntIntPair.of(batchSize, stickyKeyHash));
-            if (removed) {
-                size--;
-                handleRemovePendingAck(ledgerId, entryId, stickyKeyHash);
+            long expectedValue = PendingAckValues.pack(batchSize, stickyKeyHash);
+            if (ledgerMap == null || ledgerMap.get(entryId) != expectedValue) {
+                return false;
             }
-            if (removed && ledgerMap.isEmpty()) {
+            ledgerMap.remove(entryId);
+            size--;
+            handleRemovePendingAck(ledgerId, entryId, stickyKeyHash);
+            if (ledgerMap.isEmpty()) {
                 pendingAcks.remove(ledgerId);
             }
-            return removed;
+            return true;
         } finally {
             writeLock.unlock();
         }
@@ -327,16 +334,19 @@
     public boolean updateRemainingUnacked(long ledgerId, long entryId, int ackedDelta) {
         try {
             writeLock.lock();
-            Long2ObjectSortedMap<IntIntPair> ledgerMap = pendingAcks.get(ledgerId);
+            Long2LongSortedMap ledgerMap = pendingAcks.get(ledgerId);
             if (ledgerMap == null) {
                 return false;
             }
-            IntIntPair current = ledgerMap.get(entryId);
-            if (current == null) {
+            long packedValue = ledgerMap.get(entryId);
+            if (PendingAckValues.isNotFound(packedValue)) {
                 return false;
             }
-            int newRemaining = current.leftInt() - ackedDelta;
-            ledgerMap.put(entryId, IntIntPair.of(newRemaining, current.rightInt()));
+            int newRemaining = PendingAckValues.remainingUnacked(packedValue) - ackedDelta;
+            if (newRemaining < 0) {
+                return false;
+            }
+            ledgerMap.put(entryId, PendingAckValues.pack(newRemaining, PendingAckValues.stickyKeyHash(packedValue)));
             return true;
         } finally {
             writeLock.unlock();
@@ -353,51 +363,47 @@
     public boolean remove(long ledgerId, long entryId) {
         try {
             writeLock.lock();
-            Long2ObjectSortedMap<IntIntPair> ledgerMap = pendingAcks.get(ledgerId);
+            Long2LongSortedMap ledgerMap = pendingAcks.get(ledgerId);
             if (ledgerMap == null) {
                 return false;
             }
-            IntIntPair removedEntry = ledgerMap.remove(entryId);
-            boolean removed = removedEntry != null;
-            if (removed) {
-                size--;
-                int stickyKeyHash = removedEntry.rightInt();
-                handleRemovePendingAck(ledgerId, entryId, stickyKeyHash);
+            long removedEntry = ledgerMap.remove(entryId);
+            if (PendingAckValues.isNotFound(removedEntry)) {
+                return false;
             }
-            if (removed && ledgerMap.isEmpty()) {
+            size--;
+            handleRemovePendingAck(ledgerId, entryId, PendingAckValues.stickyKeyHash(removedEntry));
+            if (ledgerMap.isEmpty()) {
                 pendingAcks.remove(ledgerId);
             }
-            return removed;
+            return true;
         } finally {
             writeLock.unlock();
         }
     }
 
     /**
-     * Atomically remove and return the pending ack for the given ledger ID and entry ID.
-     * Unlike {@link #remove(long, long)}, this method returns the removed entry so the caller
-     * can access the batch size and sticky key hash without a separate get operation.
+     * Atomically remove and return the remaining unacked count for the given ledger ID and entry ID.
      *
-     * @param ledgerId the ledger ID
-     * @param entryId the entry ID
-     * @return the removed entry as an IntIntPair (batchSize, stickyKeyHash), or null if not found
+     * @return the remaining unacked count, or {@link #PENDING_ACK_NOT_FOUND} if not found
      */
-    public IntIntPair removeAndGet(long ledgerId, long entryId) {
+    int removeAndGetRemainingUnacked(long ledgerId, long entryId) {
         try {
             writeLock.lock();
-            Long2ObjectSortedMap<IntIntPair> ledgerMap = pendingAcks.get(ledgerId);
+            Long2LongSortedMap ledgerMap = pendingAcks.get(ledgerId);
             if (ledgerMap == null) {
-                return null;
+                return PENDING_ACK_NOT_FOUND;
             }
-            IntIntPair removedEntry = ledgerMap.remove(entryId);
-            if (removedEntry != null) {
-                size--;
-                handleRemovePendingAck(ledgerId, entryId, removedEntry.rightInt());
+            long removedEntry = ledgerMap.remove(entryId);
+            if (PendingAckValues.isNotFound(removedEntry)) {
+                return PENDING_ACK_NOT_FOUND;
             }
-            if (removedEntry != null && ledgerMap.isEmpty()) {
+            size--;
+            handleRemovePendingAck(ledgerId, entryId, PendingAckValues.stickyKeyHash(removedEntry));
+            if (ledgerMap.isEmpty()) {
                 pendingAcks.remove(ledgerId);
             }
-            return removedEntry;
+            return PendingAckValues.remainingUnacked(removedEntry);
         } finally {
             writeLock.unlock();
         }
@@ -444,30 +450,30 @@
             } else {
                 readLock.lock();
             }
-            ObjectBidirectionalIterator<Long2ObjectMap.Entry<Long2ObjectSortedMap<IntIntPair>>> ledgerMapIterator =
+            ObjectBidirectionalIterator<Long2ObjectMap.Entry<Long2LongSortedMap>> ledgerMapIterator =
                     pendingAcks.headMap(markDeleteLedgerId + 1).long2ObjectEntrySet().iterator();
             while (ledgerMapIterator.hasNext()) {
-                Long2ObjectMap.Entry<Long2ObjectSortedMap<IntIntPair>> entry = ledgerMapIterator.next();
+                Long2ObjectMap.Entry<Long2LongSortedMap> entry = ledgerMapIterator.next();
                 long ledgerId = entry.getLongKey();
-                Long2ObjectSortedMap<IntIntPair> ledgerMap = entry.getValue();
-                Long2ObjectSortedMap<IntIntPair> ledgerMapHead;
+                Long2LongSortedMap ledgerMap = entry.getValue();
+                Long2LongSortedMap ledgerMapHead;
                 if (ledgerId == markDeleteLedgerId) {
                     ledgerMapHead = ledgerMap.headMap(markDeleteEntryId + 1);
                 } else {
                     ledgerMapHead = ledgerMap;
                 }
-                ObjectBidirectionalIterator<Long2ObjectMap.Entry<IntIntPair>> entryMapIterator =
-                        ledgerMapHead.long2ObjectEntrySet().iterator();
+                ObjectBidirectionalIterator<Long2LongMap.Entry> entryMapIterator =
+                        ledgerMapHead.long2LongEntrySet().iterator();
                 while (entryMapIterator.hasNext()) {
-                    Long2ObjectMap.Entry<IntIntPair> intIntPairEntry = entryMapIterator.next();
-                    long entryId = intIntPairEntry.getLongKey();
+                    Long2LongMap.Entry pendingAckEntry = entryMapIterator.next();
+                    long entryId = pendingAckEntry.getLongKey();
                     if (!acquiredWriteLock) {
                         retryWithWriteLock = true;
                         return;
                     }
-                    IntIntPair value = intIntPairEntry.getValue();
-                    int batchSize = value.leftInt();
-                    int stickyKeyHash = value.rightInt();
+                    long packedValue = pendingAckEntry.getLongValue();
+                    int batchSize = PendingAckValues.remainingUnacked(packedValue);
+                    int stickyKeyHash = PendingAckValues.stickyKeyHash(packedValue);
                     if (pendingAcksRemoveHandler != null) {
                         if (!batchStarted) {
                             pendingAcksRemoveHandler.startBatch();
@@ -504,6 +510,12 @@
         }
     }
 
+    private static Long2LongSortedMap newLedgerPendingAcks() {
+        Long2LongRBTreeMap ledgerPendingAcks = new Long2LongRBTreeMap();
+        ledgerPendingAcks.defaultReturnValue(PendingAckValues.PACKED_NOT_FOUND);
+        return ledgerPendingAcks;
+    }
+
     private void handleRemovePendingAck(long ledgerId, long entryId, int stickyKeyHash) {
         PendingAcksRemoveHandler pendingAcksRemoveHandler = pendingAcksRemoveHandlerSupplier.get();
         if (pendingAcksRemoveHandler != null) {
diff --git a/pulsar-broker/src/test/java/org/apache/pulsar/broker/service/PendingAckValuesTest.java b/pulsar-broker/src/test/java/org/apache/pulsar/broker/service/PendingAckValuesTest.java
new file mode 100644
index 0000000..1931654
--- /dev/null
+++ b/pulsar-broker/src/test/java/org/apache/pulsar/broker/service/PendingAckValuesTest.java
@@ -0,0 +1,60 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.pulsar.broker.service;
+
+import static org.testng.Assert.assertEquals;
+import static org.testng.Assert.assertFalse;
+import static org.testng.Assert.assertThrows;
+import static org.testng.Assert.assertTrue;
+import org.testng.annotations.Test;
+
+public class PendingAckValuesTest {
+    @Test
+    public void pack_RoundTripsRemainingUnackedAndStickyKeyHash() {
+        int[][] values = new int[][] {
+                {0, 0},
+                {1, -1},
+                {Integer.MAX_VALUE, Integer.MIN_VALUE},
+                {42, Integer.MAX_VALUE},
+                {1_000_000, -123456789}
+        };
+
+        for (int[] value : values) {
+            long packedValue = PendingAckValues.pack(value[0], value[1]);
+
+            assertFalse(PendingAckValues.isNotFound(packedValue));
+            assertEquals(PendingAckValues.remainingUnacked(packedValue), value[0]);
+            assertEquals(PendingAckValues.stickyKeyHash(packedValue), value[1]);
+        }
+    }
+
+    @Test
+    public void packedNotFound_UsesReservedNegativeRemainingUnackedValue() {
+        assertTrue(PendingAckValues.isNotFound(PendingAckValues.PACKED_NOT_FOUND));
+        assertEquals(PendingAckValues.remainingUnacked(PendingAckValues.PACKED_NOT_FOUND),
+                PendingAckValues.NOT_FOUND);
+        assertEquals(PendingAckValues.stickyKeyHash(PendingAckValues.PACKED_NOT_FOUND), 0);
+        assertFalse(PendingAckValues.isNotFound(PendingAckValues.pack(0, 0)));
+    }
+
+    @Test
+    public void pack_RejectsNegativeRemainingUnacked() {
+        assertThrows(IllegalArgumentException.class, () -> PendingAckValues.pack(-1, 0));
+    }
+}
diff --git a/pulsar-broker/src/test/java/org/apache/pulsar/broker/service/PendingAcksMapTest.java b/pulsar-broker/src/test/java/org/apache/pulsar/broker/service/PendingAcksMapTest.java
index c13babb..59aa302 100644
--- a/pulsar-broker/src/test/java/org/apache/pulsar/broker/service/PendingAcksMapTest.java
+++ b/pulsar-broker/src/test/java/org/apache/pulsar/broker/service/PendingAcksMapTest.java
@@ -25,11 +25,11 @@
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.never;
 import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoMoreInteractions;
 import static org.mockito.Mockito.when;
 import static org.testng.Assert.assertEquals;
 import static org.testng.Assert.assertFalse;
 import static org.testng.Assert.assertTrue;
-import it.unimi.dsi.fastutil.ints.IntIntPair;
 import java.util.ArrayList;
 import java.util.List;
 import org.testng.annotations.Test;
@@ -295,41 +295,178 @@
     }
 
     @Test
-    public void removeAndGet_RemovesAndReturnsEntry() {
+    public void removeAndGetRemainingUnacked_RemovesAndReturnsEntry() {
         Consumer consumer = createMockConsumer("consumer1");
         PendingAcksMap pendingAcksMap = new PendingAcksMap(consumer, () -> null, () -> null);
         pendingAcksMap.addPendingAckIfAllowed(1L, 1L, 5, 123);
 
-        IntIntPair result = pendingAcksMap.removeAndGet(1L, 1L);
+        int result = pendingAcksMap.removeAndGetRemainingUnacked(1L, 1L);
 
-        assertTrue(result != null);
-        assertEquals(result.leftInt(), 5);
-        assertEquals(result.rightInt(), 123);
+        assertEquals(result, 5);
         assertFalse(pendingAcksMap.contains(1L, 1L));
         assertEquals(pendingAcksMap.size(), 0);
     }
 
     @Test
-    public void removeAndGet_ReturnsNullWhenNotFound() {
+    public void removeAndGetRemainingUnacked_ReturnsNotFoundWhenNotFound() {
         Consumer consumer = createMockConsumer("consumer1");
         PendingAcksMap pendingAcksMap = new PendingAcksMap(consumer, () -> null, () -> null);
 
-        IntIntPair result = pendingAcksMap.removeAndGet(1L, 1L);
+        int result = pendingAcksMap.removeAndGetRemainingUnacked(1L, 1L);
 
-        assertTrue(result == null);
+        assertEquals(result, PendingAcksMap.PENDING_ACK_NOT_FOUND);
         assertEquals(pendingAcksMap.size(), 0);
     }
 
     @Test
-    public void removeAndGet_InvokesRemoveHandler() {
+    public void removeAndGetRemainingUnacked_InvokesRemoveHandler() {
         Consumer consumer = createMockConsumer("consumer1");
         PendingAcksMap.PendingAcksRemoveHandler removeHandler = mock(PendingAcksMap.PendingAcksRemoveHandler.class);
         PendingAcksMap pendingAcksMap = new PendingAcksMap(consumer, () -> null, () -> removeHandler);
         pendingAcksMap.addPendingAckIfAllowed(1L, 1L, 5, 123);
 
-        pendingAcksMap.removeAndGet(1L, 1L);
+        pendingAcksMap.removeAndGetRemainingUnacked(1L, 1L);
 
         verify(removeHandler).handleRemoving(consumer, 1L, 1L, 123, false);
     }
 
+    @Test
+    public void getRemainingUnacked_ReturnsStoredCount() {
+        Consumer consumer = createMockConsumer("consumer1");
+        PendingAcksMap pendingAcksMap = new PendingAcksMap(consumer, () -> null, () -> null);
+        pendingAcksMap.addPendingAckIfAllowed(1L, 1L, 0, -1);
+        pendingAcksMap.addPendingAckIfAllowed(1L, 2L, Integer.MAX_VALUE, Integer.MIN_VALUE);
+
+        assertEquals(pendingAcksMap.getRemainingUnacked(1L, 1L), 0);
+        assertEquals(pendingAcksMap.getRemainingUnacked(1L, 2L), Integer.MAX_VALUE);
+        assertEquals(pendingAcksMap.getRemainingUnacked(1L, 3L), PendingAcksMap.PENDING_ACK_NOT_FOUND);
+    }
+
+    @Test
+    public void removeAndGetRemainingUnacked_RemovesAndInvokesRemoveHandler() {
+        Consumer consumer = createMockConsumer("consumer1");
+        PendingAcksMap.PendingAcksRemoveHandler removeHandler = mock(PendingAcksMap.PendingAcksRemoveHandler.class);
+        PendingAcksMap pendingAcksMap = new PendingAcksMap(consumer, () -> null, () -> removeHandler);
+        pendingAcksMap.addPendingAckIfAllowed(1L, 1L, 0, Integer.MIN_VALUE);
+        pendingAcksMap.addPendingAckIfAllowed(1L, 2L, Integer.MAX_VALUE, -1);
+
+        assertEquals(pendingAcksMap.removeAndGetRemainingUnacked(1L, 1L), 0);
+        assertEquals(pendingAcksMap.removeAndGetRemainingUnacked(1L, 2L), Integer.MAX_VALUE);
+        assertEquals(pendingAcksMap.removeAndGetRemainingUnacked(1L, 3L), PendingAcksMap.PENDING_ACK_NOT_FOUND);
+
+        verify(removeHandler).handleRemoving(consumer, 1L, 1L, Integer.MIN_VALUE, false);
+        verify(removeHandler).handleRemoving(consumer, 1L, 2L, -1, false);
+        verifyNoMoreInteractions(removeHandler);
+        assertEquals(pendingAcksMap.size(), 0);
+    }
+
+    @Test
+    public void packedPendingAckFields_RoundTripThroughPublicAccessors() {
+        Consumer consumer = createMockConsumer("consumer1");
+        PendingAcksMap pendingAcksMap = new PendingAcksMap(consumer, () -> null, () -> null);
+        int[][] values = new int[][] {
+                {1, 123},
+                {0, 0},
+                {Integer.MAX_VALUE, Integer.MIN_VALUE},
+                {2, Integer.MAX_VALUE},
+                {42, -123456789}
+        };
+
+        for (int i = 0; i < values.length; i++) {
+            pendingAcksMap.addPendingAckIfAllowed(10L + i, 100L + i, values[i][0], values[i][1]);
+        }
+
+        List<int[]> forEachValues = new ArrayList<>();
+        pendingAcksMap.forEach((ledgerId, entryId, remainingUnacked, stickyKeyHash) ->
+                forEachValues.add(new int[] {
+                        (int) ledgerId, (int) entryId, remainingUnacked, stickyKeyHash
+                }));
+        assertEquals(forEachValues.size(), values.length);
+        for (int i = 0; i < values.length; i++) {
+            assertEquals(pendingAcksMap.getRemainingUnacked(10L + i, 100L + i), values[i][0]);
+            assertEquals(forEachValues.get(i), new int[] {10 + i, 100 + i, values[i][0], values[i][1]});
+        }
+
+        for (int i = 0; i < values.length; i++) {
+            assertEquals(pendingAcksMap.removeAndGetRemainingUnacked(10L + i, 100L + i), values[i][0]);
+        }
+        assertEquals(pendingAcksMap.size(), 0);
+    }
+
+    @Test
+    public void updateRemainingUnacked_PreservesStickyKeyHash() {
+        Consumer consumer = createMockConsumer("consumer1");
+        PendingAcksMap pendingAcksMap = new PendingAcksMap(consumer, () -> null, () -> null);
+        pendingAcksMap.addPendingAckIfAllowed(1L, 1L, 10, Integer.MIN_VALUE);
+
+        assertTrue(pendingAcksMap.updateRemainingUnacked(1L, 1L, 4));
+
+        assertOnlyPendingAck(pendingAcksMap, 1L, 1L, 6, Integer.MIN_VALUE);
+    }
+
+    @Test
+    public void updateRemainingUnacked_AllowsZeroRemainingUnacked() {
+        Consumer consumer = createMockConsumer("consumer1");
+        PendingAcksMap pendingAcksMap = new PendingAcksMap(consumer, () -> null, () -> null);
+        pendingAcksMap.addPendingAckIfAllowed(1L, 1L, 1, -1);
+
+        assertTrue(pendingAcksMap.updateRemainingUnacked(1L, 1L, 1));
+
+        assertEquals(pendingAcksMap.getRemainingUnacked(1L, 1L), 0);
+        assertOnlyPendingAck(pendingAcksMap, 1L, 1L, 0, -1);
+        assertEquals(pendingAcksMap.size(), 1);
+    }
+
+    @Test
+    public void updateRemainingUnacked_DoesNotStoreNegativeRemainingUnacked() {
+        Consumer consumer = createMockConsumer("consumer1");
+        PendingAcksMap pendingAcksMap = new PendingAcksMap(consumer, () -> null, () -> null);
+        pendingAcksMap.addPendingAckIfAllowed(1L, 1L, 1, 123);
+
+        assertFalse(pendingAcksMap.updateRemainingUnacked(1L, 1L, 2));
+
+        assertOnlyPendingAck(pendingAcksMap, 1L, 1L, 1, 123);
+    }
+
+    @Test
+    public void removeAllUpTo_PreservesPackedFieldsInCallback() {
+        Consumer consumer = createMockConsumer("consumer1");
+        PendingAcksMap pendingAcksMap = new PendingAcksMap(consumer, () -> null, () -> null);
+        pendingAcksMap.addPendingAckIfAllowed(1L, 1L, Integer.MAX_VALUE, Integer.MIN_VALUE);
+        pendingAcksMap.addPendingAckIfAllowed(1L, 2L, 1, -1);
+        pendingAcksMap.addPendingAckIfAllowed(2L, 1L, 2, Integer.MAX_VALUE);
+
+        List<int[]> callbackInvocations = new ArrayList<>();
+        pendingAcksMap.removeAllUpTo(1L, 2L, (ledgerId, entryId, remainingUnacked, stickyKeyHash) ->
+                callbackInvocations.add(new int[] {
+                        (int) ledgerId, (int) entryId, remainingUnacked, stickyKeyHash
+                }));
+
+        assertEquals(callbackInvocations.size(), 2);
+        assertEquals(callbackInvocations.get(0), new int[] {1, 1, Integer.MAX_VALUE, Integer.MIN_VALUE});
+        assertEquals(callbackInvocations.get(1), new int[] {1, 2, 1, -1});
+        assertTrue(pendingAcksMap.contains(2L, 1L));
+    }
+
+    @Test
+    public void removeWithValue_MatchesSignedPackedFields() {
+        Consumer consumer = createMockConsumer("consumer1");
+        PendingAcksMap pendingAcksMap = new PendingAcksMap(consumer, () -> null, () -> null);
+        pendingAcksMap.addPendingAckIfAllowed(1L, 1L, Integer.MAX_VALUE, -1);
+
+        assertFalse(pendingAcksMap.remove(1L, 1L, Integer.MAX_VALUE, 1));
+        assertTrue(pendingAcksMap.remove(1L, 1L, Integer.MAX_VALUE, -1));
+        assertFalse(pendingAcksMap.contains(1L, 1L));
+    }
+
+    private static void assertOnlyPendingAck(PendingAcksMap pendingAcksMap, long ledgerId, long entryId,
+                                             int remainingUnacked, int stickyKeyHash) {
+        List<long[]> pendingAcks = new ArrayList<>();
+        pendingAcksMap.forEach((actualLedgerId, actualEntryId, actualRemainingUnacked, actualStickyKeyHash) ->
+                pendingAcks.add(new long[] {
+                        actualLedgerId, actualEntryId, actualRemainingUnacked, actualStickyKeyHash
+                }));
+        assertEquals(pendingAcks.size(), 1);
+        assertEquals(pendingAcks.get(0), new long[] {ledgerId, entryId, remainingUnacked, stickyKeyHash});
+    }
 }