blob: opt-in connection-pool saturation metrics for AWS and GCP - #561
blob: opt-in connection-pool saturation metrics for AWS and GCP#561iamabhilaksh wants to merge 7 commits into
Conversation
Adds an opt-in withMetricsPublisher(...) hook that samples HTTP connection-pool utilization (max / leased / available / pending) and publishes cloud-agnostic Metric objects, so operators can observe pool saturation uniformly across providers. - multicloudj-common: MetricsPublisher, Metric, ConnectionPoolMetrics - blob-client: withMetricsPublisher on BlobStoreBuilder - AWS: AwsMetricsPublisherAdapter bridges the native MetricPublisher SPI - GCP: GcpConnectionPoolMetricsInterceptor samples the Apache pool per response (installed only when the builder builds its own transport) Validation evidence (see VALIDATION_532.md): - Accuracy (live GCS): under a saturated 4-connection pool with 24 concurrent uploads, published metrics track reality exactly (observedMax=4, peakLeased=4, peakPending=20, minAvailable=0); reproduced identically across two runs. - Overhead (JMH microbench): the full per-response sampling path costs 10.6 +/- 0.1 ns/op -- ~0.000007% of a ~150ms GCS request, confirming sampling sits effectively off the request path. Provider-agnostic (the sampled path lives in multicloudj-common). Builds on the approach proposed in the community PR salesforce#532. Alibaba support is deferred to a follow-up (accuracy not yet validated on OSS). Co-authored-by: Haythem Khiri <hxemos@gmail.com>
1abc3aa to
f9684bd
Compare
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #561 +/- ##
============================================
+ Coverage 83.61% 83.63% +0.02%
Complexity 674 674
============================================
Files 215 219 +4
Lines 15010 15099 +89
Branches 2076 2089 +13
============================================
+ Hits 12550 12628 +78
- Misses 1636 1646 +10
- Partials 824 825 +1
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
- BlobStoreBuilder: document the four connection-pool counters as the guaranteed cross-provider subset; stop claiming Alibaba support (not wired yet, no-op today) so the javadoc matches the code. - AwsMetricsPublisherAdapter: document that forwarding the full AWS metric tree is intentional (pool counters are a subset under the HttpClient category; the rest is a provider-specific superset). - GcpBlobStoreTest: add coverage for the metrics-enabled connection manager swap — asserts the default 200/20 pool sizing is preserved and that an explicit maxConnections is still honored on that path.
|
Hi @iamabhilaksh, Thanks for looking into this. However, since #532 was already covering this exact architecture and feature set (AWS, GCP, Alibaba, and metric abstractions), the standard practice would have been to review #532 or push commits directly onto that branch rather than opening a superseding PR. Since the core implementation and design stem from #532, let's keep #532 as the primary PR. Feel free to push your JMH benchmarks and the common-module refactoring directly to my branch so we keep the authorship, commit history, and discussion clean in one place. Thanks! |
kchoy-sfdc
left a comment
There was a problem hiding this comment.
A few notes from a local review — one functional gap on the GCP path (inline) plus a couple of smaller items. Nice feature overall; the abstraction is clean and off-by-default.
Work-item tag in the title: small thing — could we drop the [W-…] work-item tag from the PR title? Since this repo is public, it's cleaner to keep work-item ↔ PR traceability tracked on our side rather than in the title. I checked and it's only in the title — the commit messages and changed files are all clean — so this is just a quick title edit.
| // in a controlled benchmark this roughly tripled small-file directory throughput. The knob is | ||
| // left unset by default so single-object callers keep the lean default connection footprint. | ||
| if (builder.getMaxConnections() != null) { | ||
| if (builder.getMetricsPublisher() != null) { |
There was a problem hiding this comment.
GCP: a metrics-only builder never installs this interceptor.
This block only runs inside buildHttpClient, which is only reached when shouldConfigureHttpClient (line 1618) returns true — and that gate checks proxyEndpoint || maxConnections || socketTimeout || idleConnectionTimeout, but not getMetricsPublisher(). So a builder with only withMetricsPublisher(...) set (no proxy/maxConnections/timeout) never reaches here: buildStorage / buildMultipartUploadClient skip setTransportOptions(...), the interceptor is never registered, and nothing is ever published.
That's the most likely way an operator enables the feature ("I just want pool metrics"), so it's effectively a silent no-op for the common case, and it contradicts the builder javadoc promise that the four counters are emitted on every supported provider.
Suggested fix: add || builder.getMetricsPublisher() != null to shouldConfigureHttpClient. Note the two new GCP tests call buildHttpClient reflectively, so they bypass this gate and won't catch it — worth adding one test that builds a metrics-only client through build() and asserts the interceptor is actually wired onto the transport.
There was a problem hiding this comment.
Good catch, this was exactly right. Added || builder.getMetricsPublisher() != null to shouldConfigureHttpClient so a metrics-only builder still owns its transport and installs the interceptor.
On the test: I added testShouldConfigureHttpClient_trueWhenOnlyMetricsPublisherSet, but you're right that it goes through the reflective gate helper, so it proves the gate flips, not that the interceptor lands on the transport end-to-end through build(). Fair to call that partial coverage. [Thinking out loud] a true build()-through assertion means reaching into the HttpTransportOptions to confirm the interceptor is registered, which is a bit awkward given how the transport is constructed — let me take a proper look and either add it here or track it as a focused follow-up. Won't claim it's covered until it is.
There was a problem hiding this comment.
Update: went ahead and added the end-to-end assertion rather than deferring it. testBuildHttpClient_metricsOnlyInstallsPoolMetricsInterceptor builds the client through buildHttpClient(...) for a metrics-only builder and walks the Apache exec chain (execChain → ProtocolExec.httpProcessor → responseInterceptors) to assert GcpConnectionPoolMetricsInterceptor is actually registered on the transport — so it now proves the wiring, not just the gate. Pushed in f05830f.
| overrideConfig.apiCallTimeout( | ||
| Duration.ofMillis(config.getRetryConfig().getTotalTimeout())); | ||
| if (config.getMetricsPublisher() != null) { | ||
| overrideConfig.addMetricPublisher( |
There was a problem hiding this comment.
The CRT-backed async client doesn't wire the metrics publisher.
This wires the adapter for the standard S3AsyncClientBuilder path, but the S3CrtAsyncClientBuilder overload (applyCommonConfig around line 757) has no equivalent — so withParallelDownloadsEnabled(true) + withMetricsPublisher(...) silently emits nothing (the CRT path is taken when parallel downloads are enabled).
This is defensible since CRT uses a native HTTP stack rather than the SDK metric SPI, but the builder javadoc states AWS support for "both the synchronous and asynchronous clients" with no caveat. Could we either add a one-line javadoc note that the CRT-backed async path (parallel downloads) does not emit metrics, or log a debug message when a publisher is set on the CRT path — so it's a documented limitation rather than a silent gap?
There was a problem hiding this comment.
Went with the javadoc route. Added a caveat on BlobStoreBuilder.withMetricsPublisher noting the CRT-backed async client (parallel downloads) uses a native HTTP stack and doesn't emit connection-pool metrics, plus a matching comment on the CRT applyCommonConfig so it's documented at the code site too. Made the AWS bullet in the javadoc explicit about the exception rather than the unqualified "both sync and async" it promised before.
| * category; the metric names in that category match the cloud-agnostic {@link | ||
| * com.salesforce.multicloudj.common.observability.ConnectionPoolMetrics} vocabulary. | ||
| */ | ||
| public class AwsMetricsPublisherAdapter implements software.amazon.awssdk.metrics.MetricPublisher { |
There was a problem hiding this comment.
Can we use import statement instead of FQCN here?
There was a problem hiding this comment.
Done — imported software.amazon.awssdk.metrics.MetricPublisher and dropped the FQCN.
| * @param metricsPublisher The publisher to receive client metrics, or {@code null} to disable. | ||
| * @return An instance of self | ||
| */ | ||
| public BlobStoreBuilder<T> withMetricsPublisher(MetricsPublisher metricsPublisher) { |
There was a problem hiding this comment.
Feature is unreachable through the public client builders. withMetricsPublisher is added only to the abstract SPI BlobStoreBuilder. Unlike its siblings withRetryConfig and withTracingPolicy — which are re-declared as delegating methods on both BucketClient.BlobBuilder (withRetryConfig ~L1051, withTracingPolicy ~L1100) and BlobClient.BlobClientBuilder (~L145 / ~L156) — no delegating withMetricsPublisher was added to either user-facing builder. A normal caller who goes through BucketClient/BlobClient cannot set a publisher; the only way to reach it is to instantiate new AwsBlobStore.Builder() / new GcpBlobStore.Builder() at the SPI level and cast (which is exactly what the new tests do). As written, the feature ships dead to end users. Please add the delegating withMetricsPublisher(...) to BucketClient.BlobBuilder and BlobClient.BlobClientBuilder (and the async equivalents) to follow the established three-place pattern.
There was a problem hiding this comment.
Agreed, this was the big one — it shipped dead to anyone going through the public builders. Added the delegating withMetricsPublisher(...) in all three places to match the withRetryConfig/withTracingPolicy pattern: BlobClientBuilder, BucketClient.BlobBuilder, and AsyncBucketClient.Builder (which overrides and calls super). So a normal caller can now set a publisher without dropping to the SPI and casting.
| * | ||
| * <p>Called when the owning client is closed. | ||
| */ | ||
| default void close() {} |
There was a problem hiding this comment.
MetricsPublisher.close() is never invoked on any path — the documented lifecycle contract is unfulfilled and buffered publishers leak. This javadoc promises "Called when the owning client is closed," but nothing calls it. GcpBlobStore.close() closes only transferManager and storage; the interceptor's publisher is never closed. On AWS, AwsMetricsPublisherAdapter.close() correctly delegates, but nothing ever closes the adapter — AwsBlobStore.close() / AwsAsyncBlobStore.close() close only the S3 client, and the AWS SDK v2 does not close MetricPublishers registered via addMetricPublisher (ownership stays with the caller). The interface explicitly recommends deferring transmission to a background thread, so a buffered/threaded publisher will never be flushed or shut down when the blob client is closed — leaking a thread/executor per client instance. Either wire close() through both providers' close(), or drop the method from the contract.
There was a problem hiding this comment.
Fixed on both providers. GcpBlobStore, AwsBlobStore, and AwsAsyncBlobStore now hold the supplied MetricsPublisher and call close() on it in their own close(), with a comment noting neither the GCS SDK nor AWS SDK v2 closes caller-registered publishers. Added AwsBlobStoreTest#testCloseReleasesMetricsPublisher asserting the publisher is closed. So a buffered/threaded publisher gets shut down with the client now instead of leaking.
| private final String name; | ||
|
|
||
| /** The recorded value of the metric. */ | ||
| private final Object value; |
There was a problem hiding this comment.
The public Metric contract over-promises neutrality and will be hard to tighten later. Metric/MetricsPublisher are documented as cloud-agnostic, but value is a bare Object and category is a free-form String, and the two providers populate them asymmetrically: GCP emits exactly the four ConnectionPoolMetrics counters under category "HttpClient", while AwsMetricsPublisherAdapter forwards the SDK's entire native metric tree (raw AWS names like ApiCallDuration/OperationName, values of Duration/String/int, categories ApiCall/ApiCallAttempt/HttpClient). So a consumer written to the "neutral" contract can portably rely on only four names under one category; everything else is AWS-shaped and absent on GCP, with no compile-time help to tell them apart. This is a brand-new public API surface — since value:Object + free-form category can't be narrowed later without a breaking change, please make this a conscious API commitment: document the value type of the four guaranteed pool counters, and consider gating the AWS full-tree passthrough behind an explicit opt-in so the default contract stays genuinely provider-neutral.
There was a problem hiding this comment.
This is the one I'd like to keep open for a bit. I did the first half: documented the provider-neutral contract on Metric — the four ConnectionPoolMetrics counters under category "HttpClient" with an Integer value are the only cross-provider guarantee, and anything else (the AWS ApiCall/ApiCallAttempt tree, Duration/String values) is an explicitly non-portable superset a consumer must not assume.
On the second half — gating the AWS full-tree passthrough behind an explicit opt-in so the default stays strictly neutral — I think you're right that it's the cleaner API commitment, and since value:Object + free-form category can't be narrowed later without a break, it's worth deciding deliberately rather than in a PR thread. I've written up the tradeoff in a short design doc and am setting up a quick review to lock the call. Happy to go either way (document-only vs. opt-in flag) based on where that lands — will circle back here with the decision.
…lifecycle, provider-neutral contract Applies the six review fixes for opt-in connection-pool saturation metrics: - Delegate withMetricsPublisher(...) from the public client builders (BlobClientBuilder, BucketClient.BlobBuilder, AsyncBucketClient.Builder) to the SPI BlobStoreBuilder, mirroring withRetryConfig/withTracingPolicy. - GCP: include getMetricsPublisher() in shouldConfigureHttpClient so a metrics-only builder still owns its transport and installs the pool-sampling interceptor (otherwise metrics silently no-op). - Close the caller-supplied MetricsPublisher in GcpBlobStore, AwsBlobStore, and AwsAsyncBlobStore close(); neither the AWS SDK v2 nor the GCS SDK owns publishers registered by the caller. - Document the provider-neutral Metric contract: the four HttpClient connection-pool counters are guaranteed on every provider; any other name/category (AWS ApiCall/ApiCallAttempt, non-Integer values) is a non-portable superset consumers must not assume. - Document the CRT-backed async client limitation (parallel downloads use a native HTTP stack that bypasses the metric-publisher SPI, so no metrics are emitted on that path) on withMetricsPublisher javadoc and the CRT config. - AwsMetricsPublisherAdapter: import MetricPublisher instead of using the FQCN. Adds regression tests: AwsBlobStoreTest#testCloseReleasesMetricsPublisher and GcpBlobStoreTest#testShouldConfigureHttpClient_trueWhenOnlyMetricsPublisherSet.
|
Hi everyone, Just to give an update @sandeepvinayak requested the design RFC for the connection-pool telemetry architecture on PR #532. I've published the proposal here: https://docs.google.com/document/d/15nN0RBYwVIMOdCAzZeXgRvu73aoLxyO5mcMxYQoXjGU/edit?usp=sharing To keep the review feedback and maintainer sign-offs easy to track in one place for Sandeep, it would be great if we could consolidate the remaining review on PR #532. @iamabhilaksh Great catch on the builder delegation and close() lifecycle handling. Would you mind pushing those commits onto branch blob-aws-metrics-publisher on #532? That way we can merge all these improvements together on the primary branch and keep everyone's co-authorship clean. Thanks! |
…ics interceptor Adds testBuildHttpClient_metricsOnlyInstallsPoolMetricsInterceptor, which builds the HttpClient through buildHttpClient(...) for a metrics-only builder and walks the Apache exec chain (execChain -> ProtocolExec.httpProcessor -> ImmutableHttpProcessor.responseInterceptors) to assert GcpConnectionPoolMetricsInterceptor is registered on the transport. The existing gate test only proves shouldConfigureHttpClient returns true; this closes the gap by verifying end-to-end that the interceptor is actually wired, per review feedback.
LihaoLiuXs
left a comment
There was a problem hiding this comment.
Why do we need to expose withMetricsPublisher(...)? Was this feature requested by a client, or is it to unblock the benchmark test?
|
Hi @LihaoLiuXs, Yes, this is a direct production requirement from my team (SeaS/Search). During heavy Solr indexing bursts, backups, and segment restores, connection pool starvation is one of our main culprits for I/O latency spikes and timeouts. Exposing withMetricsPublisher(...) gives us the visibility we need into transport pool saturation (LeasedConcurrency, queued requests, etc.) to size our connection pools properly and troubleshoot storage issues faster in prod. |
Summary
Adds an opt-in
withMetricsPublisher(...)hook that samples HTTP connection-pool utilization (max / leased / available / pending) and publishes cloud-agnosticMetricobjects, so operators can observe pool saturation uniformly across providers. Off by default, zero change when unset.MetricsPublisher,Metric,ConnectionPoolMetricsAwsMetricsPublisherAdapterbridges the nativeMetricPublisherSPIGcpConnectionPoolMetricsInterceptorsamples the Apache pool per response (installed only when the builder owns its transport; mirrors the default 200/20 pool sizing so enabling metrics never shrinks the pool)withMetricsPublisheronBlobStoreBuilderRelationship to #532
Reworks and supersedes @hxe-m's #532 — carries the same feature forward with a common-module abstraction and validation evidence. Retained as
Co-authored-by. Alibaba support is deferred to a follow-up (accuracy not yet validated on OSS).Validation