Skip to content

fix(spanner): bound the location-aware routing caches (P0-5)#8

Open
fornwall wants to merge 2 commits into
mainfrom
fix/p0-5-routing-cache-unbounded-memory
Open

fix(spanner): bound the location-aware routing caches (P0-5)#8
fornwall wants to merge 2 commits into
mainfrom
fix/p0-5-routing-cache-unbounded-memory

Conversation

@fornwall

Copy link
Copy Markdown
Owner

Issue

P0-5 — Unbounded routing-cache memory growth (per-finder cache + SoftReference finder map), from the Spanner Java client review. Two coupled defects across the two layers of the location-aware routing cache produced a single failure mode: routing memory grows without bound until the heap fills.

  1. The per-database KeyRangeCache was never trimmed. The ranges TreeMap and groups map grow on every server CacheUpdate; the eviction method shrinkTo(int) had zero production callers (confirmed — only the declaration and a unit test existed). Scan-heavy or wide-key workloads accumulate an entry (~200–400 B) per distinct range the server ever reports, cleared only when the database id changes.
  2. The ChannelFinder map used SoftReference pressure as its eviction policy. With (1), each per-database finder grows until the heap is nearly exhausted, triggers full-GC thrash, is dropped wholesale, then rebuilt. Independently, stale references were only reaped every 1024th getOrCreateChannelFinder call, so finders for no-longer-accessed databases lingered.

One correction to the review entry: the finder's recipe caches (KeyRecipeCache) were already bounded (Guava caches, 1000 entries each) — the unbounded per-finder state was the KeyRangeCache alone.

Fix

  • KeyRangeCache: addRanges now calls the existing sampled-LRU shrinkTo when the cache exceeds a 10,000-range cap (DEFAULT_MAX_RANGES), shrinking 10% below the cap so the O(size) eviction cost is amortized over many insertions.
  • KeyAwareChannel: the per-database finder map is now a strongly referenced Guava cache bounded by size (100 finders, LRU) and expire-after-access (30 minutes, driven by the same ticker as the transaction-affinity cache so tests can control it). The removal listener marks the evicted finder stale and calls EndpointLifecycleManager.unregisterFinder. shutdown()/shutdownNow() invalidate all finders.
  • ChannelFinder: new markStale(). A stale finder drops async cache updates and stops publishing active addresses. The active-address publish now happens while holding the finder's updateLock, which makes the eviction ordering airtight: a concurrent publish either completes before markStale() returns (and is then superseded by the finder-generation bump in the subsequent unregisterFinder) or observes the stale flag and skips publishing. Without this, an in-flight call still holding an evicted finder could re-register endpoints under a dead finder key, leaking them permanently.

Eviction of a finder that in-flight calls still reference is safe: routing lookups on it are read-only, and its dropped cache just means those calls fall back to the default channel until the cache is re-learned by a fresh finder.

Tests

  • KeyRangeCacheTest.addRangesEvictsWhenOverMaxRanges — the cache never exceeds the cap during sustained addRanges traffic, stays within the hysteresis band, keeps the most recently added range resident, and every resident range still routes to its own server.
  • ChannelFinderTest.markStaleDropsAsyncUpdates / markStaleStopsPublishingActiveAddressesToLifecycleManager.
  • KeyAwareChannelTest.channelFinderExpiresAfterInactivity — end-to-end: a learned route works, then after 31 ticker-minutes of inactivity the finder (and its routing cache) is gone and the query falls back to the default channel.

Full google-cloud-spanner module suite: 9778 tests, 0 failures (JDK 21). fmt:check clean.

🤖 Generated with Claude Code

https://claude.ai/code/session_01QkZzotf4wMQKXEhEpTLRKL

fornwall and others added 2 commits July 19, 2026 16:40
The per-database KeyRangeCache grew without bound: the eviction method
shrinkTo(int) had no production callers, so the ranges TreeMap and groups
map accumulated an entry for every distinct range the server ever
reported. Wire shrinkTo to a 10k-range cap (with 10% hysteresis to
amortize the O(size) eviction cost) at the end of addRanges.

The per-database ChannelFinder map relied on SoftReference pressure as
its eviction policy, so the unbounded caches above filled the heap and
caused full-GC thrash before being dropped wholesale; stale references
were also only reaped every 1024th lookup. Replace it with a strongly
referenced Guava cache bounded by size (100 finders, LRU) and
expire-after-access (30 minutes). Evicted finders are marked stale so an
in-flight call that still references one cannot re-register endpoints
with the lifecycle manager after unregisterFinder has run; the
active-address publish now happens under the finder's update lock to
make that ordering airtight.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QkZzotf4wMQKXEhEpTLRKL
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant