Repository navigation
Drop empty pool partitions and string-free composite keys - #2390
Open
pavel-ptashyts wants to merge 9 commits into
Open
pavel-ptashyts wants to merge 9 commits into
pavel-ptashyts wants to merge 9 commits into
Conversation
DefaultChannelPool adds a partition for every key it is offered and only removes one on flushPartitions, so a client that talked to many hosts once keeps all their empty deques for good, and the idle cleaner walks every one of them on each tick (every 100 ms by default). The cleaner now drops a partition once it has stayed empty for as long as an idle channel may stay pooled (the pooled idle timeout, else the connection TTL). An offer may still hold the partition it read before the removal, so removal unmaps the deque and then raises a retired flag, and an offer re-reads that flag after linking: either the offer sees it and moves its channel to a live partition, or the remover's drain finds the channel and moves it. No channel is lost. flushPartitions raises the same flag. Before, an offer racing a flush could link its channel into the unmapped deque, where nothing leased, reaped or closed it again. Claude Code on behalf of Pavel Ptashyts Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With a proxy or a virtual host, every partition key lookup built Uri.getBaseUrl(), a fresh "scheme://host:port" string, only to hash and compare it, and hashed the key through Objects.hash, which allocates a varargs array and boxes the proxy port. A request does this at least twice: once for its future and once to poll the pool. CompositePartitionKey now keeps the scheme, host and port as the plain PartitionKey does and hashes them inline. Keys stay equal exactly when the base URLs were equal, and toString still prints the base URL. Claude Code on behalf of Pavel Ptashyts Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The two soak tests waited for their workers in an unbounded loop, so a worker stuck in the pool hung the build instead of failing it, and nothing guaranteed that the cleaner or flush passes overlapped the work. They now share a helper with a deadline, daemon workers and a pacing bound that keeps the passes spread over the work. The bound is loose on purpose: workers that take turns with the passes no longer reproduce the races, which mutation runs confirmed. The scheme assertion went through a proxy, where the scheme also picks the proxy port, so it held even without the scheme in equals. Add a test for how the empty partition lifetime is chosen, describe what "stayed empty" means precisely, and let the cleaner benchmark wait for the clock without Thread.sleep. Claude Code on behalf of Pavel Ptashyts Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Workers that waited whenever the cleaner or flush passes fell behind ended up taking turns with them, and then the soak tests no longer caught a lost channel (mutation runs showed it). Workers now never wait; instead each keeps going until a minimum number of passes has run, so the passes land while the workers are busy however the threads are scheduled. The retirement soak only asserts that some partition was dropped and recreated, which that guarantees, rather than a count that depends on scheduling. Also note in retire() that a channel in transit is briefly in no deque. Claude Code on behalf of Pavel Ptashyts Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
flushPartitions closed every channel linked into a partition before it ran. With the retired flag, an offer that linked a channel just before the flush could still see the flag, take its node back and move the channel to a new partition, while the flush's iterator skipped the node it emptied. The channel then outlived a flush that callers use to drop stale connections. A partition unmapped by a flush is now marked as flushed, and an offer that takes a node back from one closes the channel the way the flush would have. The retirement soak now requires more deques than keys: without retirement there is at most one per key, so its old bound held even when nothing was retired. Claude Code on behalf of Pavel Ptashyts Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The cleaner and a flush can both pick the same partition. The cleaner then drained it and moved a channel the flush meant to close into a new partition, and the flushed flag the flush wrote could be missed by an offer that read the retired flag the cleaner wrote. Whichever of the two unmaps the partition now owns what is left in it; the other leaves it alone, so the flag pair is only ever written by one thread. The cleaner benchmark replaces a live channel the cleaner closed after a pause longer than the idle timeout instead of offering null. Claude Code on behalf of Pavel Ptashyts Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Moving the channels an offer left in a retired partition to a new partition kept races open: the channel sat in no deque for a moment, where poll() missed it, and a flush that started meanwhile could miss it for good, so a connection pooled before a flush outlived it. Now whatever lands in a partition after it was unmapped is closed, by the cleaner's drain, by the offer that takes its node back, or by the flush, and nothing is ever moved. Closing costs a reconnect, and only for an offer that hits the narrow window in which the cleaner drops a partition that has been empty for the whole idle timeout. The flushed flag is no longer needed. Claude Code on behalf of Pavel Ptashyts Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The test calls retire() on a non-empty partition, the state the race leaves behind, rather than racing an offer; the soak test does that. Also drop an assertion message from the time channels were moved. Claude Code on behalf of Pavel Ptashyts Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
One test only checks that an empty partition survives its first sightings, not the lifetime it was named after; another still spoke of moving channels, which the pool no longer does. Claude Code on behalf of Pavel Ptashyts Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Two costs of the HTTP/1 connection pool that grow with how many hosts a client has ever talked to, or that every request pays when it goes through a proxy or uses a virtual host.
1. The pool now drops partitions that stay empty.
DefaultChannelPooladded a partition (aConcurrentLinkedDeque) for every key it was offered and only removed one influshPartitions. A client that talked to many hosts once kept all their empty deques for good, and the idle cleaner walked every one of them on each tick (connectionPoolCleanerPeriod, 100 ms by default).pooledConnectionIdleTimeout, orconnectionTtlwhen the idle timeout is off. A partition is a subclass of the deque with aretiredflag and the key, sooffer/pollpay no extra indirection.retiredflag, then drains (the cleaner) or scans (a flush).offerreads the flag after linking its channel. Either the offer sees the flag, or the remover sees the channel. Nothing that lands in an unmapped partition is moved. It is closed by the cleaner's drain, by the flush, or by the offer that takes its node back, after the usual ownership claim. Moving channels between partitions was tried first. It reopened races: a channel was briefly in no deque, and a channel pooled before a flush could outlive the flush. Closing costs a reconnect. That happens only when an offer lands in the moment the cleaner drops a partition that has been empty for the whole idle timeout.flushPartitionnow usesremove(key, partition). The oldremove(key)could unmap a newer partition for the same key than the one it was closing.flushPartitionscould link its channel into the deque the flush had just unmapped. The channel then sat there alive, and nothing ever leased, reaped or closed it again.The default
connectionPoolCleanerPeriodis unchanged. PT0.1S has been the default since #1205 (2016), which added the setting so that idle connections are not all closed at once.2.
CompositePartitionKeyno longer builds a string per lookup. With a proxy or a virtual host,PerHostChannelPoolPartitioning.getPartitionKeybuiltUri.getBaseUrl(), a freshscheme://host:portstring, only to hash and compare it. It then hashed the key throughObjects.hash, which allocates a varargs array and boxes the proxy port. A request computes the key at least twice: once for its future, and once from the live request to poll the pool. The key now holds the scheme, host and port, as the plainPartitionKeyalready does, and hashes them inline. Keys are equal exactly when the base URL strings were equal, andtoStringprints the same text as before.No public API change. The new constructor,
Partitionandretireare package-private.Benchmarks
JMH 1.37, JDK 17.0.2,
-f 2 -wi 3 -i 5 -prof gc, upstreammain(d722291) against this branch (3817fa6). Both benchmarks are inclient/src/jmh.ChannelPoolCleanerBenchmark: one cleaner pass with N dormant partitions (empty for longer than the idle timeout) and 16 live ones, each leased and returned per invocation.cycleis that lease and return alone, socleanerPass - cycleis the pass.A pass allocates next to nothing on either side. What remains at 10,000 is the map's table:
ConcurrentHashMapkeeps the capacity it grew to, and the pass still walks the empty bins.PartitionKeyLookupBenchmark: build the key, then hash and compare it against an equal key in aConcurrentHashMap.Throughput numbers come from a shared, loaded machine. The B/op figures are the stable ones.
Tests
DefaultChannelPoolTest:Thread.sleep, bounded by a deadline): offers and polls against a cleaner that drops every empty partition, and against back-to-back flushes. At quiescence every channel must be pooled or closed.link, the drain inretire, the close inlink, the ownership check inretire, and retirement itself.ChannelPoolPartitioningTest: key equality across equivalent URIs, every component separates keys, the secured proxy port, explicit default ports, and thetoStringformat.mvnw.cmd -B -ntp clean verify -Dgpg.skip=trueon JDK 17.0.2 at 3817fa6: BUILD SUCCESS, 2050 tests, 0 failures, 35 skipped, Revapi clean. JDK 11 is not installed on this machine; CI covers it.Second-agent review
Each round ran read-only in fresh sessions on the full diff against upstream
main.gpt-6-astra, reasoning effort medium, withAGENTS.mdas the checklist.claude-opus-5-5, effort medium, with Read/Grep/Glob only.Every finding was fixed except these, which were declined on purpose:
emptySince(rounds 3 and 6). It is the clock the idle-timeout check already uses, and a clock step only changes when a partition is dropped.destroy()during a cleaner pass (round 2). The same race already exists foroffer, andChannelManagercloses all channels beforedestroy().linkand without the drain inretire. An assertion that the window was hit would depend on scheduling, which round 2 asked to avoid.Claude Code on behalf of Pavel Ptashyts
🤖 Generated with Claude Code