xds/resolver: drop field references on Close to prevent retention across ClientConn recycle - #9301
Conversation
…oss ClientConn recycle When callers repeatedly Close a ClientConn dialed with an xds:/// target and immediately redial a fresh one (as bigtable's ConnectionRecycler does every few minutes), the closed xdsResolver instances are pinned in memory across recycles. Over hours this accumulates significant heap: in the standalone reproduction at github.com/sushanb/bigtable-recycle-repro (50 DirectPath channels, 10s recycle interval, 8h run), retained heap grew from ~90 MiB to 6.3 GiB on grpc-go v1.81.1 baseline. The retention is via multiple field references off *xdsResolver that survive Close: r.cc (-> ClientConn's ServiceConfig chain), r.curConfigSelector (-> ServiceConfig via UpdateState), and transitively r.dm / r.xdsClient watcher metadata held by the shared xdsclient.DefaultPool. Individually each field is a benign back-reference; together they form multiple retention paths through externally-held state. Go's GC handles cycles, but these are external-anchored chains, not cycles. Explicitly dropping the field references at Close leaves the resolver struct with no outbound edges and allows the entire per-channel resolver + CDS balancer subtree (~40 CallbackSerializers, JSON-parsed configs, attribute chains, cloned address slices per channel) to be collected. In the same 8h A/B, retained heap growth dropped 86%-91%: HeapAlloc growth baseline +5487 MiB -> with fix +475 MiB (91% less) HeapSys growth baseline +6243 MiB -> with fix +891 MiB (86% less) HeapObjects baseline +61.5M -> with fix +5.9M (90% less) This is a distinct, orthogonal bug from grpc#9140 (activeClusters refcount on early stream failure). Both should ship. Includes a regression test that reproduces the "shared xdsClient pool + close/redial one of many channels" pattern in-process. The synthetic setup is much smaller than production DirectPath, so per-iter numbers are within GC noise regardless of the fix; the test guards against a future regression that would grow retained heap by >=20 KiB per close+redial. Full evidence including hourly heap snapshots and pprof composition diff lives at github.com/sushanb/bigtable-recycle-repro.
|
|
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## master #9301 +/- ##
==========================================
+ Coverage 87.54% 87.57% +0.03%
==========================================
Files 429 429
Lines 30622 30636 +14
==========================================
+ Hits 26807 26830 +23
+ Misses 3814 3806 -8
+ Partials 1 0 -1
🚀 New features to boost your workflow:
|
|
@mbissa : Could you please prioritize this review. GCS is affected by this memory leak. |
|
Hi @sushanb , thank you for your contribution and for opening this PR! Before we can move forward, could you please sign the CLA? |
|
I signed the cla. @eshitachandwani |
|
For
Can we instead try to free up the service config , maybe by doing something like |
Per review feedback on grpc#9301: release the resolver-provided ConfigSelector at ClientConn.Close by swapping to a defaultConfigSelector{nil}. The old ConfigSelector transitively retains resolver-owned state (parsed service configs, cluster/plugin maps for xds; equivalents for other resolvers), so this helps every resolver, not just xds. The existing xdsResolver.Close field cleanup remains necessary because the resolver itself is still pinned by the shared xdsClient pool's watcher metadata.
|
Thanks @eshitachandwani — good call, pushed as Kept the Build + |
eshitachandwani
left a comment
There was a problem hiding this comment.
LGTM modulo 2 comments. Adding @easwars as a second reviewer.
| if err != nil { | ||
| t.Fatalf("%s: grpc.NewClient failed: %v", label, err) | ||
| } | ||
| cc.Connect() |
There was a problem hiding this comment.
We dont need this cc.Connect. The client will connect itself on the first RPC.
There was a problem hiding this comment.
Dropped in efecd06 — you are right, the following EmptyCall with WaitForReady handles it.
| // comfortably below the ~60 KB / iter observed in the standalone repro. | ||
| const maxBytesPerIter = 20 * 1024 | ||
| if perIterInuse > maxBytesPerIter { | ||
| t.Errorf("close+redial retained %d bytes/iter of HeapInuse over %d iterations (limit %d) — see googleapis/google-cloud-go#14582", |
There was a problem hiding this comment.
I dont think we should include the issue in logs.
There was a problem hiding this comment.
Dropped the suffix in efecd06 — failure message is self-contained now.
- Remove cc.Connect() in buildAndConnect — the following EmptyCall with WaitForReady triggers the initial connection on its own; the explicit Connect was redundant. - Drop the "see googleapis/google-cloud-go#14582" suffix from the failure message so test output stays self-contained.
|
Can we also |
| if testing.Short() { | ||
| t.Skip("skipping heap-growth stress test under -short") | ||
| } |
There was a problem hiding this comment.
Our CI pipeline does not set the -test.short flag and I don't think we want this test to run for every CI run, which happens for every push on every PR. I can see two options here:
- Change the
testing.ymlfile to set the-test.shortflag - Remove this test from OSS and move it to g3, and ensure this is a g3 only file
I prefer the latter. Let me know your thoughts.
Per easwars' review: nil out m.watcher and m.xdsClient at the end of DependencyManager.Close so the manager doesn't retain the resolver (ConfigWatcher) or the xDS client after shutdown. Every callback that touches those fields already re-checks m.stopped under m.mu, so the nil assignments are safe once m.stopped is set.
|
Done in 80dac1c — added |
|
@sushanb : The tests are failing with a panic now. |
The unsubscribe closure returned by SubscribeToCluster is wrapped in sync.OnceFunc and may run from the balancer tree Close after DependencyManager.Close has already torn everything down and dropped m.watcher. Before this change, the post-Close invocation reached maybeSendUpdateLocked and panicked on m.watcher.Update. Add an m.stopped guard at the top of unsubscribeFromCluster to match the pattern used by every other post-Close callback in this file. Restores the test/xds suite that started failing after 80dac1c.
|
Fixed in 8b015fa. Root cause: after 80dac1c nils out Fix adds Verified locally with |
easwars
left a comment
There was a problem hiding this comment.
LGTM, modulo where to place the test and how to run it.
| runtime.GC() | ||
| runtime.GC() |
There was a problem hiding this comment.
Why do we need two calls to this?
| cc, err := grpc.NewClient("xds:///"+serviceName, | ||
| grpc.WithTransportCredentials(insecure.NewCredentials()), | ||
| grpc.WithResolvers(r), | ||
| ) |
There was a problem hiding this comment.
Nit: Single line. See: go/go-style/guide#line-length
| t.Logf("heap after %d warm-up + %d stress iterations (persistent channel held throughout):", | ||
| warmup, iterations) | ||
| t.Logf(" HeapInuse: baseline=%d after=%d delta=%d (%d bytes/iter)", | ||
| mBase.HeapInuse, mAfter.HeapInuse, deltaInuse, perIterInuse) | ||
| t.Logf(" HeapSys : baseline=%d after=%d delta=%d (%d bytes/iter)", | ||
| mBase.HeapSys, mAfter.HeapSys, deltaSys, perIterSys) | ||
| t.Logf(" goroutines: baseline=%d after=%d delta=%d", | ||
| goroutinesBase, goroutinesAfter, goroutinesAfter-goroutinesBase) | ||
| t.Logf(" heap profiles: baseline=%s after=%s", baselinePath, afterPath) | ||
| t.Logf(" diff: go tool pprof -top -flat -inuse_space -diff_base %s %s", | ||
| baselinePath, afterPath) |
There was a problem hiding this comment.
Nit: Single line for every call to t.Logf
| t.Errorf("close+redial retained %d bytes/iter of HeapInuse over %d iterations (limit %d)", | ||
| perIterInuse, iterations, maxBytesPerIter) |
There was a problem hiding this comment.
Nit: Single line here too.
Removing the in-repo regression test; it will be submitted separately. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…close-drop-field-refs # Conflicts: # internal/xds/resolver/xds_resolver.go
Summary
When callers repeatedly
Close()aClientConndialed with anxds:///target and immediately redial a fresh one (as Bigtable'sConnectionRecyclerdoes every few minutes), the closed*xdsResolverinstances are pinned in memory across recycles. Over hours this accumulates significant heap. On grpc-go v1.81.1 baseline, an 8-hour run of the recycle reproduction (50 DirectPath channels, 10s recycle interval) grew retained heap from ~90 MiB → 6.3 GiB.Full reproduction: https://github.com/sushanb/bigtable-recycle-repro
Root cause
Retention is via multiple field references off
*xdsResolverthat surviveClose:r.cc→ theClientConn, whoseServiceConfigstill transitively references the resolverr.curConfigSelector→ captured by theClientConn'sServiceConfigviaUpdateStater.dm/r.xdsClient→ watcher metadata held by the sharedxdsclient.DefaultPoolsingleton, which survives every ClientConn lifetimeIndividually each field is a benign back-reference; together they form multiple independent retention chains through externally-anchored state. Go's tracing GC handles cycles fine, but these aren't cycles — they're chains rooted at package-global state (the shared xdsClient pool) and other ClientConns that stay alive.
Explicitly dropping the field references at the end of
Closeleaves the resolver struct with no outbound edges. The entire per-channel resolver + CDS balancer subtree (roughly 40CallbackSerializers, JSON-parsed service configs,attributes.Attributeschains, cloned[]resolver.Addressslices per channel) becomes GC-collectible.Impact (measured)
Same repro, same workload, 8-hour A/B, 50 channels, 10s recycle interval, DirectPath xDS engaged:
Hourly heap snapshots and
pprof -basecomposition diffs are in the linked repro.Related but distinct
This is orthogonal to #9140 (activeClusters refcount on early stream failure). Both bugs exist in v1.83; both should ship. Applying #9140 alone does NOT fix this leak — verified with an 8-hour run against grpc-go master @ the #9140 merge commit
89d4d61e.Test
Included
internal/xds/balancer/cdsbalancer/e2e_test/close_redial_leak_test.go— reproduces the "sharedxdsclient.Pool+ close-and-redial one of many channels" pattern in-process:NewXDSResolverWithPoolForTestingwith a shared pool so every dial sub/unsubscribes against the same refcounted xdsClient (matches production wiring;NewXDSResolverWithConfigForTestingwould build a fresh pool per iteration and hide the leak surface).ClientConnpins the pool refcount above zero across the run, mirroring the "N-1 other channels stay open" invariant from Bigtable.HeapInusegrowth < 20 KiB.Caveat: the synthetic in-process xDS server is much smaller than production DirectPath (a handful of clusters vs many, minimal endpoint set), so per-iter numbers are within GC noise regardless of whether the fix is applied. The test's primary purpose is a future-regression guard at 20 KiB/iter — any change that reintroduces a retention chain of the observed magnitude (~60 KB/iter in the standalone repro) will exceed that threshold. The primary evidence for the fix's magnitude is the standalone-repro A/B linked above.
Test plan
go test -run "Test/CloseRedialDoesNotRetainXDSState" ./internal/xds/balancer/cdsbalancer/e2e_test/passes with the fix./internal/xds/resolver/...build cleanRELEASE NOTES: None