-
Notifications
You must be signed in to change notification settings - Fork 1.6k
Fix idle pool cleaner generation race #2289
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 2 commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -37,7 +37,7 @@ | |
| import java.util.concurrent.ConcurrentLinkedDeque; | ||
| import java.util.concurrent.TimeUnit; | ||
| import java.util.concurrent.atomic.AtomicBoolean; | ||
| import java.util.concurrent.atomic.AtomicIntegerFieldUpdater; | ||
| import java.util.concurrent.atomic.AtomicLongFieldUpdater; | ||
| import java.util.function.Predicate; | ||
|
|
||
| import static org.asynchttpclient.util.DateUtils.unpreciseMillisTime; | ||
|
|
@@ -52,8 +52,8 @@ public final class DefaultChannelPool implements ChannelPool { | |
| private static final AttributeKey<IdleState> IDLE_STATE_ATTRIBUTE_KEY = AttributeKey.valueOf("channelIdleState"); | ||
|
|
||
| // The partition deques hold the bare Channel; per-checkout idle state (start timestamp + the | ||
| // owned/tombstone CAS flag) lives on the channel's IDLE_STATE_ATTRIBUTE_KEY attribute, which is | ||
| // allocated once per physical connection and reused across every pool cycle (no per-offer holder). | ||
| // generation/ownership CAS state) lives on the channel's IDLE_STATE_ATTRIBUTE_KEY attribute, | ||
| // which is allocated once per physical connection and reused across every pool cycle. | ||
| private final ConcurrentHashMap<Object, ConcurrentLinkedDeque<Channel>> partitions = new ConcurrentHashMap<>(); | ||
| private final AtomicBoolean isClosed = new AtomicBoolean(false); | ||
| private final Timer nettyTimer; | ||
|
|
@@ -132,16 +132,18 @@ private boolean offer0(Channel channel, Object partitionKey, long now) { | |
| if (partition == null) { | ||
| partition = partitions.computeIfAbsent(partitionKey, pk -> new ConcurrentLinkedDeque<>()); | ||
| } | ||
| // Reuse the channel's IdleState instead of allocating a holder per offer; reset() stamps the | ||
| // idle start and clears the owned flag (must happen-before offerFirst publishes the channel, | ||
| // so any thread that observes it in the deque also observes owned == 0). | ||
| // Reuse the channel's IdleState instead of allocating a holder per offer; reset() transfers an | ||
| // owned generation to a new leasable generation before offerFirst publishes the channel. | ||
| Attribute<IdleState> idleStateAttribute = channel.attr(IDLE_STATE_ATTRIBUTE_KEY); | ||
| IdleState idleState = idleStateAttribute.get(); | ||
| if (idleState == null) { | ||
| idleState = new IdleState(); | ||
| idleStateAttribute.set(idleState); | ||
| IdleState newIdleState = new IdleState(); | ||
| IdleState existingIdleState = idleStateAttribute.setIfAbsent(newIdleState); | ||
| idleState = existingIdleState == null ? newIdleState : existingIdleState; | ||
| } | ||
| if (!idleState.reset(now)) { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Heads up that false here means the caller closes the channel. ChannelManager line 426 calls closeChannel on a false return, and that is really what false has always meant here, NoopChannelPool returns false every time exactly so nothing gets pooled. So a duplicate offer now kills a live pooled channel. I tried it: second offer returns false, the close leaves it inactive but still in the deque, and the next poll on that partition comes back null. Nothing in tree can actually hit this since setDiscard runs first, but it does turn a harmless no op into quietly dropping a good keep alive. Might be simpler to treat a duplicate as a no op, debug log and return true.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Agreed. I now treat a duplicate as an accepted no-op and return true, so ChannelManager will not close a live pooled channel. The generation and original partition remain unchanged, and the test verifies that the channel stays active and leasable. |
||
| return false; | ||
| } | ||
| idleState.reset(now); | ||
| return partition.offerFirst(channel); | ||
| } | ||
|
|
||
|
|
@@ -293,52 +295,68 @@ private static final class ChannelCreation { | |
| * {@link #IDLE_STATE_ATTRIBUTE_KEY} attribute, then reused across every pool checkout so no holder | ||
| * is allocated per offer. | ||
| * | ||
| * <p>{@code owned} is a single CAS flag with two roles, both meaning "this idle entry is claimed, | ||
| * do not lease it": a successful {@code poll()} lease, or a {@code removeAll()} tombstone. The pool | ||
| * upholds the invariant that a channel sitting in a partition deque has {@code owned == 0} unless it | ||
| * was tombstoned, because {@link #reset(long)} clears the flag before {@code offerFirst} publishes | ||
| * the channel and {@code poll()} unlinks a channel from the deque before claiming it. {@code start} | ||
| * doubles as a generation token: it changes on every offer, letting the cleaner detect a channel | ||
| * that was leased and re-offered between its expiry check and its claim. | ||
| * <p>The low bit of {@code state} is the ownership flag; the remaining bits advance by two on every | ||
| * offer because bit zero is reserved. A successful {@code poll()} lease and a {@code removeAll()} | ||
| * tombstone both claim the current generation. The cleaner can therefore claim only the exact | ||
| * generation whose expiry it evaluated, without temporarily claiming a newer entry while checking | ||
| * whether {@code start} changed. | ||
| * | ||
| * <p>A generation can be reset only while owned. New state starts owned, and {@code poll()} claims | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Tiny thing, this says two producers of owned state but there are four. The removeAll tombstone and the cleaner's pre close claim leave it owned too, and offer will reset from either. That part is pre existing and harmless since poll drops inactive channels, it just reads like a complete list. Separately, the guard returns false with nothing logged, so if the invariant ever does break, all you would see is a connection closing for no apparent reason.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Updated the ownership documentation to cover the initial state, poll leases, removeAll tombstones, cleaner pre-close claims, and the reset-in-progress state. A rejected generation transfer now emits a rate-limited debug message, while the public offer is accepted as a no-op instead of causing a close. |
||
| * a generation before returning it, so {@code offer()} transfers ownership instead of performing a | ||
| * non-atomic update from a leasable generation. | ||
| */ | ||
| static final class IdleState { | ||
|
|
||
| private static final AtomicIntegerFieldUpdater<IdleState> OWNED_UPDATER = | ||
| AtomicIntegerFieldUpdater.newUpdater(IdleState.class, "owned"); | ||
| private static final long OWNED_MASK = 1L; | ||
| private static final AtomicLongFieldUpdater<IdleState> STATE_UPDATER = | ||
| AtomicLongFieldUpdater.newUpdater(IdleState.class, "state"); | ||
|
|
||
| private volatile long start; | ||
| @SuppressWarnings("unused") | ||
| private volatile int owned; | ||
| private volatile long state = OWNED_MASK; | ||
|
|
||
| long start() { | ||
| return start; | ||
| } | ||
|
|
||
| long snapshot() { | ||
| return state; | ||
| } | ||
|
|
||
| boolean isOwned() { | ||
| return owned != 0; | ||
| return isOwned(state); | ||
| } | ||
|
|
||
| /** Atomically claim this entry; returns true only for the caller that transitions 0 -> 1. */ | ||
| static boolean isOwned(long stateSnapshot) { | ||
| return (stateSnapshot & OWNED_MASK) != 0; | ||
| } | ||
|
|
||
| /** Atomically claim the current generation. */ | ||
| boolean takeOwnership() { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This overload pair is easy to misread at a call site, because the no argument form silently re reads the field while the one argument form validates a caller supplied snapshot, and both are named takeOwnership. The tryTakeOwnership name suggested in the issue for the snapshot form reads better and makes the failure mode obvious.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Renamed the snapshot form to tryTakeOwnership. The no-argument takeOwnership method now clearly delegates to the conditional snapshot operation. |
||
| return OWNED_UPDATER.getAndSet(this, 1) == 0; | ||
| return tryTakeOwnership(state); | ||
| } | ||
|
|
||
| /** Undo a claim taken via {@link #takeOwnership()} (used only on the cleaner re-offer race). */ | ||
| void releaseOwnership() { | ||
| owned = 0; | ||
| /** Atomically claim {@code stateSnapshot}, provided it is still the current generation. */ | ||
| boolean tryTakeOwnership(long stateSnapshot) { | ||
| return !isOwned(stateSnapshot) | ||
| && STATE_UPDATER.compareAndSet(this, stateSnapshot, stateSnapshot | OWNED_MASK); | ||
| } | ||
|
|
||
| /** Stamp the idle start and mark the channel leasable again. Called on every offer. */ | ||
| void reset(long now) { | ||
| /** Transfer an owned generation to a new leasable generation. Called on every offer. */ | ||
| synchronized boolean reset(long now) { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I do not think the synchronized is buying what it looks like it is buying. If a duplicate offer loses the race it parks here, and when it wakes up it re reads state fresh. So if a poll grabbed ownership in the meantime, the loser sails straight through the isOwned check and stamps a timestamp that is now well out of date, onto a generation that is not its own. Walking through it. reset(1000) publishes gen 2 with start 1000, poll takes gen 2, a second thread calls reset(1000) and parks. First thread offers again, so reset(5000) publishes gen 4 with start 5000, and poll takes gen 4. Now the parked thread wakes, sees gen 4 is owned, passes the check, writes start back to 1000, and moves gen 4 to gen 6 leasable. Idle clock jumps back four seconds, and the entry goes leasable via a thread that never owned it. I did reproduce this. With a plain CAS and no monitor the loser just fails and nothing happens. The lock is what turns that into a quiet wrong answer.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Agreed, and I reproduced this ordering. I removed synchronized and added a reset-in-progress bit. reset now CASes the exact owned snapshot to the resetting state before writing the timestamp, then publishes the next leasable generation. A delayed caller with a stale snapshot fails without retimestamping or transferring the newer generation. |
||
| long stateSnapshot = state; | ||
| if (!isOwned(stateSnapshot)) { | ||
| return false; | ||
| } | ||
| long nextGeneration = (stateSnapshot & ~OWNED_MASK) + 2; | ||
| start = now; | ||
| owned = 0; | ||
| return STATE_UPDATER.compareAndSet(this, stateSnapshot, nextGeneration); | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Small thing, this CAS cannot fail. Only reset takes state out of owned and the monitor serializes those, and tryTakeOwnership needs an unowned snapshot so it can never match. I ran four threads at it for eight seconds and got 58 million resets with zero CAS failures, so the boolean is always true unless the guard above catches it. The lock is not free though. On JDK 25 a take plus reset cycle went from 20.7 ns to 52.9 ns, roughly 22 ns of that being the monitor, which more than eats the fence you saved by dropping the intermediate store. Guard, then start = now, then a plain volatile store of the next generation gets you the same thing for two stores.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Removed the monitor. I kept a meaningful CAS by using it to reserve the reset-in-progress state; unlike the CAS under the monitor, it can fail for a duplicate or stale caller. This avoids the monitor cost while preventing competing writers from changing the timestamp before winning the transfer. |
||
| } | ||
| } | ||
|
|
||
| private final class IdleChannelDetector implements TimerTask { | ||
|
|
||
| private boolean isIdleTimeoutExpired(IdleState idleState, long now) { | ||
| return maxIdleTimeEnabled && now - idleState.start() >= maxIdleTime; | ||
| private boolean isIdleTimeoutExpired(long idleStart, long now) { | ||
| return maxIdleTimeEnabled && now - idleStart >= maxIdleTime; | ||
| } | ||
|
|
||
| @Override | ||
|
|
@@ -410,7 +428,8 @@ private int reapPartition(ConcurrentLinkedDeque<Channel> partition, long now) { | |
| continue; | ||
| } | ||
|
|
||
| if (idleState.isOwned()) { | ||
| long stateSnapshot = idleState.snapshot(); | ||
| if (IdleState.isOwned(stateSnapshot)) { | ||
| // In-deque + owned ==> a removeAll(Channel) tombstone, or a node a concurrent poll() | ||
| // has already leased and unlinked. Either way: unlink, never close — the owner of the | ||
| // claim is responsible for closing it. Unlinking an already-unlinked node through the | ||
|
|
@@ -419,22 +438,17 @@ private int reapPartition(ConcurrentLinkedDeque<Channel> partition, long now) { | |
| continue; | ||
| } | ||
|
|
||
| boolean isIdleTimeoutExpired = isIdleTimeoutExpired(idleState, now); | ||
| long idleStart = idleState.start(); | ||
| boolean isIdleTimeoutExpired = isIdleTimeoutExpired(idleStart, now); | ||
| boolean isRemotelyClosed = !Channels.isChannelActive(channel); | ||
| boolean isTtlExpired = isTtlExpired(channel, now); | ||
| if (!isIdleTimeoutExpired && !isRemotelyClosed && !isTtlExpired) { | ||
| continue; // healthy idle channel, leave it for poll() | ||
| } | ||
|
|
||
| long startSnapshot = idleState.start(); | ||
| // Claim before closing so we never close a channel poll() is leasing concurrently. | ||
| if (!idleState.takeOwnership()) { | ||
| continue; // poll() (or removeAll(Channel)) won the claim; that owner now handles the channel | ||
| } | ||
| if (idleState.start() != startSnapshot) { | ||
| // The channel was leased and re-offered (fresh start) between the expiry check and | ||
| // the claim, so it is leasable again — release it instead of closing it. | ||
| idleState.releaseOwnership(); | ||
| // Claim the generation evaluated above. A lease and re-offer changes the generation, | ||
| // so this CAS cannot claim the fresh entry or interfere with a concurrent poll of it. | ||
| if (!idleState.tryTakeOwnership(stateSnapshot)) { | ||
| continue; | ||
| } | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -31,6 +31,7 @@ | |
| import java.util.Collections; | ||
| import java.util.Map; | ||
| import java.util.Set; | ||
| import java.util.concurrent.CompletableFuture; | ||
| import java.util.concurrent.ConcurrentHashMap; | ||
| import java.util.concurrent.ConcurrentLinkedDeque; | ||
| import java.util.concurrent.ConcurrentLinkedQueue; | ||
|
|
@@ -86,6 +87,22 @@ public void offerThenPollReturnsSameChannelThenEmpty() { | |
| pool.destroy(); | ||
| } | ||
|
|
||
| @Test | ||
| public void duplicateOfferDoesNotResetLeasableGeneration() throws Exception { | ||
| DefaultChannelPool pool = noReaperPool(); | ||
| Channel channel = new EmbeddedChannel(); | ||
|
|
||
| assertTrue(pool.offer(channel, KEY)); | ||
| long generation = idleState(channel).snapshot(); | ||
|
|
||
| assertFalse(pool.offer(channel, KEY), "an already leasable channel must not be offered again"); | ||
| assertEquals(generation, idleState(channel).snapshot()); | ||
| assertEquals(1, partitionSize(pool, KEY)); | ||
| assertSame(channel, pool.poll(KEY)); | ||
|
|
||
| pool.destroy(); | ||
| } | ||
|
|
||
| @Test | ||
| public void reofferingReusesTheSameIdleStateInstance() throws Exception { | ||
| DefaultChannelPool pool = noReaperPool(); | ||
|
|
@@ -245,6 +262,46 @@ public void channelReofferedAfterExpiryIsNotReaped() throws Exception { | |
| pool.destroy(); | ||
| } | ||
|
|
||
| @Test | ||
| public void cleanerDoesNotCloseChannelReofferedAfterExpiryCheck() throws Exception { | ||
| CapturingTimer timer = new CapturingTimer(); | ||
| DefaultChannelPool pool = idlePool(timer, Duration.ofMillis(1)); | ||
| BlockingActiveChannel channel = new BlockingActiveChannel(); | ||
|
|
||
| pool.offer(channel, KEY); | ||
| idleState(channel).reset(0); | ||
| channel.blockNextActiveCheck(); | ||
|
|
||
| CompletableFuture<Void> cleanerRun = CompletableFuture.runAsync(timer::fire); | ||
| try { | ||
| assertTrue(channel.awaitActiveCheck(), "cleaner must reach the active-channel check"); | ||
| assertSame(channel, pool.poll(KEY)); | ||
| assertTrue(pool.offer(channel, KEY)); | ||
| } finally { | ||
| channel.resumeActiveCheck(); | ||
| } | ||
| cleanerRun.get(5, TimeUnit.SECONDS); | ||
|
|
||
| assertTrue(channel.isActive(), "cleaner must not close a freshly re-offered channel"); | ||
| assertEquals(1, partitionSize(pool, KEY), "cleaner must not unlink the fresh generation"); | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I do not think this one can fail. There is only one node in the deque, poll unlinks it, and the re offer makes a fresh node the cleaner's cursor never reaches, so the iterator remove is a no op on something already unlinked. I checked by making the tryTakeOwnership failure branch call it.remove as well, and the test still passed. If you want it to actually bite, put a second untouched channel ahead of the blocked one so the iterator is still on a live node when it resumes.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Added a second untouched channel ahead of the blocked channel and switched this test pool to FIFO, so the cleaner cursor traverses a live neighbor while the blocked channel is leased and re-offered. The test now verifies that both entries remain and are leasable in the expected order. |
||
| assertSame(channel, pool.poll(KEY), "freshly re-offered channel must remain leasable"); | ||
|
|
||
| pool.destroy(); | ||
| } | ||
|
|
||
| @Test | ||
| public void idleGenerationChangesWhenTimestampIsReused() { | ||
| DefaultChannelPool.IdleState idleState = new DefaultChannelPool.IdleState(); | ||
| assertTrue(idleState.reset(1)); | ||
| long firstGeneration = idleState.snapshot(); | ||
|
|
||
| assertTrue(idleState.takeOwnership()); | ||
| assertTrue(idleState.reset(1)); | ||
|
|
||
| assertFalse(idleState.tryTakeOwnership(firstGeneration)); | ||
| assertFalse(idleState.isOwned()); | ||
| } | ||
|
|
||
| // ---- reap pass unlinks many channels in a single tick (O(n) iterator-remove) ---- | ||
|
|
||
| @Test | ||
|
|
@@ -415,11 +472,15 @@ public void idleCountPerHostCountsOnlyLeasableChannels() { | |
|
|
||
| @Test | ||
| public void concurrentOfferPollRemoveAllIsConsistent() throws Exception { | ||
| // Real timer so the cleaner reaps tombstones concurrently with offer/poll/removeAll. | ||
| // TTL only (idle disabled) so the cleaner never closes our shared EmbeddedChannels cross-thread. | ||
| // Real timer so the cleaner claims expired entries and reaps tombstones concurrently with | ||
| // offer/poll/removeAll. | ||
| HashedWheelTimer timer = new HashedWheelTimer(10, TimeUnit.MILLISECONDS); | ||
| DefaultChannelPool pool = new DefaultChannelPool(Duration.ZERO, Duration.ofHours(1), | ||
| DefaultChannelPool pool = new DefaultChannelPool(Duration.ofMillis(20), Duration.ofHours(1), | ||
| PoolLeaseStrategy.LIFO, timer, Duration.ofMillis(10)); | ||
| CountDownLatch cleanerClosedChannel = new CountDownLatch(1); | ||
| Channel expiringChannel = new EmbeddedChannel(); | ||
| expiringChannel.closeFuture().addListener(future -> cleanerClosedChannel.countDown()); | ||
| assertTrue(pool.offer(expiringChannel, "expiring-partition")); | ||
|
|
||
| final int channelCount = 16; | ||
| Channel[] channels = new Channel[channelCount]; | ||
|
|
@@ -473,6 +534,8 @@ public void concurrentOfferPollRemoveAllIsConsistent() throws Exception { | |
| if (failure.get() != null) { | ||
| fail("worker threw: " + failure.get(), failure.get()); | ||
| } | ||
| assertTrue(cleanerClosedChannel.await(5, TimeUnit.SECONDS), | ||
| "soak must exercise the cleaner ownership and close path"); | ||
| assertTrue(leasedInactive.isEmpty(), "poll must never lease an inactive channel"); | ||
|
|
||
| // Drain leases, then let the cleaner run a couple of ticks and confirm no tombstone leak: | ||
|
|
@@ -490,14 +553,52 @@ public void concurrentOfferPollRemoveAllIsConsistent() throws Exception { | |
|
|
||
| // ---- helpers ---- | ||
|
|
||
| private static Object idleState(Channel channel) throws Exception { | ||
| private static DefaultChannelPool.IdleState idleState(Channel channel) throws Exception { | ||
| Field keyField = DefaultChannelPool.class.getDeclaredField("IDLE_STATE_ATTRIBUTE_KEY"); | ||
| keyField.setAccessible(true); | ||
| @SuppressWarnings("unchecked") | ||
| io.netty.util.AttributeKey<Object> key = (io.netty.util.AttributeKey<Object>) keyField.get(null); | ||
| io.netty.util.AttributeKey<DefaultChannelPool.IdleState> key = | ||
| (io.netty.util.AttributeKey<DefaultChannelPool.IdleState>) keyField.get(null); | ||
| return channel.attr(key).get(); | ||
| } | ||
|
|
||
| private static final class BlockingActiveChannel extends EmbeddedChannel { | ||
|
|
||
| private final AtomicBoolean blockNextActiveCheck = new AtomicBoolean(); | ||
| private final CountDownLatch activeCheck = new CountDownLatch(1); | ||
| private final CountDownLatch resumeActiveCheck = new CountDownLatch(1); | ||
|
|
||
| @Override | ||
| public boolean isActive() { | ||
| // EmbeddedChannel calls this from its constructor before subclass fields are initialized. | ||
| AtomicBoolean block = blockNextActiveCheck; | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This null guard is load bearing and deserves a one line comment saying why. EmbeddedChannel's constructor calls the subclass isActive once while these fields are still null, so without the guard you get an NPE. The trap is that the NPE is swallowed by a DefaultPromise listener, so removing this check would fail silently rather than loudly, and an IDE will flag the check as always true on a final field and invite someone to delete it.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Added the one-line constructor-dispatch explanation above the null guard so the EmbeddedChannel initialization trap is explicit. |
||
| if (block != null && block.compareAndSet(true, false)) { | ||
| activeCheck.countDown(); | ||
| try { | ||
| if (!resumeActiveCheck.await(5, TimeUnit.SECONDS)) { | ||
| throw new AssertionError("active-channel check was not resumed"); | ||
| } | ||
| } catch (InterruptedException e) { | ||
| Thread.currentThread().interrupt(); | ||
| throw new AssertionError("active-channel check was interrupted", e); | ||
| } | ||
| } | ||
| return super.isActive(); | ||
| } | ||
|
|
||
| void blockNextActiveCheck() { | ||
| blockNextActiveCheck.set(true); | ||
| } | ||
|
|
||
| boolean awaitActiveCheck() throws InterruptedException { | ||
| return activeCheck.await(5, TimeUnit.SECONDS); | ||
| } | ||
|
|
||
| void resumeActiveCheck() { | ||
| resumeActiveCheck.countDown(); | ||
| } | ||
| } | ||
|
|
||
| private static Channel channelWithRemoteAddress(String host) { | ||
| return new EmbeddedChannel() { | ||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Minor one. This runs before the reset guard now, so a rejected offer leaves an empty deque sitting in partitions and the cleaner walks it every tick. Was not possible before, since offer0 could not return false. Probably just move the lookup below the reset call.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Agreed. I moved partition creation after the idle-generation transfer and simplified it to a direct computeIfAbsent. A duplicate offer now returns before touching partitions, so it cannot leave an empty deque for the cleaner.