Skip to content
This repository was archived by the owner on Jul 22, 2026. It is now read-only.

Add potential clients cache to avoid recomputation on every request - #1151

Merged
jcleezer merged 11 commits into
masterfrom
thorth/potential-clients-cache
Apr 23, 2026
Merged

Add potential clients cache to avoid recomputation on every request#1151
jcleezer merged 11 commits into
masterfrom
thorth/potential-clients-cache

Conversation

@TylerHorth

@TylerHorth TylerHorth commented Feb 27, 2026

Copy link
Copy Markdown
Contributor

Summary

SimpleLoadBalancer.getPotentialClients() is a hotspot in production profiles. It runs O(n) loops on every request (n = total hosts in partition) to build a Map<URI, TrackerClient> by iterating all URIs, filtering banned hosts, resolving TrackerClients, and applying subsetting. All inputs to this computation change only on state events (host join/leave, config updates), yet the work was repeated on every request.

This PR moves the O(n) computation into the state-change event handlers in SimpleLoadBalancerState, precomputing and caching the result in a ConcurrentHashMap. The request path becomes a single O(1) map lookup. The feature is gated behind a new config flag enablePotentialClientsCache (default: false) so it can be rolled out incrementally.

Key changes:

  • SimpleLoadBalancerState maintains a _potentialClientsCache keyed by (serviceName, scheme, partitionId), rebuilt eagerly by subscriber callbacks after URI, service, or cluster property updates
  • SimpleLoadBalancer.getPotentialClients() checks the cache first, falling back to the existing O(n) logic when the cache is disabled or returns null
  • New enablePotentialClientsCache flag plumbed through D2ClientConfigD2ClientBuilderZKFSTogglingLoadBalancerFactoryImplSimpleLoadBalancerState
  • Removed expensive LogUtil.debug() calls from the hot path (Logger.isDebugEnabled() is costly at high call rates)

Testing Done

  • 30 new property-style tests in PotentialClientsCacheTest verify equivalence between the cached and uncached code paths across 12 input scenarios varying URI count (1–20), cluster-banned count (0–10), and service-banned count (0–5)
  • Lifecycle tests verify cache rebuild on URI changes, ban-list changes, service removal, and multi-service-per-cluster behavior
  • All existing d2 tests pass

@TylerHorth
TylerHorth marked this pull request as ready for review February 27, 2026 02:21
* On rebuild, the entire inner map for a service is replaced atomically, which
* naturally removes stale entries for partitions that no longer exist.
*/
private final ConcurrentMap<String, Map<String, Map<Integer, Map<URI, TrackerClient>>>> _potentialClientsCache;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Looks like outermap is only concurrentMap, would this cause any race conditions on inner maps ?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The inner maps are immutable, so no risk for race conditions

@TylerHorth
TylerHorth force-pushed the thorth/potential-clients-cache branch from ec6dc7c to 1bddfed Compare April 8, 2026 18:00
@TylerHorth
TylerHorth force-pushed the thorth/potential-clients-cache branch from 6de10e7 to 8e0df88 Compare April 14, 2026 16:58
TylerHorth and others added 11 commits April 21, 2026 17:49
…okup

SimpleLoadBalancer.getPotentialClients() previously ran O(n) loops on
every request to build the Map<URI, TrackerClient> by iterating all URIs,
filtering banned hosts, and applying subsetting. This work is now done
eagerly in response to state-change events (URI/service/cluster property
updates) and cached in SimpleLoadBalancerState, making the request path
a single ConcurrentHashMap lookup.

The feature is gated behind a new config flag `enablePotentialClientsCache`
(default: false) plumbed through D2ClientConfig -> D2ClientBuilder ->
ZKFSTogglingLoadBalancerFactoryImpl -> SimpleLoadBalancerState. When
disabled, the existing O(n) fallback logic is used unchanged.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
30 tests verifying the precomputed cache produces results identical to
the original O(n) per-request computation. Uses DataProvider to sweep
across scenarios varying URI count (1-20), cluster-banned count (0-10),
and service-banned count (0-5). Each scenario creates two load balancers
(cache enabled vs disabled) and asserts equivalence.

Also includes lifecycle tests for cache rebuild on URI/ban-list changes,
invalidation on service removal, and multi-service-per-cluster behavior.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The LogUtil.debug() check calls Logger.isDebugEnabled() which is
expensive on the hot request path.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The previous assertPotentialClientsEquivalent reimplemented the filtering
logic in the test rather than exercising the actual old code path. Replace
with drainAllRoutableHosts which calls getClient() 1000 times with varied
request URIs to discover all hosts each LB can route to, then compares
the full sets. The cached LB hits the precomputed cache; the uncached LB
falls through to the original O(n) fallback in SimpleLoadBalancer.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…ries

Replace flat ConcurrentMap<PotentialClientsKey, ...> with nested
serviceName -> scheme -> partitionId -> Map<URI, TrackerClient>.

On rebuild, the entire inner map for a service is replaced atomically,
which naturally drops entries for partitions that no longer exist.
Invalidation becomes a single O(1) remove(serviceName) instead of
scanning all keys with removeIf. PotentialClientsKey class is deleted.

Adds test that verifies stale partition entries are removed when a URI
update drops a partition.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
47 tests covering all cache scenarios: end-to-end equivalence with
exhaustive host draining (24 scenarios), equivalence after state changes,
URI/service/cluster property lifecycle, stale partition removal,
multi-service and multi-cluster isolation, overlapping bans, cache
immutability, disabled fallback, rapid updates, and boundary lookups.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…y paths

The enablePotentialClientsCache flag was only plumbed through the ZK
factory path (ZKFSTogglingLoadBalancerFactoryImpl), so the potential
clients cache was never activated when using XDS or LastSeen load
balancers. This propagates the flag through all factory code paths:

- XdsFsTogglingLoadBalancerFactory: add enablePotentialClientsCache
  parameter and pass it to SimpleLoadBalancerState
- XdsLoadBalancerWithFacilitiesFactory: pass config.enablePotentialClientsCache
  when constructing XdsFsTogglingLoadBalancerFactory
- LastSeenBalancerWithFacilitiesFactory: pass config.enablePotentialClientsCache
  when constructing SimpleLoadBalancerState
- ZKFSTogglingLoadBalancerFactoryImpl: add new backwards-compatible constructor
  with enablePotentialClientsCache appended (preserving existing constructor)

Adds parameterized tests covering ZK and XDS factory paths with cache
enabled/disabled, exercising the real factory classes and verifying
behavior through getPotentialClients().

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@TylerHorth
TylerHorth force-pushed the thorth/potential-clients-cache branch from 8e0df88 to a3ea0d9 Compare April 22, 2026 00:52
return result;
}

private Map<URI, TrackerClient> buildPotentialClientsSubsetting(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Is it possible to leverage getPotentialClientsSubsetting for re-use here in some capacity?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The implementation isn't identical, but yes we could likely refactor to reuse some of previous code. However, then we'd need to do dark cluster testing again. I'd rather:

  1. Use current solution
  2. Ramp globally via global config
  3. Once stable, delete the old code

What do you think?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Sounds good

@shivamgupta1 shivamgupta1 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

LGTM

@jcleezer
jcleezer merged commit 713602c into master Apr 23, 2026
2 checks passed
@jcleezer
jcleezer deleted the thorth/potential-clients-cache branch April 23, 2026 17:34
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants