Skip to content

Cache the pool key identity digest per realm - #2389

Open
pavel-ptashyts wants to merge 3 commits into
AsyncHttpClient:mainfrom
maygemdev:claude/realm-identity-digest-cache-40eab9
Open

pavel-ptashyts wants to merge 3 commits into
AsyncHttpClient:mainfrom
maygemdev:claude/realm-identity-digest-cache-40eab9

Conversation

@pavel-ptashyts

Copy link
Copy Markdown
Contributor

Motivation

PrincipalScopedPartitionKey.scope(...) runs on every pool poll and offer of a connection-authenticated exchange: an NTLM, Kerberos or SPNEGO origin, or a proxy login on a per-connection proxy hop (HTTPS or WebSocket through an HTTP proxy, or SOCKS). Each call ran SHA-256 over all identity fields of the realm, hex-encoded the result, joined the origin and proxy digests into a new string, and the pool then hashed that string again. That is about 0.4 us and 560 bytes per call for one realm, twice per exchange.

The key was introduced for GHSA-v2j5-22fr-j62r (83e552e, d3bb4d6) so that two identities never share an authenticated socket, and it holds a digest so that it never holds the password. Both stay true.

Modification

  • identityOf(realm) caches the digest per thread in a small table (8 sets of 2 ways), keyed by the realm instance: a hit needs WeakReference.get() == realm. Every digest input except the custom login config is a final field of Realm. The login config is the caller's own map, which they can still change after build() and which SpnegoEngine reads when it logs in, so a realm that has one is digested on every call, as before.
  • The cache holds both the realm and its digest through weak references. It keeps neither the realm (and with it the password) nor the digest alive. A digest that no pool key holds is computed again after the next collection.
  • The thread-local values are JDK types only (MessageDigest, Object[] of WeakReference), so the cache does not pin this library's class loader on container threads.
  • The key stores the origin and proxy digests as two fields instead of joining them as origin + '/' + proxy. Equality is unchanged, because a hex digest never contains /. A cache hit reuses the same String, so its hash code is computed once.
  • The digest inputs and their encoding are unchanged, including the Digest scheme not keying on the challenge realm name. No public API change: identityOf, digest and CACHED_IDENTITIES are package-private, for the test.

Only PrincipalScopedPartitionKey, its test and a new benchmark change.

Tests

New in PrincipalScopedPartitionKeyTest:

  • aCachedIdentityIsTheDigestItself: identityOf equals digest, a second call returns the same instance, and a realm with a login config is digested on every call.
  • aCachedIdentityMatchesAnUncachedOne: same key from the cached path, the uncached path and another thread, for NTLM, Kerberos and SPNEGO on both hops.
  • aWarmCacheStillSeparatesEveryKeyedField: with the base realm cached, a change to any one keyed field still changes the key, on both hops: scheme, principal, password, realm name, NTLM domain and host, login context, SPN, useCanonicalHostname, login config.
  • equalRealmsBuiltSeparatelyShareAKey, also across threads.
  • aLoginConfigChangedAfterBuildMovesTheKey.
  • manyRealmsKeepTheirOwnKeys: 200 realms over 16 slots, with rebuilt equal instances.
  • twoRealmsInOneSetBothStayCached: two realms in the same slot, called alternately. Checked by mutation: a one-way cache fails it.
  • theCacheKeepsNeitherTheRealmNorItsIdentityAlive. Checked by mutation: holding the identity strongly fails it.
  • anOriginLoginAndTheSameProxyLoginDoNotShareAKey.

./mvnw clean verify on JDK 17.0.2 (Windows) passed on ba8a92112: 2043 tests, 0 failures, 35 skipped, Revapi clean. JDK 11 is not installed on this machine, so CI covers it.

Benchmark

PrincipalScopedPartitionKeyBenchmark, JMH 1.37, JDK 17.0.2, Windows, 1 fork, 5 x 1 s warmup, 5 x 1 s measurement, -prof gc. main and this branch run the same benchmark class: for main, its PrincipalScopedPartitionKey.class comes first on the classpath. scopeOnly calls scope(...). lookup also does a HashMap.get with the key, as the pool does. realms = 1 reuses one realm per hop. realms = 1024 cycles through equal, separately built instances, so every call misses the cache. The host runs other builds, so the times are noisy. B/op is exact.

Benchmark hops realms main ns/op branch ns/op main B/op branch B/op
scopeOnly ntlm 1 384.5 +- 8.4 7.0 +- 0.1 560 32
scopeOnly proxyTunnel 1 358.9 +- 23.5 9.0 +- 5.3 552 32
scopeOnly both 1 1070.7 +- 771.6 14.8 +- 11.7 1040 32
scopeOnly ntlm 1024 384.5 +- 8.6 479.0 +- 377.0 560 520
scopeOnly proxyTunnel 1024 368.1 +- 34.6 422.7 +- 174.7 552 512
scopeOnly both 1024 1112.0 +- 1843.9 891.4 +- 14.2 1040 1000
lookup ntlm 1 450.0 +- 49.2 12.3 +- 0.4 560 32
lookup proxyTunnel 1 521.3 +- 397.6 12.8 +- 0.4 552 32
lookup both 1 1277.4 +- 1961.4 18.0 +- 0.7 1040 32
lookup ntlm 1024 687.3 +- 501.0 759.3 +- 1161.0 560 520
lookup proxyTunnel 1024 489.1 +- 366.7 793.1 +- 1085.4 552 512
lookup both 1024 897.3 +- 121.3 879.5 +- 204.5 1040 1000

