Skip to content

Drop empty pool partitions and string-free composite keys - #2390

Open
pavel-ptashyts wants to merge 9 commits into
AsyncHttpClient:mainfrom
maygemdev:claude/pool-cleaner-partitions-fd3391
Open

pavel-ptashyts wants to merge 9 commits into
AsyncHttpClient:mainfrom
maygemdev:claude/pool-cleaner-partitions-fd3391

Conversation

@pavel-ptashyts

Copy link
Copy Markdown
Contributor

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. DefaultChannelPool added a partition (a ConcurrentLinkedDeque) for every key it was offered and only removed one in flushPartitions. 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).

  • The cleaner now drops a partition once it has found it empty at every tick for as long as an idle channel may stay pooled: pooledConnectionIdleTimeout, or connectionTtl when the idle timeout is off. A partition is a subclass of the deque with a retired flag and the key, so offer/poll pay no extra indirection.
  • An offer may still hold the partition it read before the removal. The remover unmaps the partition, then raises the retired flag, then drains (the cleaner) or scans (a flush). offer reads 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.
  • Whoever unmaps a partition owns what is left in it, so the cleaner and a flush never both drain one. flushPartition now uses remove(key, partition). The old remove(key) could unmap a newer partition for the same key than the one it was closing.
  • This also fixes a leak that predates this change. An offer racing flushPartitions could 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 connectionPoolCleanerPeriod is 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. CompositePartitionKey no longer builds a string per lookup. With a proxy or a virtual host, PerHostChannelPoolPartitioning.getPartitionKey built Uri.getBaseUrl(), a fresh scheme://host:port string, only to hash and compare it. It then hashed the key through Objects.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 plain PartitionKey already does, and hashes them inline. Keys are equal exactly when the base URL strings were equal, and toString prints the same text as before.

No public API change. The new constructor, Partition and retire are package-private.

Benchmarks

JMH 1.37, JDK 17.0.2, -f 2 -wi 3 -i 5 -prof gc, upstream main (d722291) against this branch (3817fa6). Both benchmarks are in client/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. cycle is that lease and return alone, so cleanerPass - cycle is the pass.

dormant hosts pass on main pass on branch
1,000 6.7 us 1.3 us
10,000 74.4 us 11.1 us

A pass allocates next to nothing on either side. What remains at 10,000 is the map's table: ConcurrentHashMap keeps 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 a ConcurrentHashMap.

route main branch
plain (per-host key, reference) 8.5 ns, 0 B/op 8.6 ns, 0 B/op
virtual host 38.0 ns, 112 B/op 11.3 ns, 0 B/op
HTTP proxy 43.0 ns, 128 B/op 12.4 ns, 0 B/op

Throughput numbers come from a shared, loaded machine. The B/op figures are the stable ones.

Tests

  • DefaultChannelPoolTest:
    • Deterministic tests: dropping an empty partition; keeping a non-empty one; the default lifetime, and how it is chosen from the idle timeout and the TTL; both sides of the offer race (a stale read is simulated by mapping the retired partition back); a claimed channel is left to its owner; the cleaner leaves alone a partition a flush unmapped first; an offer racing a flush has its channel closed rather than stranded.
    • Two soak tests (no 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.
    • Mutation runs confirmed that the tests fail without each part of the protocol: the flag read in link, the drain in retire, the close in link, the ownership check in retire, and retirement itself.
  • ChannelPoolPartitioningTest: key equality across equivalent URIs, every component separates keys, the secured proxy port, explicit default ports, and the toString format.

mvnw.cmd -B -ntp clean verify -Dgpg.skip=true on 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.

  • Guided reviewer: Codex gpt-6-astra, reasoning effort medium, with AGENTS.md as the checklist.
  • Independent reviewer: Claude claude-opus-5-5, effort medium, with Read/Grep/Glob only.
round commit guided (Codex) independent (Claude)
1 bd4a75a 1 major, 3 minor (tests and bench) 1 minor, 2 nits
2 0df6a4e 1 minor (soak flakiness) 1 minor, 2 nits
3 d4a07be 1 minor (vacuous soak bound) 2 minor, 1 nit (flush could miss a channel)
4 43de544 1 major (cleaner and flush on one partition), 2 minor 1 minor, 2 nits
5 d73e886 No findings 1 minor (channel moved past a flush), 1 nit
6 dc572f9 No findings 1 minor, 3 nits
7 060f5cc No findings 2 nits (test name, stale comment)
8 3817fa6 No findings 2 minor (soak tests may not hit the race), declined below

Every finding was fixed except these, which were declined on purpose:

  • Splitting the key change into its own PR (round 4). The two changes were scoped together on purpose.
  • Wall-clock time for 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 for offer, and ChannelManager closes all channels before destroy().
  • Closing, rather than moving, the channel of an offer that races a partition drop (round 6). See above.
  • The soak tests not proving that the race window was hit (rounds 4 and 8). A deterministic test covers each branch. In every mutation run, the soaks failed without the flag check in link and without the drain in retire. 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

pavel-ptashyts and others added 9 commits November 9, 2026 19:46
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant