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 —
|
|
This PR has been marked as Stale because it has been open for 30 days with no activity. If you would like the PR to remain open, please remove the stale label or comment on the PR. |
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.