Skip to content

(refactor) Pick First LB state machine - #2784

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

(refactor) Pick First LB state machine#2784
nathanielford wants to merge 13 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.

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.

Correct me if I'm wrong, but if a newly built LB (in PickFirstState::Idle), with an empty address list, gets a resolver update that errors we could get here without having addresses.

I also changed the handling if we are idle, have no addresses, or are already in transient failure to enter SteadyState, to avoid being in the Idle state during transient failure.

Comment thread grpc/src/client/load_balancing/pick_first.rs Outdated
}

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?

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.

I think all of these have been addressed now:

  1. Line 125 (TODO: should steady_state be a "mode" selector enum...):
    • This was the primary motivation for this PR, replacing mutable fields with the PickFirstState enum (Idle, FirstPass, SteadyState, Ready).
  2. Line 217 (TODO: prevent redundant IDLE updates?):
    • This is now being handled by state node typestates; IdleState::enter() publishes picker state only on entry and update.
  3. Line 258 (TODO: avoid this update if we are in TF (i.e. sticky TF)?):
    • Sticky TRANSIENT_FAILURE is now isolated inside SteadyState, which maintains the FailingPicker without emitting CONNECTING updates until a subchannel reaches READY. So I think this is handled.
  4. Line 266 (TODO: set the last connection error?):
    • SteadyState::enter explicitly accepts last_error: String and passes the most recent error to set_failing_picker.
  5. Line 461 (TODO: make error mandatory.):
    • ctx.set_failing_picker(&error) takes &str instead of Option.
  6. Line 592 (TODO: is it safe to assume any call to work() while idle means we should connect?):
    • I think this should be safe because IdlePicker is the sole scheduler in IDLE. This is mapped in IdleState::work via self.exit_idle(ctx). But if you can think of another scenario let me know!

Comment thread grpc/src/client/load_balancing/pick_first.rs Outdated
Comment thread grpc/src/client/load_balancing/pick_first.rs
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.

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.

I've had this switch to SteadyState with empty values, which should handle this properly, I think, but please double check. There is also a new test around 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.

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.

I've had it enter a fresh first pass regardless here. I think that covers the cases necessary.

@nathanielford

Copy link
Copy Markdown
Contributor Author

I think I've addressed everything so far. Let me know if you have any further feedback!

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