Skip to content

Implement PickFirst load balancer#2570

Open
nathanielford wants to merge 31 commits intohyperium:masterfrom
nathanielford:implement/PickFirstLB
Open

Implement PickFirst load balancer#2570
nathanielford wants to merge 31 commits intohyperium:masterfrom
nathanielford:implement/PickFirstLB

Conversation

@nathanielford
Copy link
Copy Markdown
Collaborator

@nathanielford nathanielford commented Mar 25, 2026

Motivation

Full implementation of the pick first load balancer, including 'Happy eyeballs' features.

Solution

Load balancing implementation to pick the first available endpoint to connect to, maintaining stickiness across endpoint updates if configured. Handles accepting new LB configuration and subchannel reconstruction.

Prototype is at https://github.com/nathanielford/grpc-rust-testbed/tree/main/pick_first_lib

Notes

  • Ended up including all happy eyeball features because it wasn't clear where best to slice the line. Considering this a full implementation, and it should be reviewed as such.
  • This does use tokio::spawn and tokio::time, which may need to be replaced to make things runtime agnostic. Please comment in the PR whether this is the case.

@nathanielford nathanielford requested a review from dfawley March 25, 2026 15:30
@nathanielford nathanielford self-assigned this Mar 25, 2026
@nathanielford nathanielford requested review from arjan-bal and removed request for dfawley March 25, 2026 16:17
@nathanielford nathanielford force-pushed the implement/PickFirstLB branch 3 times, most recently from a442272 to 73397ff Compare March 25, 2026 17:29
Copy link
Copy Markdown
Collaborator

@arjan-bal arjan-bal left a comment

Choose a reason for hiding this comment

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

Leaving initial comments while I review the remaining changes.

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

#[cfg(test)]
mod test {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

There are some tricky edge cases found by Go's e2e-style tests that we discovered when implementing a new dual-stack pick_first LB in Go. I added unit tests for them to ensure the balancer behaved as expected. These tests are mainly in two files:

It may not be possible to write e2e-style tests in Rust right now since we don't have the same test utilities as Go. However, we can still test the same scenarios using a mock channel.

I would recommend seeing if Gemini can convert these Go tests into tests for Rust, skipping the Happy Eyeballs ones. This would act as a conformance test. I did something similar to generate tests for credentials in the following way:

  1. Clone gRPC Go.
  2. Point the Gemini CLI to the test files for gRPC Go and gRPC Rust.
  3. Ask Gemini to create similar test cases for gRPC Rust.

If it gives decent results without much effort, that's great; otherwise, we can improve test coverage later.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

I think I was able to do this in the tests, particularly the last two tests in pick_first.rs. LMK if you think something is misaligned with these!

@arjan-bal arjan-bal removed their assignment Mar 30, 2026
@nathanielford nathanielford force-pushed the implement/PickFirstLB branch from 73397ff to bdf7fc3 Compare April 6, 2026 19:32
@nathanielford nathanielford force-pushed the implement/PickFirstLB branch from 7ea10ad to e1bcaf4 Compare April 27, 2026 20:55
@nathanielford nathanielford requested a review from arjan-bal April 27, 2026 21:38
@nathanielford nathanielford marked this pull request as ready for review April 27, 2026 21:39
@arjan-bal arjan-bal assigned arjan-bal and unassigned nathanielford Apr 28, 2026
…A61 endpoint handling

This commit implements the PickFirst load balancer policy for Tonic gRPC, focusing on:
- Efficient subchannel management with backoff preservation.
- "Stickiness" support: continuing to use an existing Ready subchannel if it remains in resolver updates.
- Compliance with gRFC A61: endpoints are now shuffled before being flattened into an address list, ensuring multiple addresses for a single endpoint (e.g., IPv4/IPv6) stay together.
- Clean state reset: subchannels and selected state are now cleared when receiving an empty address list.
- Alignment with the updated synchronous testing framework in master.

Includes comprehensive test coverage for basic connection, failover, stickiness, exhaustion, deterministic endpoint shuffling, de-duplication, and empty updates.
…active failover

This change enhances the PickFirst load balancing policy to better support
gRFC A61 (Happy Eyeballs) and improve connection establishment latency.

Key changes:
- Implement IPv6/IPv4 address interleaving in `compile_address` to ensure
  subsequent connection attempts alternate between protocol families.
- Introduce a `subchannel_states` cache in `PickFirstPolicy` to track the
  connectivity status of managed subchannels.
- Refactor connection logic to use a `frontier_index` and proactively skip
  subchannels known to be in `TransientFailure` (e.g., during backoff).
- Update `advance_frontier` to safely maintain the index within the bounds
  of the address list, ensuring the policy remains reactive to recovery.
- Add deterministic unit tests for shuffling and interleaving logic.
Comment thread grpc/src/client/load_balancing/pick_first.rs Outdated
Comment on lines +195 to +198
let error = self
.last_connection_error
.clone()
.unwrap_or_else(|| "all addresses in transient failure".to_string());
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I think if we update self.last_connection_error with an error message in the following two events, it is guaranteed to be present:

  1. When a subchannel starts in TRANSIENT_FAILURE.
  2. When a subchannel enters TRANSIENT_FAILURE.

Then we can call .expect() and avoid using a custom error message generated by the LB.

Comment thread grpc/src/client/load_balancing/pick_first.rs Outdated
Comment on lines +397 to +399
// Update the cache for all updates.
self.subchannel_states
.insert(subchannel.address(), state.clone());
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Before inserting, we should first ensure that the address is part of the current list of subchannels. If not, the function should return early. This can happen when a subchannel update has been queued by the channel while a resolver updated arrives, removing the address.

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

// Steady State Retries: Automatically connect when a subchannel goes IDLE.
if self.steady_state.is_some() && state.connectivity_state == ConnectivityState::Idle {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

nit: It would be good to group the handling of steady_state and the first pass into an if/else block, and handle common transitions (e.g., READY or READY->IDLE) before this block. This would allow readers to understand the first pass and steady-state behavior separately.

// Steady State Failures: Count failures across all subchannels.
if let Some(ref mut ss) = self.steady_state {
if state.connectivity_state == ConnectivityState::TransientFailure {
if ss.record_failure(self.subchannels.len()) {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

nit: As a minor simplification, we can pass the channel controller to record_failure and let it handle requesting re-resolution itself.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

I moved all the steady-state stuff into the steady state handler. I think that it makes it a bit cleaner, to keep all the state tracking there. LMK if you think something is leaky though!

if let Some(next_sc) = self.advance_frontier(false) {
let next_sc = next_sc.clone(); // Clone to avoid borrow issues
self.trigger_subchannel_connection(next_sc, channel_controller);
} else {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

We need to cancel the timer in the else case also.It can probably happen before the if/else block.

Comparing the references of timer_handle with the Go implementation, we also need to abort the timer when the LB policy is dropped and when a new resolver update arrives.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

This should all be handled - though do please audit all the possible cases!

@nathanielford nathanielford force-pushed the implement/PickFirstLB branch from 880fb6f to 9cbb1a4 Compare May 4, 2026 23:19
@nathanielford nathanielford force-pushed the implement/PickFirstLB branch from b02264a to 6698786 Compare May 6, 2026 23:54
@nathanielford
Copy link
Copy Markdown
Collaborator Author

Note that this now includes #2631, which will merge first so as to keep a clearer record. However, I needed the fix to get the tests ported from Go to work.

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants