gh-4569: Deterministically close shared HTTP client on TransportClientFactory#shutdown() - #4585
Conversation
Signed-off-by: Prahlad Bhakat <prahladbhakat05@gmail.com>
|
|
||
| private final Set<RequestConfigCustomizer> requestConfigCustomizers; | ||
|
|
||
| private volatile CloseableHttpClient sharedHttpClient; |
There was a problem hiding this comment.
I am a little concerned about this in 2 cases:
First the refresh case when eureka.client.refresh.enable=true. A refresh would close his but never nulls it out.
Second when if a second Eureka client is created, then it would reuse this sharedHttpClient, if one calls close if destroys the second instance.
There was a problem hiding this comment.
Good catch. The refresh case is now handled by clearing sharedHttpClient after closing it, so a subsequent get() creates a fresh client instead of reusing the closed instance.
For the second scenario, where multiple Eureka clients might share the same supplier instance, I wanted to confirm whether that can actually happen with the current RestClientTransportClientFactory lifecycle before introducing additional reference-counting/lifecycle management.
There was a problem hiding this comment.
For the second scenario, where multiple Eureka clients might share the same supplier instance, I wanted to confirm whether that can actually happen with the current RestClientTransportClientFactory lifecycle before introducing additional reference-counting/lifecycle management.
It is not something we do out of the box, but in theory someone could create a second client on their own and that case they would run into a problem if one of those clients gets closed while the other one is still being used.
There was a problem hiding this comment.
This is still a concern of mine
There was a problem hiding this comment.
Thanks for clarifying. I understand the concern around a supplier instance being shared by multiple Eureka clients. Since this is not the default lifecycle but is still a possible use case, would you prefer us to handle shared supplier instances explicitly, for example with reference counting, or document this as a limitation?
Signed-off-by: Prahlad Bhakat <prahladbhakat05@gmail.com>
| supplier.close(); | ||
|
|
||
| // A get() call racing just after shutdown must not throw; the returned factory | ||
| // wraps a closed client and will fail on actual use, which is expected during |
There was a problem hiding this comment.
I don't think this is true anymore since the shared client is nulled
|
|
||
| private final Object lock = new Object(); | ||
|
|
||
| private volatile CloseableHttpClient sharedHttpClient; |
There was a problem hiding this comment.
what is the point to make it volatile here? you are reading and writing it under the same lock, and the JLS already guarantees visibility.
|
|
||
| @Override | ||
| public void close() { | ||
| synchronized (this.lock) { |
There was a problem hiding this comment.
this is overly complicated also, I think you are confusing what volatile is supposed to do and this confusion is "dragged" into this method: that is why you do a volatile read via the local httpClient. But again, this is not correct, you do not even need it, and the method can be simplified to :
@Override
public void close() {
synchronized (this.lock) {
if (this.sharedHttpClient == null) {
return;
}
try {
this.sharedHttpClient.close();
}
catch (IOException ex) {
// Best-effort close during shutdown.
}
finally {
this.sharedHttpClient = null;
}
}
}
| if (sslContext != null || hostnameVerifier != null || timeoutProperties != null) { | ||
| httpClientBuilder | ||
| .setConnectionManager(buildConnectionManager(sslContext, hostnameVerifier, timeoutProperties)); | ||
| CloseableHttpClient httpClient; |
There was a problem hiding this comment.
same goes for this method, it can be simplified:
@Override
public ClientHttpRequestFactory get(
SSLContext sslContext,
@Nullable HostnameVerifier hostnameVerifier) {
synchronized (this.lock) {
if (this.sharedHttpClient == null) {
HttpClientBuilder httpClientBuilder = HttpClientBuilder.create();
if (sslContext != null
|| hostnameVerifier != null
|| timeoutProperties != null) {
httpClientBuilder.setConnectionManager(
buildConnectionManager(
sslContext,
hostnameVerifier,
timeoutProperties));
}
this.sharedHttpClient = httpClientBuilder.build();
}
return new HttpComponentsClientHttpRequestFactory(
this.sharedHttpClient);
}
}
|
|
||
| private final Set<RequestConfigCustomizer> requestConfigCustomizers; | ||
|
|
||
| private final Object lock = new Object(); |
There was a problem hiding this comment.
and now, if you look at the other comments, you will realize that this is not even needed anymore, you can simply make the methods synchronized...
There was a problem hiding this comment.
Good point. I simplified the synchronization by making get() and close() synchronized methods and removed the explicit lock.
Signed-off-by: Prahlad Bhakat <prahladbhakat05@gmail.com>
49ff0d5 to
810798e
Compare
Fixes #4569
Problem
RestClientTransportClientFactory.newClient()builds a brand-newCloseableHttpClient(with its own dedicated connection pool) on every single call. NeitherTransportClientFactory#shutdown()norEurekaHttpClient#shutdown()ever closes these clients - cleanup relied entirely on GC. During graceful shutdown, the finalunregister()DELETE call (made fromDiscoveryClient.shutdown(), right aftercancelScheduledTasks()) can hit a connection pool that's already been torn down, throwing:This causes deregistration to fail silently during graceful shutdown, leaving stale instances in the Eureka registry until the lease expires.
History
This overlaps with #4103, which documented the same "new HTTP client per call, never closed" issue. That was addressed in #4258 by caching a shared
CloseableHttpClientinDefaultEurekaClientHttpRequestFactorySupplierand making the supplier a SpringDisposableBeanto close it on shutdown. That approach was reverted (79a2eb8) after it caused #4275: making the supplier aDisposableBeanintroduced an independent Spring bean-destroy callback with no ordering guarantee relative toCloudEurekaClient's own@Bean(destroyMethod = "shutdown")- so the shared client could be closed beforeCloudEurekaClient.shutdown()reached itsunregister()call, breaking deregistration outright.Fix
Same idea as #4258 - share one
CloseableHttpClientper supplier instance instead of building one per call - but close it through the Eureka transport lifecycle instead of an independent Spring bean-destroy callback:EurekaClientHttpRequestFactorySuppliergets a newdefault void close() {}method (backward compatible for existing custom implementations).DefaultEurekaClientHttpRequestFactorySupplierlazily builds and caches a singleCloseableHttpClient, reused across allget()calls, and closes it inclose(). It does not implementDisposableBean.RestClientTransportClientFactory#shutdown()now callseurekaClientHttpRequestFactorySupplier.close().Since
TransportClientFactory#shutdown()is invoked synchronously by Netflix'sDiscoveryClient.shutdown()- which callsunregister()beforeeurekaTransport.shutdown()- the pool is now guaranteed to close after the final deregistration request completes, in the same thread, with no race against an unrelated Spring bean-destroy path.Testing
DefaultEurekaClientHttpRequestFactorySupplierTests: verifies the client is reused acrossget()calls,close()is safe to call before anyget()and safe to call twice, and - as an explicit regression guard for When shutting down after 4.1.1, an exception occurs while unregistering #4275 - that this class does not implementDisposableBean.RestClientTransportClientFactoryShutdownTests: verifiesshutdown()delegates tosupplier.close().spring-cloud-netflix-eureka-clienttest suite passes locally (216/216, excluding one pre-existing Docker-dependent Testcontainers test unrelated to this change).