Repository navigation
Cache the pool key identity digest per realm - #2389
Open
pavel-ptashyts wants to merge 3 commits into
Open
pavel-ptashyts wants to merge 3 commits into
pavel-ptashyts wants to merge 3 commits into
Conversation
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>
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.
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 needsWeakReference.get() == realm. Every digest input except the custom login config is a final field ofRealm. The login config is the caller's own map, which they can still change afterbuild()and whichSpnegoEnginereads when it logs in, so a realm that has one is digested on every call, as before.MessageDigest,Object[]ofWeakReference), so the cache does not pin this library's class loader on container threads.origin + '/' + proxy. Equality is unchanged, because a hex digest never contains/. A cache hit reuses the sameString, so its hash code is computed once.identityOf,digestandCACHED_IDENTITIESare package-private, for the test.Only
PrincipalScopedPartitionKey, its test and a new benchmark change.Tests
New in
PrincipalScopedPartitionKeyTest:aCachedIdentityIsTheDigestItself:identityOfequalsdigest, 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 verifyon JDK 17.0.2 (Windows) passed onba8a92112: 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.mainand this branch run the same benchmark class: formain, itsPrincipalScopedPartitionKey.classcomes first on the classpath.scopeOnlycallsscope(...).lookupalso does aHashMap.getwith the key, as the pool does.realms = 1reuses one realm per hop.realms = 1024cycles 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.On a miss the times overlap with
mainwithin the error, and allocation is 40 bytes lower: no join, but two weak references. An earlier, quieter run of the same miss path gavescopeOnlyntlm 434 ns onmainagainst 449 ns on the branch, and proxyTunnel 450 ns against 411 ns.Notes
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 whatmaincosts today.MessageDigestwas 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.
AGENTS.md, ongit diff upstream/main...HEAD)gpt-6-astraclaude-opus-5-5gpt-6-astraclaude-opus-5-5gpt-6-astraclaude-opus-5-5Findings and what was done:
bothbenchmark measures permanent misses when the two realms share a slot (round 1, guided, risk). Fixed with two ways per set.twoRealmsInOneSetBothStayCachedcovers it.theCacheKeepsNeitherTheRealmNorItsIdentityAlivecovers it.realms = 1024.digest, an assertion that the bypass digests every call, and the retention test.System.gc()alone (round 2, independent, nit). It now also allocates while it waits.Claude Code on behalf of Pavel Ptashyts
🤖 Generated with Claude Code