This is an automated email from the ASF dual-hosted git repository.
hangc0276 pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/bookkeeper.git
The following commit(s) were added to refs/heads/master by this push:
new 4a98f1806c Fix WriteSet use-after-recycle in sequence reads (#4788)
4a98f1806c is described below
commit 4a98f1806c63ec74d96fd65856415eedf07af6ba
Author: void-ptr974 <[email protected]>
AuthorDate: Wed May 27 02:51:43 2026 +0800
Fix WriteSet use-after-recycle in sequence reads (#4788)
Sequence read completion recycled the WriteSet before registering slow
bookies.
When a speculative read completed the entry, the subclass could still
access the
recycled WriteSet. Late error callbacks could also enter reattempt logic
and call
indexOf() after the request had already completed.
Snapshot slow bookie addresses before delegating to the shared complete
path, and
ignore late errors after sequence read requests have completed. Apply the
same
guard to ReadLAC sequence reads, where orderedEnsemble is also recycled on
completion.
Add regression coverage for normal sequence reads and ReadLAC covering
both
completion and late-error paths.
---
.../apache/bookkeeper/client/PendingReadOp.java | 34 +-
.../client/ReadLastConfirmedAndEntryOp.java | 30 +-
.../client/TestSequenceReadWriteSetRecycle.java | 382 +++++++++++++++++++++
3 files changed, 432 insertions(+), 14 deletions(-)
diff --git
a/bookkeeper-server/src/main/java/org/apache/bookkeeper/client/PendingReadOp.java
b/bookkeeper-server/src/main/java/org/apache/bookkeeper/client/PendingReadOp.java
index cc37ca15a6..10139e999c 100644
---
a/bookkeeper-server/src/main/java/org/apache/bookkeeper/client/PendingReadOp.java
+++
b/bookkeeper-server/src/main/java/org/apache/bookkeeper/client/PendingReadOp.java
@@ -388,6 +388,12 @@ class PendingReadOp extends ReadOpBase implements
ReadEntryCallback {
@Override
synchronized void logErrorAndReattemptRead(int bookieIndex, BookieId
host, String errMsg, int rc) {
+ // A late error response from a bookie whose request was already
superseded by
+ // a faster (e.g. speculative) read must not flow through the
reattempt logic:
+ // writeSet has been recycled at the moment this entry
transitioned to complete.
+ if (isComplete()) {
+ return;
+ }
super.logErrorAndReattemptRead(bookieIndex, host, errMsg, rc);
int replica = writeSet.indexOf(bookieIndex);
@@ -415,15 +421,29 @@ class PendingReadOp extends ReadOpBase implements
ReadEntryCallback {
@Override
boolean complete(int bookieIndex, BookieId host, ByteBuf buffer) {
+ if (isComplete()) {
+ return false;
+ }
+ // Common case: the very first replica responded successfully; no
+ // speculative retry happened, so there are no slow bookies to
mark.
+ // Skip the snapshot allocation entirely.
+ final int numReplicasTried = getNextReplicaIndexToReadFrom();
+ if (numReplicasTried <= 1) {
+ return super.complete(bookieIndex, host, buffer);
+ }
+ // Speculative retry happened: snapshot the addresses of the
replicas tried
+ // before this one BEFORE calling super.complete(), which recycles
writeSet
+ // (see issue #4680). The WriteSet keeps its normal pooled
lifecycle.
+ final BookieId[] slowBookies = new BookieId[numReplicasTried - 1];
+ for (int i = 0; i < slowBookies.length; i++) {
+ slowBookies[i] = ensemble.get(writeSet.get(i));
+ }
+
boolean completed = super.complete(bookieIndex, host, buffer);
if (completed) {
- int numReplicasTried = getNextReplicaIndexToReadFrom();
- // Check if any speculative reads were issued and mark any
slow bookies before
- // the first successful speculative read as "slow"
- for (int i = 0; i < numReplicasTried - 1; i++) {
- int slowBookieIndex = writeSet.get(i);
- BookieId slowBookieSocketAddress =
ensemble.get(slowBookieIndex);
-
clientCtx.getPlacementPolicy().registerSlowBookie(slowBookieSocketAddress, eId);
+ // Mark replicas tried before the first successful response as
"slow".
+ for (BookieId slowBookie : slowBookies) {
+
clientCtx.getPlacementPolicy().registerSlowBookie(slowBookie, eId);
}
}
return completed;
diff --git
a/bookkeeper-server/src/main/java/org/apache/bookkeeper/client/ReadLastConfirmedAndEntryOp.java
b/bookkeeper-server/src/main/java/org/apache/bookkeeper/client/ReadLastConfirmedAndEntryOp.java
index ed3b90c20c..99a01a8078 100644
---
a/bookkeeper-server/src/main/java/org/apache/bookkeeper/client/ReadLastConfirmedAndEntryOp.java
+++
b/bookkeeper-server/src/main/java/org/apache/bookkeeper/client/ReadLastConfirmedAndEntryOp.java
@@ -189,6 +189,12 @@ class ReadLastConfirmedAndEntryOp implements
BookkeeperInternalCallbacks.ReadEnt
* read result code
*/
synchronized void logErrorAndReattemptRead(int bookieIndex, BookieId
host, String errMsg, int rc) {
+ // Late error response after this request was already completed
(e.g. by a
+ // faster bookie's success): writeSet/orderedEnsemble have been
recycled, so
+ // do not enter logic that would access them.
+ if (isComplete()) {
+ return;
+ }
translateAndSetFirstError(rc);
if (BKException.Code.NoSuchEntryException == rc ||
BKException.Code.NoSuchLedgerExistsException == rc) {
@@ -401,6 +407,9 @@ class ReadLastConfirmedAndEntryOp implements
BookkeeperInternalCallbacks.ReadEnt
@Override
synchronized void logErrorAndReattemptRead(int bookieIndex, BookieId
host, String errMsg, int rc) {
+ if (isComplete()) {
+ return;
+ }
super.logErrorAndReattemptRead(bookieIndex, host, errMsg, rc);
int replica = getReplicaIndex(bookieIndex);
@@ -426,15 +435,22 @@ class ReadLastConfirmedAndEntryOp implements
BookkeeperInternalCallbacks.ReadEnt
@Override
boolean complete(int bookieIndex, BookieId host, ByteBuf buffer, long
entryId) {
+ if (isComplete()) {
+ return false;
+ }
+ // Snapshot the slow-bookie addresses BEFORE super.complete()
recycles
+ // orderedEnsemble (see issue #4680). The loop runs for every
replica tried
+ // (including the successful one), so numReplicasTried is at least
1 here.
+ final int numReplicasTried = getNextReplicaIndexToReadFrom();
+ final BookieId[] slowBookies = new BookieId[numReplicasTried];
+ for (int i = 0; i < slowBookies.length; i++) {
+ slowBookies[i] = ensemble.get(orderedEnsemble.get(i));
+ }
+
boolean completed = super.complete(bookieIndex, host, buffer,
entryId);
if (completed) {
- int numReplicasTried = getNextReplicaIndexToReadFrom();
- // Check if any speculative reads were issued and mark any
bookies before the
- // first speculative read as slow
- for (int i = 0; i < numReplicasTried; i++) {
- int slowBookieIndex = orderedEnsemble.get(i);
- BookieId slowBookieSocketAddress =
ensemble.get(slowBookieIndex);
-
clientCtx.getPlacementPolicy().registerSlowBookie(slowBookieSocketAddress,
entryId);
+ for (BookieId slowBookie : slowBookies) {
+
clientCtx.getPlacementPolicy().registerSlowBookie(slowBookie, entryId);
}
}
return completed;
diff --git
a/bookkeeper-server/src/test/java/org/apache/bookkeeper/client/TestSequenceReadWriteSetRecycle.java
b/bookkeeper-server/src/test/java/org/apache/bookkeeper/client/TestSequenceReadWriteSetRecycle.java
new file mode 100644
index 0000000000..2ff3801529
--- /dev/null
+++
b/bookkeeper-server/src/test/java/org/apache/bookkeeper/client/TestSequenceReadWriteSetRecycle.java
@@ -0,0 +1,382 @@
+/*
+ *
+ * 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.bookkeeper.client;
+
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+
+import java.nio.charset.StandardCharsets;
+import java.util.List;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicReference;
+import org.apache.bookkeeper.client.BookKeeper.DigestType;
+import org.apache.bookkeeper.conf.ClientConfiguration;
+import org.apache.bookkeeper.net.BookieId;
+import org.apache.bookkeeper.test.BookKeeperClusterTestCase;
+import org.apache.bookkeeper.test.TestStatsProvider;
+import org.junit.Test;
+
+/**
+ * Regression test for issue #4680.
+ *
+ * <p>{@code SequenceReadRequest.complete()} delegates to
+ * {@code SingleLedgerEntryRequest.complete()}, which recycles {@code
writeSet},
+ * and then the subclass accesses {@code writeSet.get(i)} to register slow
+ * bookies. Because {@code WriteSetImpl} is pooled via Netty's {@code
Recycler},
+ * this is a use-after-recycle: the same instance can be handed to another
+ * read and resized/repopulated, causing either {@code
IndexOutOfBoundsException}
+ * or, worse, silently registering the wrong bookie as slow.
+ *
+ * <p>We inject a tracking {@link DistributionSchedule.WriteSet} via a custom
+ * placement policy and assert that no method on the {@code WriteSet} is
+ * invoked after {@code recycle()} during a sequence read that retries.
+ */
+public class TestSequenceReadWriteSetRecycle extends BookKeeperClusterTestCase
{
+
+ private static final DigestType DIGEST = DigestType.CRC32;
+ private static final int SPEC_TIMEOUT_MS = 500;
+
+ public TestSequenceReadWriteSetRecycle() {
+ super(4); // matches the issue scenario: ensemble = 4
+ }
+
+ @Test
+ public void completeMustNotAccessWriteSetAfterRecycle() throws Exception {
+ ClientConfiguration conf = new ClientConfiguration()
+ .setMetadataServiceUri(zkUtil.getMetadataServiceUri())
+ .setEnsemblePlacementPolicy(TrackingPlacementPolicy.class)
+ .setReorderReadSequenceEnabled(true)
+ .setSpeculativeReadTimeout(SPEC_TIMEOUT_MS)
+ .setReadTimeout(60);
+
+ try (BookKeeperTestClient bk =
+ new BookKeeperTestClient(conf, new TestStatsProvider())) {
+ // ensemble = 4, writeQuorum = 2, ackQuorum = 2: the scenario from
#4680.
+ LedgerHandle writer = bk.createLedger(4, 2, 2, DIGEST, passwd());
+ for (int i = 0; i < 5; i++) {
+ writer.addEntry(("entry-" + i).getBytes());
+ }
+ long ledgerId = writer.getId();
+ writer.close();
+
+ try (LedgerHandle reader = bk.openLedger(ledgerId, DIGEST,
passwd())) {
+ List<BookieId> ensemble =
reader.getLedgerMetadata().getAllEnsembles().get(0L);
+
+ // For entry 1 the round-robin write set is [1, 2]. Sleeping
ensemble[1]
+ // forces SequenceReadRequest to speculatively retry to
ensemble[2],
+ // which exercises the slow-bookie registration loop in
complete().
+ BookieId firstReplica = ensemble.get(1);
+ CountDownLatch sleepLatch = new CountDownLatch(1);
+ sleepBookie(firstReplica, sleepLatch);
+ try {
+ assertTrue("read must succeed via speculative retry",
+ reader.readEntries(1, 1).hasMoreElements());
+ sleepLatch.countDown();
+ Thread.sleep(1000);
+ } finally {
+ sleepLatch.countDown();
+ }
+
+ assertNoViolation(bk);
+ }
+ }
+ }
+
+ /**
+ * After {@code entry.complete()} succeeds (via a speculative retry) and
+ * recycles {@code writeSet}, a late error response from the originally
+ * slow bookie still flows through
+ * {@code SequenceReadRequest.logErrorAndReattemptRead}, which calls
+ * {@code writeSet.indexOf(bookieIndex)}, another use-after-recycle.
+ *
+ * <p>Reproduce by giving the slow bookie a read timeout shorter than the
+ * sleep window so the in-flight request fails after the speculative read
+ * has already completed the entry.
+ */
+ @Test
+ public void logErrorAndReattemptReadMustNotAccessWriteSetAfterRecycle()
throws Exception {
+ ClientConfiguration conf = new ClientConfiguration()
+ .setMetadataServiceUri(zkUtil.getMetadataServiceUri())
+ .setEnsemblePlacementPolicy(TrackingPlacementPolicy.class)
+ .setReorderReadSequenceEnabled(true)
+ .setSpeculativeReadTimeout(SPEC_TIMEOUT_MS)
+ .setReadEntryTimeout(1); // 1s, so the slow bookie's request
times out as an error
+
+ try (BookKeeperTestClient bk =
+ new BookKeeperTestClient(conf, new TestStatsProvider())) {
+ LedgerHandle writer = bk.createLedger(4, 2, 2, DIGEST, passwd());
+ for (int i = 0; i < 5; i++) {
+ writer.addEntry(("entry-" + i).getBytes());
+ }
+ long ledgerId = writer.getId();
+ writer.close();
+
+ try (LedgerHandle reader = bk.openLedger(ledgerId, DIGEST,
passwd())) {
+ List<BookieId> ensemble =
reader.getLedgerMetadata().getAllEnsembles().get(0L);
+
+ // Sleep ensemble[1] for longer than readEntryTimeout so its
request
+ // returns a timeout error after the speculative retry has
completed.
+ BookieId slowBookie = ensemble.get(1);
+ CountDownLatch sleepLatch = new CountDownLatch(1);
+ sleepBookie(slowBookie, sleepLatch);
+ try {
+ // entry 1: writeSet=[1,2]. Bookie 1 sleeps; spec retries
to bookie 2;
+ // bookie 2 responds OK, then complete() recycles writeSet.
+ assertTrue("read must succeed via speculative retry",
+ reader.readEntries(1, 1).hasMoreElements());
+
+ // Wait long enough for the slow bookie's read to time out
and the
+ // late error response to flow through
logErrorAndReattemptRead.
+ Thread.sleep(2500);
+
+ assertNoViolation(bk);
+ } finally {
+ sleepLatch.countDown();
+ }
+ }
+ }
+ }
+
+ @Test
+ public void readLacCompleteMustNotAccessOrderedEnsembleAfterRecycle()
throws Exception {
+ ClientConfiguration conf = new ClientConfiguration()
+ .setMetadataServiceUri(zkUtil.getMetadataServiceUri())
+ .setEnsemblePlacementPolicy(TrackingPlacementPolicy.class)
+ .setReorderReadSequenceEnabled(true)
+ .setFirstSpeculativeReadLACTimeout(SPEC_TIMEOUT_MS)
+ .setReadTimeout(60);
+
+ try (BookKeeperTestClient bk =
+ new BookKeeperTestClient(conf, new TestStatsProvider())) {
+ LedgerHandle writer = bk.createLedger(4, 2, 2, DIGEST, passwd());
+ writer.addEntry("entry-0".getBytes());
+ long ledgerId = writer.getId();
+
+ try (LedgerHandle reader = bk.openLedgerNoRecovery(ledgerId,
DIGEST, passwd())) {
+ writer.addEntry("entry-1".getBytes());
+ writer.addEntry("entry-2".getBytes());
+
+ List<BookieId> ensemble =
reader.getLedgerMetadata().getAllEnsembles().get(0L);
+ BookieId firstReplica = ensemble.get(1);
+ CountDownLatch sleepLatch = new CountDownLatch(1);
+ sleepBookie(firstReplica, sleepLatch);
+ try {
+ ReadLacResult result = new ReadLacResult();
+ reader.asyncReadLastConfirmedAndEntry(1, 60000, false,
result, null);
+ result.await();
+ assertTrue("readLAC must succeed via speculative retry",
+ result.rc == BKException.Code.OK && result.entry
!= null);
+ sleepLatch.countDown();
+ Thread.sleep(1000);
+ } finally {
+ sleepLatch.countDown();
+ }
+
+ assertNoViolation(bk);
+ } finally {
+ writer.close();
+ }
+ }
+ }
+
+ @Test
+ public void readLacLateErrorMustNotAccessOrderedEnsembleAfterRecycle()
throws Exception {
+ ClientConfiguration conf = new ClientConfiguration()
+ .setMetadataServiceUri(zkUtil.getMetadataServiceUri())
+ .setEnsemblePlacementPolicy(TrackingPlacementPolicy.class)
+ .setReorderReadSequenceEnabled(true)
+ .setFirstSpeculativeReadLACTimeout(SPEC_TIMEOUT_MS)
+ .setReadEntryTimeout(1);
+
+ try (BookKeeperTestClient bk =
+ new BookKeeperTestClient(conf, new TestStatsProvider())) {
+ LedgerHandle writer = bk.createLedger(4, 2, 2, DIGEST, passwd());
+ writer.addEntry("entry-0".getBytes());
+ long ledgerId = writer.getId();
+
+ try (LedgerHandle reader = bk.openLedgerNoRecovery(ledgerId,
DIGEST, passwd())) {
+ writer.addEntry("entry-1".getBytes());
+ writer.addEntry("entry-2".getBytes());
+
+ List<BookieId> ensemble =
reader.getLedgerMetadata().getAllEnsembles().get(0L);
+ BookieId slowBookie = ensemble.get(1);
+ CountDownLatch sleepLatch = new CountDownLatch(1);
+ sleepBookie(slowBookie, sleepLatch);
+ try {
+ ReadLacResult result = new ReadLacResult();
+ reader.asyncReadLastConfirmedAndEntry(1, 60000, false,
result, null);
+ result.await();
+ assertTrue("readLAC must succeed via speculative retry",
+ result.rc == BKException.Code.OK && result.entry
!= null);
+
+ Thread.sleep(2500);
+
+ assertNoViolation(bk);
+ } finally {
+ sleepLatch.countDown();
+ }
+ } finally {
+ writer.close();
+ }
+ }
+ }
+
+ private static void assertNoViolation(BookKeeperTestClient bk) {
+ TrackingPlacementPolicy policy = (TrackingPlacementPolicy)
bk.getPlacementPolicy();
+ String violation = policy.violation.get();
+ assertNull("WriteSet method '" + violation + "' was invoked after
recycle()", violation);
+ }
+
+ private static byte[] passwd() {
+ return "pwd".getBytes(StandardCharsets.UTF_8);
+ }
+
+ static final class ReadLacResult implements
AsyncCallback.ReadLastConfirmedAndEntryCallback {
+ private final CountDownLatch doneLatch = new CountDownLatch(1);
+ private int rc = BKException.Code.UnexpectedConditionException;
+ private LedgerEntry entry;
+
+ @Override
+ public void readLastConfirmedAndEntryComplete(int rc, long
lastConfirmed, LedgerEntry entry, Object ctx) {
+ this.rc = rc;
+ this.entry = entry;
+ doneLatch.countDown();
+ }
+
+ void await() throws InterruptedException {
+ assertTrue("readLAC callback did not complete",
doneLatch.await(10, TimeUnit.SECONDS));
+ }
+ }
+
+ /**
+ * Placement policy whose {@code reorderReadSequence} wraps the result
with a
+ * {@link RecycleTrackingWriteSet}. This is the only injection point for a
+ * custom {@code WriteSet} on the read path that does not require changes
to
+ * production code.
+ */
+ public static class TrackingPlacementPolicy extends
RackawareEnsemblePlacementPolicy {
+ private final AtomicReference<String> violation = new
AtomicReference<>();
+
+ @Override
+ public DistributionSchedule.WriteSet reorderReadSequence(
+ List<BookieId> ensemble,
+ BookiesHealthInfo bookiesHealthInfo,
+ DistributionSchedule.WriteSet writeSet) {
+ DistributionSchedule.WriteSet reordered =
+ super.reorderReadSequence(ensemble, bookiesHealthInfo,
writeSet);
+ return new RecycleTrackingWriteSet(reordered, violation);
+ }
+
+ @Override
+ public DistributionSchedule.WriteSet reorderReadLACSequence(
+ List<BookieId> ensemble,
+ BookiesHealthInfo bookiesHealthInfo,
+ DistributionSchedule.WriteSet writeSet) {
+ DistributionSchedule.WriteSet reordered =
+ super.reorderReadLACSequence(ensemble, bookiesHealthInfo,
writeSet);
+ return new RecycleTrackingWriteSet(reordered, violation);
+ }
+ }
+
+ /**
+ * Wrapper that records the first method invoked after {@code recycle()}.
+ * The wrapper deliberately does <em>not</em> propagate {@code recycle()}
+ * to the delegate: keeping the delegate out of the recycler pool means
+ * any post-recycle calls still observe consistent data, so a caller bug
+ * shows up as a recorded violation rather than a flaky exception.
+ */
+ static final class RecycleTrackingWriteSet implements
DistributionSchedule.WriteSet {
+ private final DistributionSchedule.WriteSet delegate;
+ private final AtomicReference<String> violation;
+ private volatile boolean recycled;
+
+ RecycleTrackingWriteSet(DistributionSchedule.WriteSet delegate,
AtomicReference<String> violation) {
+ this.delegate = delegate;
+ this.violation = violation;
+ }
+
+ private void check(String op) {
+ if (recycled) {
+ violation.compareAndSet(null, op);
+ }
+ }
+
+ @Override
+ public int size() {
+ check("size");
+ return delegate.size();
+ }
+
+ @Override
+ public boolean contains(int i) {
+ check("contains");
+ return delegate.contains(i);
+ }
+
+ @Override
+ public int get(int i) {
+ check("get(" + i + ")");
+ return delegate.get(i);
+ }
+
+ @Override
+ public int set(int i, int idx) {
+ check("set");
+ return delegate.set(i, idx);
+ }
+
+ @Override
+ public int indexOf(int idx) {
+ check("indexOf");
+ return delegate.indexOf(idx);
+ }
+
+ @Override
+ public void sort() {
+ check("sort");
+ delegate.sort();
+ }
+
+ @Override
+ public void addMissingIndices(int max) {
+ check("addMissingIndices");
+ delegate.addMissingIndices(max);
+ }
+
+ @Override
+ public void moveAndShift(int from, int to) {
+ check("moveAndShift");
+ delegate.moveAndShift(from, to);
+ }
+
+ @Override
+ public DistributionSchedule.WriteSet copy() {
+ check("copy");
+ return delegate.copy();
+ }
+
+ @Override public void recycle() {
+ recycled = true;
+ // intentionally not delegating recycle(); see class javadoc.
+ }
+ }
+}