On a miss the times overlap with main within the error, and allocation is 40 bytes lower: no join, but two weak references. An earlier, quieter run of the same miss path gave scopeOnly ntlm 434 ns on main against 449 ns on the branch, and proxyTunnel 450 ns against 411 ns.

Notes

  • A realm rebuilt for every request (for example through RequestBuilder.setRealm(Realm.Builder)), the realm the NTLM 401 handshake rebuilds, and the first poll after each collection on a caller thread all take the miss path, which costs what main costs today.
  • Pool polls from virtual threads (one per request) never hit the cache, and each such thread allocates one 32-element array. The cost is bounded and the thread-local MessageDigest was already there.

Second-agent review

Both reviewers ran read-only in fresh CLI sessions, with the intent and the security constraints (identity mixing, password exposure) in the prompt.

Round Reviewer Agent and model Effort Commit reviewed Result
1 Guided (against AGENTS.md, on git diff upstream/main...HEAD) Codex gpt-6-astra medium 7602d86 1 bug, 1 risk
1 Independent (no checklist, reads the files) Claude Code claude-opus-5-5 medium 7602d86 1 low risk, 3 nits
2 Guided Codex gpt-6-astra medium 7a62efe No findings
2 Independent Claude Code claude-opus-5-5 medium 7a62efe 3 nits
3 Guided Codex gpt-6-astra medium ba8a921 No findings
3 Independent Claude Code claude-opus-5-5 medium ba8a921 No findings

Findings and what was done:

  • Thread-local value of a library class pins the class loader (round 1, guided, bug). Fixed: the thread-locals hold JDK types only.
  • both benchmark measures permanent misses when the two realms share a slot (round 1, guided, risk). Fixed with two ways per set. twoRealmsInOneSetBothStayCached covers it.
  • The cached digest, an unsalted function of the password, outlived the realm and the client on caller threads (round 1, independent, low risk). Fixed: the digest is also held through a weak reference. theCacheKeepsNeitherTheRealmNorItsIdentityAlive covers it.
  • Class loader pinning (round 1, independent, nit). Same fix as above.
  • The benchmark measured hits only (round 1, independent, nit). Added realms = 1024.
  • The test reference for "uncached" relied on the login-config bypass, and nothing checked retention (round 1, independent, nit). Added a direct comparison with digest, an assertion that the bypass digests every call, and the retention test.
  • A hit in the second way did not become most recent, and a realm whose digest was collected could take both ways (round 2, independent, nit). Fixed: a hit there swaps the ways without allocating, and the realm's own stale entry is replaced in place.
  • The retention test relied on System.gc() alone (round 2, independent, nit). It now also allocates while it waits.
  • Virtual threads never hit the cache (round 2, independent, nit). Not changed: the cost is bounded and there is no regression. See Notes.

Claude Code on behalf of Pavel Ptashyts

🤖 Generated with Claude Code

pavel-ptashyts and others added 3 commits November 9, 2026 19:33
Every pool poll and offer for an NTLM or Negotiate exchange, or for
HTTPS through a proxy that asks for a login, ran SHA-256 over the
whole realm, hex-encoded it and concatenated the origin and proxy
digests into a new string that the pool then hashed again.

Every input of the digest is a final field of the realm except the
custom login config, which is the caller's own map and can still be
changed after build(). The digest is now kept per thread in a small
table keyed by the realm instance through a weak reference, so a
realm a request reuses is digested once per thread, and the cache
never keeps a realm or its password alive. A realm that carries a
login config is still digested on every call. The key holds the two
digests as separate fields instead of joining them, so the cached
strings are used as they are and their hash codes are computed once.

Keys are unchanged: the same inputs give the same digest, two
identities never share a key, and the key still holds no password.

Claude Code on behalf of Pavel Ptashyts

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review found three gaps in the identity cache. A thread-local value
of one of our classes pins this library's class loader on container
threads that outlive the client. The cached digest, which is an
unsalted function of the password, outlived both its realm and the
client on caller threads. And an origin and a proxy realm that fell
into the same slot evicted each other on every call.

The cache now holds only JDK types, keeps the identity behind a weak
reference too, so it lives no longer than a pool key holding it, and
checks the neighbouring slot before evicting. The benchmark gains a
mode that misses on every call, and the tests compare against the
digest directly and check that the cache keeps neither the realm nor
its identity alive.

Claude Code on behalf of Pavel Ptashyts

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A hit in the second way of a set left the order as it was, so the
next miss evicted the realm just used rather than the older one, and
a realm whose digest had been collected could take both ways of its
set. A hit there now swaps the ways, which allocates nothing, and a
recomputed digest replaces its own stale entry instead of pushing the
neighbour out. The retention test also allocates while it waits, so
it does not rely on System.gc() alone.

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