Skip to content

(refactor) Pick First LB state machine - #2784

Open
nathanielford wants to merge 6 commits into
grpc:masterfrom
nathanielford:refactor/pick-first-state-machine
Open

(refactor) Pick First LB state machine#2784
nathanielford wants to merge 6 commits into
grpc:masterfrom
nathanielford:refactor/pick-first-state-machine

Conversation

@nathanielford

Copy link
Copy Markdown
Contributor

Motivation

The initial implementation of the PickFirst LB was a little tangled in how it implemented the Happy Eyeballs requirements. This meant it was handling some phases with special state and others without. Overall it made it hard to reason about.

Further, there was abehavior gaps between the original implementation and the spec: the original discards unselected addresses upon entering READY, causing subsequent reconnections from IDLE to only attempt the single failed address instead of sweeping all resolved backends.

Solution

This implementation is fairly different, using a state machine to hold state for each of the four states of Happy Eyeballs (Idle, FirstPass, SteadyState and Ready), and being clear about state transitions between them. The construction ensures the retention of appropriate information (i.e. addresses), and makes things easier to reason about.

As a bonus, this pass also eliminates a bunch of allocations that were unneeded. Gemini estimates about a 70% smaller memory footprint. As a double plus bonus, it now passes linting checks.

@nathanielford
nathanielford requested a review from arjan-bal July 30, 2026 22:33
@nathanielford
nathanielford marked this pull request as ready for review July 30, 2026 22:33
@arjan-bal arjan-bal self-assigned this Aug 3, 2026

@arjan-bal arjan-bal 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.

Leaving initial comments, haven't reviewed the entire PR.

Comment on lines +225 to +227
PickFirstState::FirstPass(s) => !s.addresses.is_empty(),
PickFirstState::SteadyState(s) => !s.addresses.is_empty(),
PickFirstState::Ready(s) => !s.addresses.is_empty(),

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 believe in all these cases, addresses must be present and the value can directly be true.

FirstPassState::fresh_enter(ctx, addresses)
}

#[allow(clippy::unused_self)]

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.

self is used in this method, so #[allow(clippy::unused_self)] can be avoided.

}

fn work(self, ctx: &mut PickFirstContext<'_>) -> PickFirstState {
self.exit_idle(ctx)

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.

There was a TODO about checking if it's safe to assume every call to work should trigger exit_idle. Has this been confirmed?

Also, all the TODOs seem to be removed, have they been addressed?

Comment on lines +726 to +727
#[cfg(test)]
impl PickFirstPolicy {

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.

Can we move this to the test mod below?

Comment on lines +315 to +317
) -> PickFirstState {
FirstPassState::fresh_enter(ctx, addresses)
}

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.

Pickfirst should remain in idle state unless the idle picker is used or the channel calls exit_idle.

Err(e) => {
self.state = PickFirstState::Idle(IdleState {
addresses: Vec::new(),
});

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.

Pick-first should probably enter one of the Transient Failure (TF) states here.

When a valid resolver update arrives while the balancer is in TF, it should start using the new address list immediately. However, if the balancer is in IDLE, it should wait until exit_idle() is called. We should add tests to verify this.

Comment on lines +335 to +336
ctx.controller.request_resolution();
return PickFirstState::Idle(self);

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.

The balancer must transition the channel out of IDLE here. The IDLE picker only triggers exit_idle() once. If the PF balancer remained in IDLE on receiving resolver updates, it will remain permanently stuck in the IDLE state.

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.

2 participants