Fix unbounded growth of closed-connection tracking in client factories#1510
Fix unbounded growth of closed-connection tracking in client factories#1510sreekanth-db wants to merge 1 commit into
Conversation
closeConnection() left a tombstone entry per (uuid, type) in the static HTTP client map forever, and the telemetry factory tracked closed UUIDs in an unbounded set. Both grew without bound in high-churn/connection-pool deployments, eventually causing OutOfMemoryError. HTTP client entries are now removed on close, and closed-connection tracking moved to a WeakHashMap-backed set (keyed by the connection UUID) in both factories, so markers are reclaimed once the connection is unreachable. Use-after-close still returns the sentinel/Noop (issue #1325). Co-authored-by: Isaac Signed-off-by: Sreekanth Vadigi <sreekanth.vadigi@databricks.com>
| ClosedConnectionHttpClient.class, factory.getClient(ctx, HttpClientType.COMMON)); | ||
| } | ||
| // No live-client entries accumulate across many open/close cycles. | ||
| assertEquals(0, factory.liveClientCount()); |
There was a problem hiding this comment.
The only new test asserts factory.liveClientCount() == 0, but liveClientCount() returns instances.size() — a different map from the closedConnections set this PR actually changes. closeConnection() already does instances.remove(...), so this assertion is guaranteed by construction and would pass identically for a strong-keyed unbounded set (i.e. the pre-fix code). Nothing measures closedConnections, and use-after-close returns the sentinel via the first check (getClient line 59), so the test never exercises the WeakHashMap reclamation either.
Why it matters: the specific memory leak this PR claims to fix — unbounded growth of the closed-connection set — could regress and every test would still pass green, shipping the OOM back to high-churn users.
- Suggested fix: add
@VisibleForTesting int closedConnectionCount()(returnsclosedConnections.size()); after many open/close cycles with UUIDs that go out of scope,System.gc(), and a bounded wait/retry, assert the set shrinks well below N (or hold aWeakReferenceand assert it becomes null).
Posted by code-review-squad · /full-review · feedback: #code-review-squad-feedback
| getClientKey(context.getConnectionUuid(), type), | ||
| k -> new DatabricksHttpClient(context, type)); | ||
| String uuid = context.getConnectionUuid(); | ||
| if (closedConnections.contains(uuid)) { |
There was a problem hiding this comment.
Both closed-connection sets became Collections.synchronizedSet(newSetFromMap(new WeakHashMap<>())). getClient calls closedConnections.contains(uuid) up to twice per invocation (lines 59 and 65); getTelemetryClient calls it on every telemetry export. The old telemetry set was lock-free ConcurrentHashMap.newKeySet(). Every contains() now acquires a single intrinsic monitor and runs WeakHashMap.expungeStaleEntries() under that lock — serializing exactly the high-churn / connection-pool workload this PR targets.
- Suggested fix: keep reads lock-free — a
ConcurrentHashMap-backed structure with periodic pruning, or a lock-free fast path in front of the weak set. At minimum, collapse the two HTTPcontains()calls (the post-computeIfAbsentrecheck is only needed for a newly-created client).
Posted by code-review-squad · /full-review · feedback: #code-review-squad-feedback
| SimpleEntry<String, HttpClientType> key = getClientKey(uuid, type); | ||
| IDatabricksHttpClient client = | ||
| instances.computeIfAbsent(key, k -> new DatabricksHttpClient(context, type)); | ||
| if (closedConnections.contains(uuid)) { |
There was a problem hiding this comment.
The new post-computeIfAbsent undo branch (lines 65-71: instances.remove(key, client) + closeQuietly + return sentinel) is never reached by a deterministic test. The new test closes before the second getClient, so the sentinel is returned by the first check (line 59), never this branch. testConcurrentCloseAndGetClientDoesNotLeak is timing-dependent and does not assert liveClientCount()==0.
Why it matters: a bug here (wrong remove key, missing closeQuietly, inverted condition) would leak a live client + socket for a connection racing with close — the exact class from issue #1325 — with no failing test.
- Suggested fix: spy on the
computeIfAbsentmapping function to callcloseConnectionmid-creation, then assertgetClientreturnsClosedConnectionHttpClient.INSTANCEANDliveClientCount()==0.
Posted by code-review-squad · /full-review · feedback: #code-review-squad-feedback
| * (issue #1325). Weak keys: an entry is reclaimed once the connection's UUID becomes unreachable | ||
| * (the closed connection can no longer be used), so this does not grow without bound. | ||
| */ | ||
| private final Set<String> closedConnections = |
There was a problem hiding this comment.
WeakHashMap reclaims by key-object reachability, but contains() matches by .equals(). The design works only because getConnectionUuid() returns the same final String connectionUuid field object on every call (DatabricksConnectionContext.java:59,948-949). If a future caller passes an .equals()-equal but distinct String (a pooled/rehydrated/decorator context, or new String(uuid)) after the original context is GC'd, contains() returns false and getClient/getTelemetryClient will silently recreate a live client for a closed connection — defeating issue #1325's use-after-close protection. The regression test reuses the same mock object, so it never exercises the equal-but-distinct-string path.
- Suggested fix: document the invariant on the field ("keys must be the identical String returned by
getConnectionUuid()"), and add a test that closes with one context and queries with a second context returning an.equals()-equal-but-distinct string; or key the weak set on the context object itself.
Posted by code-review-squad · /full-review · feedback: #code-review-squad-feedback
Code Review Squad — ReviewScore: 80/100 — MODERATE RISK Sound, well-reasoned memory-leak fix — the WeakHashMap keyed on the context's own final UUID String reclaims closed-connection markers exactly when the connection can no longer be used (security & language reviewers independently confirmed no correctness/security regression). Two substantive concerns survive verification: the regression test measures the wrong map ( Could not post inline comment for: F5 — see body below. Medium FindingsF5 —
|
Summary
DatabricksHttpClientFactory.closeConnection()left a tombstone entry per(uuid, type)in its static map and never removed it (3 per closed connection), andTelemetryClientFactorytracked closed UUIDs in an unbounded set. Both grew without bound in high-churn / connection-pool deployments, eventually causingOutOfMemoryError. (The HTTP clients themselves were already closed — what leaked was the per-connection map/set entries.)Changes
DatabricksHttpClientFactory:closeConnection()now removes the live client entries instead of replacing them with tombstones. Closed-connection detection moved to aWeakHashMap-backed set keyed by the connection UUID, so entries are reclaimed once the connection is unreachable.getClient()returns theClosedConnectionHttpClientsentinel for closed connections, with a guard for the close-during-create race.TelemetryClientFactory: sameWeakHashMap-backed set forclosedConnectionUuids(was an unboundednewKeySet()).Behavior preserved
The use-after-close protection from #1325/#1333 is intact: after
close(),getClientreturns the sentinel andgetTelemetryClientreturnsNoopTelemetryClient. The closed-marker is keyed by the connection UUID (unique per connection), so pooled connections sharing a URL are not affected.Testing
DatabricksHttpClientTest,TelemetryHttpClientLeakTest, telemetry suites — all pass.This pull request and its description were written by Isaac.