Skip to content

perf: skip evaluation events nobody is subscribed to - #387

Merged
ppicom merged 2 commits into
Unleash:mainfrom
Meemaw:perf/skip-unsubscribed-event-dispatch
Oct 20, 2026
Merged

ppicom merged 2 commits into
Unleash:mainfrom
Meemaw:perf/skip-unsubscribed-event-dispatch

Conversation

@Meemaw

@Meemaw Meemaw commented Sep 23, 2026 •

Copy link
Copy Markdown
Contributor

We call isEnabled on hot request paths, and noticed that every call schedules a task on the shared executor to publish a ToggleEvaluated event whether or not anything is listening. With several threads evaluating at once they all pile up on that one queue lock.

EventDispatcher already knows the subscriber at construction time, and every method on UnleashSubscriber is a default no-op, so it can work out up front whether an event type will reach anything. If the subscriber inherits the default for both on(UnleashEvent) and the callback that event publishes to, there is nothing to deliver and the dispatch is skipped.

Only ToggleEvaluated and ImpressionEvent are gated — those are the two that fire per evaluation. Everything else (onReady, togglesFetched, errors, metrics, backups) dispatches exactly as before. If the check can't be resolved for any reason it counts as overridden and we dispatch, so no callback is ever silently dropped.

DefaultUnleash also asks before constructing the event, which skips the UUID.randomUUID() in the ImpressionEvent constructor.

Numbers

JMH on DefaultUnleash.isEnabled, bootstrapped toggles, polling and metrics off. JDK 21, -XX:ActiveProcessorCount=6, yggdrasil-engine 1.0.3. The subscriber here only implements onReady:

threads 12.3.0 this PR time alloc
plain toggle 1 1969 ns / 2690 B 1498 ns / 2540 B -24% -6%
toggle with impressionData 1 2196 ns / 2996 B 1501 ns / 2592 B -32% -13%
plain toggle 6 8205 ns / 2628 B 7570 ns / 2488 B -8% -5%
toggle with impressionData 6 10105 ns / 2935 B 7996 ns / 2408 B -21% -18%

With no subscriber configured at all (the default NoOpSubscriber) it goes 1897 -> 1480 ns at one thread, so -22%.

Worth saying plainly: the six-thread rows are noisier than they look. On the machine I ran this on, most of the remaining time sits inside the native engine, which serialises on its own lock, so the Java-side win is partly hidden there. Happy to rerun somewhere else if that would help. The benchmark harness isn't part of this PR — I can push it if you'd like it tracked.

Notes

  • An exact getClass() == NoOpSubscriber.class check would have been simpler, but it misses a case we hit in practice: a subscriber that exists only to react to onReady (refreshing some config after the first fetch) still pays the full per-evaluation cost. That's what prompted this.
  • isSubscribedTo is new public API on EventDispatcher. If you'd rather not expose it, the gate inside dispatch on its own gets most of the win — it just doesn't avoid the event allocation.
  • EventDispatcherTest covers the default subscriber, onReady-only, toggleEvaluated-only, impression-only, catch-all on(), a NoOpSubscriber subclass that overrides a callback, and non-evaluation events.

🤖 Generated with Claude Code

Every isEnabled/getVariant call enqueues a ToggleEvaluated task on the
shared UnleashScheduledExecutor, even when the configured subscriber
doesn't implement toggleEvaluated. The enqueue takes the executor's
queue lock, allocates a FutureTask plus queue and condition nodes and
wakes a worker, so all evaluating threads end up contending on one lock.

Every method on UnleashSubscriber is a default no-op, so EventDispatcher
can work out once, at construction, whether an event type reaches
anything: an event is skipped only when the subscriber inherits the
default for both on(UnleashEvent) and the specific callback that event
publishes to. Only ToggleEvaluated and ImpressionEvent are gated, every
other event type always dispatches, and anything that cannot be resolved
counts as overridden so no callback is ever silently dropped.

DefaultUnleash checks isSubscribedTo before constructing the event,
which also skips the UUID.randomUUID() in the ImpressionEvent
constructor.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@ppicom

ppicom commented Sep 28, 2026

Copy link
Copy Markdown

Hi there @Meemaw 👋 Thanks a lot for the contribution, let us take a look at the PR and get back to you.

@ppicom ppicom left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

good work, @Meemaw, I just have some concerns around the public guard. Let me know what you think.

if (eventDispatcher.isSubscribedTo(IsEnabledImpressionEvent.class)
&& response.map(r -> r.impressionData).orElse(false)) {
eventDispatcher.dispatch(new IsEnabledImpressionEvent(toggleName, enabled, context));
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

I understand your argument: you avoid the allocation of an event that no one will receive by guarding dispatch here in isEnabled instead of inside of dispatch.

My main concern around that is the breaking of an atomic operation. Clients rely on the convention of "you have to call isSubscribedTo before calling dispatch", which is a convention. And conventions tend to be forgotten unless enforced.

I'd advise that we move this guard inside the EventDispatcher and that we keep it private. The benefits of the maintainability of that, I think, far exceed the shavings in memory footprint. You're already kind of doing it for shouldDispatch, so it should be okay.

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.

Agreed on keeping the guard inside EventDispatcher — dropped isSubscribedTo. To avoid paying for event construction (incl. UUID.randomUUID() for impression events) when nobody listens, EventDispatcher now exposes dispatchToggleEvaluated / dispatchIsEnabledImpression / dispatchVariantImpression, which check and build atomically. JMH (interleaved, 3 rounds): all PR variants are ~25–35% faster than main; guarding inside dispatch(UnleashEvent) alone costs ~150–180 ns/call on impression-data flags vs. the typed methods.

Drop the public isSubscribedTo so callers can't forget to check it.
Per-evaluation events now go through typed dispatch methods that take
the event's fields and only construct the event when something will
receive it, keeping the evaluation path allocation-free for events
nobody listens to.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@ppicom ppicom left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Thanks, @Meemaw, appreciate the work. Merging!

@github-project-automation github-project-automation Bot moved this from In Progress to Approved PRs in Issues and PRs Oct 20, 2026
@ppicom
ppicom merged commit 33c327b into Unleash:main Oct 20, 2026
10 checks passed
@github-project-automation github-project-automation Bot moved this from Approved PRs to Done in Issues and PRs Oct 20, 2026
@Meemaw

Meemaw commented Oct 20, 2026

Copy link
Copy Markdown
Contributor Author

@ppicom Thanks. Possible to get this released?

ppicom commented Oct 20, 2026

Copy link
Copy Markdown

On it!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

3 participants