Skip to content

Cache sticky endpoint channels#2993

Open
schlosna wants to merge 5 commits into
developfrom
davids/sticky-cache-clean
Open

Cache sticky endpoint channels#2993
schlosna wants to merge 5 commits into
developfrom
davids/sticky-cache-clean

Conversation

@schlosna

@schlosna schlosna commented May 15, 2026

Copy link
Copy Markdown
Contributor

Before this PR

Dialogue clients that make heavy use of sticky channels incur a significant construction overhead (e.g. see palantir/conjure-java#2869 ) leading to per-endpoint TypeMarker
anonymous classes, ExceptionDeserializerArgs instances, and Deserializer fields,
causing excessive allocations on high throughput services.

This adds unnecessary overhead for conjure clients that use sticky channels such as ⛰️ clients via e.g. DataServiceAsync#of(com.palantir.dialogue.EndpointChannelFactory, com.palantir.dialogue.ConjureRuntime).

See #2647 which was a basis for this PR.

After this PR

==COMMIT_MSG==

Summary

  • Introduce StickyEndpointChannelsFactory interface — replaces the Supplier<Channel> return type from StickyEndpointChannels2.create() and DialogueChannel.stickyChannels() with a typed stickyChannel() method. DialogueChannel now directly implements this interface, and DialogueChannel.stickyChannels() is deprecated in favor of stickyChannel().
  • Extract CachingEndpointChannelFactory — encapsulates the Caffeine LoadingCache<Endpoint, EndpointChannel> with delegate::endpoint as the loader into a reusable EndpointChannelFactory implementation. Cache size is configurable via Config.maxEndpointCacheSize() (default 1000), and caching is bypassed when set to <= 0.
  • Simplify StickyEndpointChannels2 internals — removes the StickyEndpointChannels2EndpointFactorySupplier and StickyEndpointChannel inner classes; queue override attachment and sticky routing are now inlined in StickyChannel2.endpoint(), reducing indirection.
  • Update ReloadingClientFactoryInternalDialogueChannel now extends StickyEndpointChannelsFactory instead of declaring its own stickyChannels() method, and callers use the new stickyChannel() API directly.

Test plan

  • Existing StickyEndpointChannels2Test passes (caching behavior is identical, just encapsulated)
  • SimulationTest passes and regenerated simulation resources reflect updated sticky channel behavior
  • ReloadingClientFactoryTest passes with the new stickyChannel() API

==COMMIT_MSG==

Possible downsides?

This encapsulates a cache of Endpoint -> EndpointChannel so there will be some memory overhead; however, use of sticky channels implies these routes intend to stick around in memory and amortize overhead for benefit of throughput.

@changelog-app

changelog-app Bot commented May 15, 2026

Copy link
Copy Markdown

Generate changelog in changelog/@unreleased

Type (Select exactly one)

  • Feature (Adding new functionality)
  • Improvement (Improving existing functionality)
  • Fix (Fixing an issue with existing functionality)
  • Break (Creating a new major version by breaking public APIs)
  • Deprecation (Removing functionality in a non-breaking way)
  • Migration (Automatically moving data/functionality to a new system)

Description

Cache sticky endpoint channels

Check the box to generate changelog(s)

  • Generate changelog entry

@schlosna schlosna force-pushed the davids/sticky-cache-clean branch 2 times, most recently from ef6dbdb to 0617986 Compare May 15, 2026 14:19
@schlosna schlosna force-pushed the davids/sticky-cache-clean branch from 0617986 to 860dd5f Compare May 15, 2026 14:21
@schlosna schlosna changed the title Davids/sticky cache clean Cache sticky endpoint channels May 15, 2026
@changelog-app

changelog-app Bot commented May 22, 2026

Copy link
Copy Markdown

Successfully generated changelog entry!

Need to regenerate?

Simply interact with the changelog bot comment again to regenerate these entries.


📋Changelog Preview

💡 Improvements

  • Cache sticky endpoint channels (#2993)

@schlosna schlosna marked this pull request as ready for review May 22, 2026 15:52
@schlosna schlosna requested review from a team, aldexis, fsamuel-bs and pkoenig10 May 22, 2026 15:53
/** Maximum number of cached endpoint channels. Values {@code <= 0} disable caching. */
@Value.Default
default int maxEndpointCacheSize() {
return 1_000;

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.

Thoughts on starting with this at 0 to keep parity with existing logic, and testing a different value in some prod environment, before changing the default?

I also wonder whether 1000 is enough/too much.

Comment on lines +82 to +84
/** Maximum number of cached endpoint channels. Values {@code <= 0} disable caching. */
@Value.Default
default int maxEndpointCacheSize() {

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.

This seems to be specific to sticky channels, but the javadoc doesn't surface that at all. cached endpoint channels is instead pretty generic. It may be worth adding some clarification.

Additionally, I don't think users can modify this config directly, so we may want to pipe it through DialogueChannel.Builder

Comment on lines +32 to +35
this.cache = Caffeine.newBuilder()
.maximumSize(maxEndpointCacheSize)
.weakValues()
.build(delegate::endpoint);

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.

side-note: it may be worth starting to use https://github.com/palantir/cache for new caches like this (which iirc gives us instrumentation by default, which could be interesting to have regardless, since that would help identify cases where the cache size may be inappropriate)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

That library doesn't support weak values (and I'm not sure I want to support weak values there). Using Caffeine feels more appropriate here.

Comment on lines -258 to -261
@Override
public String toString() {
return "StickyEndpointChannel{delegate=" + delegate + '}';
}

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.

Seeing this makes me wonder whether anybody may have been logging the channel, as a way to debug stuff, and whether the loss of this override may cause debugging pain (since you're now returning request -> router.execute(request, endpointWithQueueOverride);)

Comment on lines +17 to +21
package com.palantir.dialogue.core;

import com.palantir.dialogue.Channel;

public interface StickyEndpointChannelsFactory {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Change the package and make the name non-plural to be consistent with EndpointChannelFactory.

Also this returns Channel, not EndpointChannel.

Suggested change
package com.palantir.dialogue.core;
import com.palantir.dialogue.Channel;
public interface StickyEndpointChannelsFactory {
package com.palantir.dialogue;
import com.palantir.dialogue.Channel;
public interface StickyChannelFactory {

Comment on lines +59 to +62
int maxEndpointCacheSize = cf.maxEndpointCacheSize();
EndpointChannelFactory channelFactory = (maxEndpointCacheSize <= 0)
? delegate
: new CachingEndpointChannelFactory(delegate, maxEndpointCacheSize);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This feels a bit like overkill to me.

If we want to keep this, I'd consider making the CachingEndpointChannelFactory constructor private and pushing this logic into a static factory method there, so callers don't need to be concerned with this.

}
}

private static final class StickyEndpointChannel implements EndpointChannel {

@pkoenig10 pkoenig10 May 26, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

nit: Can we keep this? Having non-anonymous classes is helpful for telemetry/debugging because it has a nice name in stacktraces and has a toString implementation.

@@ -50,7 +50,8 @@ public Channel getChannel(Simulation simulation, Supplier<Map<String, Simulation

public Supplier<Channel> getSticky2NonReloading(Simulation simulation, Map<String, SimulationServer> servers) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This return type should probably be changed to use the new factory interface we've introduced. Then we can just do return dialogueChannelWithDefaults(...).

sticky = StickyEndpointChannels2.create(config, nodeSelectionChannel, endpointChannelFactory);
StickyEndpointChannelsFactory stickyEndpointChannelsFactory =
StickyEndpointChannels2.create(config, nodeSelectionChannel, endpointChannelFactory);
sticky = () -> stickyEndpointChannelsFactory.stickyChannel();

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Same here - we should get rid of this Supplier indirection because it just makes the code harder to understand. Tests can just call stickyChannel() instead of get().

@aldexis

aldexis commented Jun 2, 2026

Copy link
Copy Markdown
Contributor

@schlosna What is the current status of this PR? We've noticed similar things across a number of services, as the largest allocation pressure (especially on smaller stacks), and I'm keen to figure out if we can reduce/remove this

Comment on lines 307 to 310
@Override
public <T> T sticky(Class<T> clientInterface) {
return session().sticky(clientInterface);
}

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.

@schlosna If I'm correct, our usage looks roughly like

Supplier<DialogueService> serviceSupplier = () -> {
    DialogueClients.StickyChannelSession stickySession = stickyChannelFactory2.session();
    return stickySession.sticky(DialogueService.class);
};

and most of the allocations actually from the ExceptionDeserializerArgs that get created in the dialogue clients upon instantiation.

I don't think this PR would actually change that fact, even if we cache the channel, because the client itself would be recreated everytime?

@aldexis aldexis Jun 8, 2026

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.

I've added a benchmark in #3021 showing that

  • Exception mapper make up ~75% of the allocations (probably lower/higher depending on how many errors actually exist - this is for ~100 errors)
  • Channels make up another ~25%
  • The rest (including TypeMarker and deserializer allocations) seem to make up ~1.5% of the current allocation profile

Seems like we need both this and palantir/conjure-java#2869 or instead cache the dialogue client creation itself (which maybe we should do? Creating tons of dialogue clients isn't particularly efficient either)

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.

3 participants