[fix][proxy] PIP-478: resolve the proxy's broker-client credential off the event loop - #26328
[fix][proxy] PIP-478: resolve the proxy's broker-client credential off the event loop#26328lhotari wants to merge 2 commits into
Conversation
david-streamlio
left a comment
There was a problem hiding this comment.
The motivation is right and the shape of the fix is the one I'd want: ProxyService owns one driver, each DirectProxyHandler opens its own exchange, and the continuation is dispatched back onto the channel's event loop so command construction and writes stay where they were. I traced the load-bearing invariants rather than taking them from the description, and they hold:
- Same credential is still sent.
ProxyConnection.getClientAuthentication()returnsservice.getProxyClientAuthenticationPlugin(), so theauthenticationfield this class used before and the plugin the new driver is built from are the same instance. Routing throughProxyServicedoesn't change which credential goes to the broker. This is the non-obvious premise of the whole refactor and is worth a word in the PR body. - Per-host scoping and the initial call are preserved verbatim.
newAuthenticationExchange(remoteHostName)→V5AuthContexts.binaryCallContext(brokerHost)→LegacyV4CredentialAdapter.getAuthDataAsyncdoesv4.getAuthData(callContext.brokerHost())thend.authenticate(AuthData.INIT_AUTH_DATA)— exactly whatchannelActivedid inline. - Challenge rounds keep the same provider. The adapter stashes the
AuthenticationDataProviderin the call-context state slot, which reproduces the old retainedauthenticationDataProviderfield for multi-round mechanisms. Rounds stay serialized here because the next challenge only arrives after the previous response is written. - The lifecycle claim checks out.
forStartedV4Plugin→wrapAlreadyStarted→ownsLifecycle=false, and the adapter gates bothv4.start()ininitializeAsyncandv4.close()inclose()on that flag. - Ordering is genuinely unchanged. Inbound client bytes reach the backend only through
ProxyConnection.channelRead, gated onthis.directProxyHandler != null, which is assigned only inhandleBrokerConnected. So nothing can be written to the backend channel betweenchannelActiveand the deferred connect write. The HAProxy header still goes out synchronously first, and movingisTlsOutboundChannelabove the send is correct.
Findings, in the order I'd act on them:
1. The REFRESH path isn't covered by the cited tests — and isn't reachable in this class.
ProxyBackendHandler.channelRead stops decoding once state == HandshakeCompleted (set in handleConnected) and forwards raw bytes to the client. On the broker side, ServerCnx.maybeScheduleAuthenticationCredentialsRefresh only schedules the refresh task at authenticationRefreshCheckSeconds (default 60s) after connect completes. So the broker's REFRESH sentinel can never reach DirectProxyHandler.handleAuthChallenge — it arrives long after the handler stopped decoding, and gets proxied straight through to the client.
ProxyRefreshAuthTest asserts on ClientCnx.getLastDisconnectedTimestamp() over pulsarClientImpl.getCnxPool().getConnections() — it exercises the client's refresh through the proxy, not this code path.
So "the proxy authentication suites exercise both the connect path and the broker-pushed REFRESH path end to end through the changed code" holds for connect but not for REFRESH. Since the REFRESH branch is where the semantics actually changed (old: new provider + authenticate(REFRESH_AUTH_DATA); new: fresh exchange + getAuthDataAsync() → authenticate(INIT_AUTH_DATA), the delta pip-478.md:972 documents), I'd rather the PR body said plainly that the branch is defensive parity with ClientCnx/ProxyClientCnx and is not reachable here, than claimed test coverage for it. Not a code change — a claim change.
2. getProxyClientAuthenticationDriver() puts a shared monitor on the per-connection hot path. (ProxyService.java:702)
Every backend channelActive, on every proxy event loop, takes the ProxyService monitor — which is also held by createMetricsServlet() (:400), resetMetricsServlet() (:601), getMetricsServlet() (:710) and addPrometheusRawMetricsProvider() (:714). Those are all cheap, so this isn't a correctness bug, but the sibling code in this same stack — ClientCnx.resolveAuthDriver — deliberately does a lock-free read first and only synchronizes on the miss, precisely to keep the connect path off a shared monitor. Matching that here (make proxyClientAuthenticationDriver volatile, then double-check) seems worth it in a PR about not making connections wait on shared machinery. Note volatile isn't optional if you do this — the field is currently plain, so an unsynchronized read would be a data race.
3. The command-build failure path also closes now, which the PR body doesn't mention.
checkState(!authData.isComplete()) (:418) used to throw into catch (Exception e) { log.error("Error mutual verify") } and leave the connection open. It now lands in sendWhenResolved's builder catch and closes the channel. I think that's the right call and consistent with the stated policy, but the body attributes the new close behaviour only to resolution failures. Worth one more sentence so it isn't discovered later as a surprise.
4. Auth method name is read from the v4 plugin, not the exchange. (:292, :422)
ClientCnx takes the name from the exchange that produced the credential; this class still reads authentication.getAuthMethodName(). For the proxy the two agree (same plugin instance, and the bridge's authMethodName() delegates to v4.getAuthMethodName()), so this is correct as written — and arguably better here, since it doesn't depend on a completed round. But it reads as an oversight next to ClientCnx. A short comment saying why would stop someone "fixing" it later.
5. Two silent paths in sendWhenResolved. (:314, :322)
- The future returned by
whenCompleteAsyncis discarded. Ifctx.executor()rejects during event-loop shutdown, that rejection completes the discarded future and vanishes — no log, no close. Harmless in practice since the channel is dying anyway, but a// intentionally discardedwould say so on purpose. - The
!ctx.channel().isActive()early return leaves no trace. Alog.debug()there would make "backend connected but never sent CommandConnect" diagnosable; right now that outcome is invisible.
On the test you removed. Rather than a fixture that has to authenticate end to end, the property is pinnable much more cheaply: configure the proxy with a small v4 plugin whose getAuthData records Thread.currentThread().getName(), then assert the recorded name is pulsar-auth-blocking-shared (or at least isn't the Netty loop). That sidesteps the TLS/fixture setup entirely and pins exactly the invariant this PR exists to establish. Happy to write it if useful.
Nothing here is a blocker on the code as written — 1 is a claim in the description, 2–5 are polish. The functional core looks right to me.
There was a problem hiding this comment.
Pull request overview
Moves proxy-to-broker credential resolution off Netty event loops using the v5 authentication driver.
Changes:
- Adds a shared, lazily initialized authentication driver.
- Resolves connect and challenge credentials asynchronously.
- Closes backend channels when credential resolution fails.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
ProxyService.java |
Owns the shared v5 authentication driver. |
DirectProxyHandler.java |
Asynchronously handles broker authentication exchanges. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
|
Following up on the Copilot review that landed alongside mine, because one of its comments is correct in a way my review could be misread to dismiss. Its comment on
Scoping this against what I wrote, since the two reviews interact: My review argued the broker-pushed REFRESH sentinel cannot reach It does not rule out the rest, and I should have flagged that and didn't. During The missing round cap is the part I'd act on first. It is not a concurrency question at all: a broker that keeps answering each Copilot's other comment — that the event-loop property isn't pinned by a test — restates what the PR body already discloses, so nothing new there; my suggestion about a recording plugin still stands as the cheap way to get it. |
e82d6f7 to
b8cb00f
Compare
358ef32 to
017ec05
Compare
6ea3eb0 to
7363e39
Compare
7363e39 to
c76c372
Compare
|
Thanks — and particularly for the follow-up that scoped the two reviews against each other rather than 1. The REFRESH claim — corrected, and it is stronger than a timing argumentYou are right, and the description was wrong. It is also not merely that the broker's refresh task is So the branch is documented as PIP-478 routing-rule conformance and the description now says plainly 2. Shared monitor on the connect path — taken
3. The command-build failure path also closes — said in the descriptionAdded, including which 4. Auth method name — comment addedStating why reading it from the plugin is right here and does not depend on a completed round, so the 5. Two silent paths — both addressed
Round-state guarding — taken, minus one piece, deliberatelyYour table is right that all three are missing. Two are now there:
The generation guard I did not take, and I want to be explicit about the divergence. Once a The test — went further than the recording pluginYour suggestion was the right instinct and I took the diagnosis behind it: the fixture was the problem, Four tests, each mutation-verified to fail alone and for its own reason:
The one property I did not write an assertion for is "command construction resumes on the event loop". On the shared pool, from your #26327 reviewRecording where that lands now: the proxy still borrows the shared pool here, by design — it has no
Prepared with the assistance of Claude Code (Opus 5). |
|
/pulsarbot rerun |
|
Verified The round-state guarding. The generation guard you left out — I think that is correct, and it is a better answer than the one requested. Once a challenge arriving mid-round is dropped, rounds are strictly serialized, so there is no superseded continuation for a generation counter to detect. Worth noting for anyone reading the thread later: this is a case where the bot's ask and the right fix diverged. Applying all three guards verbatim would have added dead state; the analysis of why only two apply is what produced the smaller, correct change. The rest, all confirmed: the memoized driver is read without the monitor and the field is On the REFRESH branch — agreed with the sharper version. I had it as a timing argument and you are right that it is structural: after the handshake the connection stops decoding and switches to byte-level proxying, so the frame cannot reach Nothing further from me. Approving separately. |
david-streamlio
left a comment
There was a problem hiding this comment.
Approving. Verified all of it on d0a4cfece4; details in the comment above.
The round guard and cap are in, with the flag lifecycle correct — set synchronously in sendWhenResolved, cleared on the event-loop continuation. The generation guard was deliberately left out and I think that is the right call rather than a gap: dropping a mid-round challenge makes rounds strictly serialized, so there is nothing left to supersede, and the divergence is documented so the next reader does not restore it.
The five findings from my original review are all addressed, including the two silent paths.
d0a4cfe to
8eef794
Compare
aad9bc7 to
b153b64
Compare
b153b64 to
7e1730d
Compare
7e1730d to
b761129
Compare
b761129 to
0661a07
Compare
0661a07 to
e29468f
Compare
…f the event loop DirectProxyHandler.channelActive called the v4 authentication plugin inline — getAuthData() then authenticate(INIT_AUTH_DATA) — on the thread delivering the channel-active event, and handleAuthChallenge did the same for every challenge round. That is arbitrary plugin code: an OAuth2 token endpoint round trip, an Athenz ZTS fetch, a GSSAPI exchange with the KDC. While it ran, every connection multiplexed onto that Netty loop stalled — the exact hazard PIP-478 removed from the client, left in place on the proxy because pip-478.md put the proxy's broker-client credential I/O out of scope. Now that the client has no synchronous path at all, keeping one here has no justification, so the proxy drives the same v5 machinery: ProxyService owns one V5BinaryAuthenticationDriver built from its started v4 plugin, and each backend connection opens its own exchange against it. The exchange's calls always off-load; with no ClientAuthenticationServices bound — the proxy is not a PulsarClient and has no executor to lend — the work lands on the framework's shared blocking pool, which is the case V5AuthContexts documents for exactly this caller. Command ordering on the channel is unchanged: the continuation is dispatched back onto the channel's own event loop, so the connect command and auth responses are still built and written there. A resolution failure now closes the backend channel rather than only logging, since the proxy has no credential to send and leaving the connection open would wait out the broker's timeout instead of letting the client retry. The broker-pushed REFRESH sentinel starts a fresh exchange, per binary routing rule 2, mirroring ClientCnx. Covered by the existing proxy authentication suites, which exercise both the connect and the REFRESH paths end to end (ProxyRefreshAuthTest, ProxyForwardAuthDataTest, ProxyAuthenticatedProducerConsumerTest, ProxyWithAuthorizationTest — 19 tests, all passing). A dedicated assertion that no credential call lands on a proxy IO thread is still worth adding.
…uth rounds AuthenticationExchange is single-round and non-thread-safe, and its javadoc makes serializing rounds the caller's obligation. DirectProxyHandler became its second caller after ClientCnx and did not honour it: while the frame decoder is still running (state Init), two challenge frames delivered in one read reach handleAuthChallenge in the same event-loop turn, before either resolution has completed, and both drive the same exchange. A challenge arriving while a round is in flight is now dropped, which makes rounds strictly serialized — so this class needs none of the generation guarding ClientCnx carries, because nothing left can supersede an in-flight round. ClientCnx needs it only because it lets a REFRESH supersede one; the proxy can drop a REFRESH instead, both because it cannot reach this handler (the connection stops decoding after the handshake) and because the broker's refresh check is a scheduleAtFixedRate task that re-sends on the next tick. Also bounds the exchange with MAX_AUTH_CHALLENGE_ROUNDS, mirroring ClientCnx: a broker that answers every CommandAuthResponse with another challenge would otherwise loop against the proxy forever, and each round now also schedules credential work onto a blocking pool. getProxyClientAuthenticationDriver() now reads the memoized driver lock-free and synchronizes only on the miss, as ClientCnx.resolveAuthDriver does: every backend channelActive would otherwise take the ProxyService monitor, which is shared with the metrics-servlet accessors. The field is volatile accordingly. Adds DirectProxyHandlerAuthTest, which builds ProxyBackendHandler on an EmbeddedChannel — whose event loop runs a submitted task only when the test asks it to, turning "on the event loop or off it" from a race into an assertion, and letting a challenge be delivered while a resolution is deliberately still pending. All four tests are mutation-verified: inlining either v4 credential call fails its off-load test alone with the event-loop thread recorded; removing the drop guard or the round cap fails its own test alone. Smaller review points: the discarded whenCompleteAsync future and the inactive-channel early return now say what they are, and a comment records why the auth method name is read from the v4 plugin rather than from the exchange. Assisted-by: Claude Code (Opus 5)
e29468f to
6b7cb16
Compare
Main Issue: #25890
PIP: #25890
Motivation
DirectProxyHandler.channelActivecalled the v4 authentication plugin inline —getAuthData()thenauthenticate(INIT_AUTH_DATA)— on the thread delivering the channel-active event, andhandleAuthChallengedid the same for every challenge round.That thread is a Netty I/O loop, and the code being called is arbitrary plugin code: an OAuth2 token
endpoint round trip, an Athenz ZTS fetch, a GSSAPI exchange with the KDC. While it ran, every
connection multiplexed onto that loop stalled — the exact hazard PIP-478 exists to remove, and the one
it already removed from the client.
pip-478.md:145put the proxy's broker-client credential I/O out of scope, on the reasoning that theclient still had a synchronous path anyway. After the v5-native inversion (#26317) the client has no
synchronous path at all, so there is no longer a justification for keeping one here — this is the last
place in the codebase where plugin credential code runs on an event loop.
Modifications
ProxyServiceowns oneV5BinaryAuthenticationDriver, built lazily from its already-started v4plugin (the bridge must not run that lifecycle a second time, since
ProxyServicestarts and closesthat instance itself). Each
DirectProxyHandleropens its own exchange against it — the exchange iswhat carries per-connection conversation state, so a multi-round mechanism still works across rounds.
The getter reads the memoized driver without holding the monitor and synchronizes only on the miss, as
ClientCnx.resolveAuthDriverdoes: this is theProxyServicemonitor, shared with the metrics-servletaccessors, and every backend
channelActivewould otherwise take it.The credential itself is unchanged.
ProxyConnection.getClientAuthentication()returnsservice.getProxyClientAuthenticationPlugin(), so the plugin this class used before and the plugin thedriver is built from are the same started instance. Routing through
ProxyServicechanges where thecredential is resolved, not which credential is sent.
No
ClientAuthenticationServicesare bound: the proxy is not aPulsarClientand has no client-ownedexecutor to lend. Credential work therefore lands on the framework's shared blocking pool, which is
the case
V5AuthContextsdocuments for exactly this caller — the alternative, running it inline, isthe event loop this change exists to free.
The exchange's rounds are serialized and bounded.
AuthenticationExchangeis single-round andnon-thread-safe, and its javadoc makes serializing rounds the caller's obligation; this class is its
second caller after
ClientCnx. A challenge arriving while a round is still in flight is droppedrather than re-entering the exchange concurrently — two challenge frames delivered in one read reach
handleAuthChallengein the same event-loop turn, before either resolution has completed. Rounds arethen strictly serialized, which is why this class needs none of the generation guarding
ClientCnxcarries: nothing here can supersede an in-flight round. A challenge round cap
(
MAX_AUTH_CHALLENGE_ROUNDS, mirroringClientCnx) closes the backend connection rather than lettinga broker that answers every
CommandAuthResponsewith another challenge loop forever — each round nowalso schedules work onto a blocking pool.
Three properties are preserved deliberately:
event loop, so the connect command and every auth response are still built and written there,
whether the credential was already in memory or needed I/O.
credential to send; leaving the connection open would wait out the broker's timeout instead of
letting the client retry. This mirrors what
ClientCnx.completeAuthChallengewas fixed to do. Thesame applies to a failure to build the command:
checkState(!authData.isComplete())used tothrow into a
catchthat logged and left the connection open, and now closes it too.mirroring
ClientCnx— rather than being routed into the conversation it just terminated. Thatbranch is conformance with the rule, not a path the broker can reach here: see Verifying below.
The auth method name is read from the v4 plugin rather than from the exchange (where
ClientCnxreadsit). Both are correct here — the proxy owns one started plugin and the bridge's
authMethodName()delegates to it — and reading it from the plugin does not depend on a round having completed; a comment
now says so, since next to
ClientCnxit reads like an oversight.The now-unused
AuthenticationDataProviderfield is removed.Verifying this change
DirectProxyHandlerAuthTestpins the properties that live insideProxyBackendHandler, against anEmbeddedChannel: its event loop runs a submitted task only when the test asks it to, which turns "didthis run on the event loop or off it" from a race into an assertion. All four are
mutation-verified — each fails, alone and for its own reason, when the guard it pins is removed:
theConnectCredentialIsResolvedOffTheEventLoop/theChallengeRoundIsResolvedOffTheEventLoop—the v4 plugin records the thread of every credential call; both must land on
pulsar-auth-blocking-shared. Restoring either inline call records the event-loop thread instead.aChallengeArrivingWhileARoundIsInFlightIsDropped— reproduces two challenges reaching the handlerbefore the first resolution completes. Without the guard the second one re-enters the exchange.
anEndlesslyChallengingBrokerIsCutOffAtTheRoundCap— 11 challenges; the exchange must answer 10 andthe channel must close. Without the cap it answers all 11 and keeps going.
The existing proxy authentication suites cover the connect path end to end and still pass:
ProxyRefreshAuthTest,ProxyForwardAuthDataTest,ProxyAuthenticatedProducerConsumerTest,ProxyWithAuthorizationTest,ProxyAuthenticationTest.Not covered, because it is not reachable here: the broker-pushed REFRESH branch.
ProxyBackendHandler.channelReadstops decoding oncestate == HandshakeCompleted, and the brokerarms its refresh task (
scheduleAtFixedRate) with an initial delay ofauthenticationRefreshCheckSecondsafter connect completes — so a REFRESH is always proxied straightthrough to the client as raw bytes, and the client answers it. That is what
ProxyRefreshAuthTestasserts on:
ClientCnx.getLastDisconnectedTimestamp(), the client's refresh through the proxy. Theproxy's own credential refresh lives in
ProxyConnection(#25179), not in this class. An earlierrevision of this description claimed the suites covered the REFRESH path through the changed code;
they do not, and nothing can, so the branch is documented as routing-rule conformance instead.
./gradlew sanityCheckandquickCheckpass.Does this pull request potentially affect one of the following parts:
If the box was checked, please highlight the changes
Threading model: the proxy's broker-client credential resolution moves off the Netty event loop
onto the framework's shared blocking pool. Command construction and writes stay on the event loop, so
ordering on the channel is unchanged.