Skip to content

Register each User-Agent entry once to stop per-connection latency growth - #1714

Open
vuanhphung wants to merge 2 commits into
mainfrom
fix-useragent-otherinfo-growth
Open

vuanhphung wants to merge 2 commits into
mainfrom
fix-useragent-otherinfo-growth

Conversation

@vuanhphung

@vuanhphung vuanhphung commented Oct 3, 2026 •

Copy link
Copy Markdown
Collaborator

Description

UserAgentManager.setUserAgent() runs on every Driver.connect() and calls the SDK's UserAgent.withOtherInfo(), which appends to a static list without deduplicating. UserAgent.asString() then formats that entire list under a global lock on every HTTP request, from both the Thrift transport and the SDK ApiClient. In a long-running JVM that opens a connection per query, request latency and lock contention grow with every connection the JVM has ever opened.

This registers each key/value entry with the SDK once per JVM. Distinct UserAgentEntry values are capped at 64, so applications that pass a unique value per connection stay bounded. The driver logs once at WARN when the cap is first hit; SQL Execution API requests still send their own entry, and client-type entries are not capped. Below the cap, the rendered User-Agent header is unchanged, since asString() already deduplicates.

Testing

Unit tests

  • UserAgentManagerTest: 1,000 repeated setUserAgent() calls register each entry with the SDK exactly once (verified with mockStatic(UserAgent.class, CALLS_REAL_METHODS)), and the entry still appears in the header. Distinct customer entries stop at 64 while client-type entries are still registered.
  • DatabricksSdkClientUserAgentTest: past the cap, an SQL Execution API request still carries its own UserAgentEntry right after os/.
  • Mutation check: disabling the dedup fails the repeat test, and disabling the cap fails both cap tests.
  • UserAgentManagerTest, UserAgentOrderingTest, DatabricksSdkClientUserAgentTest and DatabricksSdkClientTest pass.

Reproduction against a SQL warehouse

A single long-lived JVM runs 16 threads, each looping Driver.connect() → SELECT 1 → close() for 2 minutes (~3.7k connections), with OAuth token passthrough, a UserAgentEntry, and JFR (-XX:StartFlightRecording=settings=profile). Every 10 s it logs per-phase latency, the size of the SDK's UserAgent.otherInfo list, and the time of one getUserAgentString() call. A control run trims otherInfo by reflection to separate its effect from warehouse, network and TLS variance.

3.4.1 3.4.1, list trimmed this PR
SDK user-agent list size at end 7,280 2–8 2
connect p50 slope, 2nd half (ms per 1k connections) +8.4 −1.5 +2.1
close p50 slope, 2nd half (ms per 1k connections) +10.1 +0.2 +1.5
worker CPU samples in UserAgent.asString 70% 0.4% 0.8%
monitor waits ≥10 ms on the list 949 (13.8 s) 0 0

A third 3.4.1 run seeded the list with 40k simulated prior connections. Connect p50 rose to ~2 s, close to ~1.3 s and execute to ~0.8 s, against a ~245 / 81 / 160 ms baseline; 97% of worker CPU samples were in asString, and threads spent 803 s in monitor waits on the list over 60 s.

Telemetry Errors

  • Not applicable — this PR does not add or change a telemetry-visible error.

Additional Notes to the Reviewer

  • Customer entries still live in JVM-global SDK state, so Thrift requests carry other connections' entries (pre-existing). Injecting the customer segment per connection on Thrift, as the SQL Execution API path already does, would remove both the shared state and the cap; I'd suggest that as a follow-up.
  • SDK 0.106.0 and 0.118.0 both have the unbounded append in UserAgent.withOtherInfo.
  • To reproduce with the uber jar, build with mvn clean package; an incremental build reused stale classes from assembly-uber/target.

This pull request and its description were written by Isaac.

UserAgent.withOtherInfo appends to a static list on every call, and UserAgent.asString formats the whole list under a global lock on every HTTP request. Calling it from setUserAgent on each connection made request latency grow with the number of connections opened in the JVM. Register each entry once and cap distinct UserAgentEntry values at 64.

Signed-off-by: Vu Anh Phung <vu.phung@databricks.com>
Co-authored-by: Isaac <no-reply@databricks.com>

@peco-review-bot peco-review-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: 1 Low

Looks good — a well-scoped fix for the per-connection User-Agent list growth, with correct dedup/cap logic (duplicates don't inflate the counter, SDK append precedes set insertion, method is synchronized) and good test coverage. One low note: the 64-entry cap is global per-JVM, so high-cardinality multi-tenant JVMs could silently drop customer attribution after 64 distinct entries.

Comment thread src/main/java/com/databricks/jdbc/common/util/UserAgentManager.java Outdated
Log once at WARN when the 64-entry cap is first hit and note the cap in the changelog. Verify withOtherInfo call counts with a static mock instead of reflecting into UserAgent.otherInfo, reset the registry around each test, and add an SEA test past the cap.

Signed-off-by: Vu Anh Phung <vu.phung@databricks.com>
Co-authored-by: Isaac <no-reply@databricks.com>

@peco-review-bot peco-review-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: 1 Low

Looks good — a well-scoped, well-tested fix for the per-connection User-Agent growth, with sound synchronization, correct cap/dedup ordering, and matching changelog + unit coverage. Only one low, edge-case note about the dedup Set sharing a namespace between client-type and customer entries. The known limitation that customer entries remain JVM-global (so the cap is cross-workspace, and Thrift carries other connections' entries) is already acknowledged in the PR description as a follow-up.

}

private static synchronized void registerOtherInfo(
String key, String value, boolean isCustomerEntry) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Low — The dedup Set and the registeredCustomerEntries counter are shared across both client-type and customer entries. Because the key is key + "/" + value, a customer who passes Java/THttpClient or Java/SQLExecHttpClient as their UserAgentEntry would collide with the client-type entry: it would be deduped against (or dedupe) the client entry and never increment the customer count. This is an unlikely edge case (customer prefixes are typically their app name, not Java), and the net effect is benign, but it means the cap accounting is keyed on the full key/value string rather than being namespaced per entry type. Not blocking — flagging for awareness.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant