Skip to content

grpc/client: support custom call options - #2878

Merged
arjan-bal merged 19 commits into
grpc:masterfrom
arjan-bal:call-attributes
Oct 15, 2026
Merged

arjan-bal merged 19 commits into
grpc:masterfrom
arjan-bal:call-attributes

Conversation

@arjan-bal

@arjan-bal arjan-bal commented Sep 22, 2026 •

Copy link
Copy Markdown
Contributor

Adds a type-keyed map in CallOptions for passing arbitrary data between interceptors, the LB picker, and the upcoming config selector. It stores at most one value per type, keyed by TypeId, and is similar to http::Extensions.

Two properties are driven by the call path:

  • Send — interceptors are async, so the map must be holdable across .await points and movable between threads.
  • Clone — retries need an independent copy, so Clone is a deep copy and stored types are required to be Clone.

Unlike http::Extensions, stored values are not required to be Sync. This is mainly because the bumpalo::Bump type is !Sync. So the bound can't be satisfied if an arena is introduced later.

Why not an arena?

A bumpalo-backed implementation was prototyped and benchmarked against the Vec implementation for 5–15 attributes. The differences were small and inconsistent at those sizes, and the arena cost a new dependency plus substantially more unsafe, forged 'static lifetimes on arena-allocated boxes, MaybeUninit slot management, and hand-written drop ordering. Not worth it, so the plain Vec implementation is chosen.

Benchmarks

Lifecycle Benchmark (Creation, Insertions, Tear-down)

Item Count CallAttributes (Median) http::Extensions (Median) Time Difference Speedup / Advantage
5 items 153.74 ns 165.50 ns -11.76 ns ~1.08x faster (7.1% reduction)
10 items 312.16 ns 333.44 ns -21.28 ns ~1.07x faster (6.4% reduction)
15 items 412.07 ns 608.92 ns -196.85 ns ~1.48x faster (32.3% reduction)

Lookup Benchmark (Retrieval by Type)

Item Count CallAttributes (Median) http::Extensions (Median) Time Difference Speedup / Advantage
5 items 8.00 ns 20.75 ns -12.75 ns ~2.60x faster (61.5% reduction)
10 items 42.05 ns 46.99 ns -4.94 ns ~1.12x faster (10.5% reduction)
15 items 58.09 ns 72.14 ns -14.06 ns ~1.24x faster (19.5% reduction)

Comment thread grpc/src/call_attributes.rs Outdated
Comment thread grpc/src/client/load_balancing/mod.rs Outdated
Comment thread grpc/src/client/load_balancing/mod.rs Outdated
Comment thread grpc/src/client/load_balancing/mod.rs
Comment thread grpc/src/call_attributes.rs Outdated
//!
//! [`Attributes`](crate::attributes::Attributes) is a persistent, immutable
//! map shared between components: adding to it produces a new map.
//! [`CallAttributes`] instead it is mutated in place.

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.

Maybe we can come up with better names that describe what the different attribute types are/do, instead of where they're used?

Options?

Attributes -> ImmutableAttributes
CallAttributes -> Attributes

or

Attributes -> AttributeChain
CallAttributes -> AttributeMap

Or... is it feasible to merge these two types by adding a way to layer these new Attributes?

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.

How about using the prefix "Persistent" for the existing attributes and no prefix for the new ones? Some options:

  • PersistentAttributes and Attributes
  • PersistentTypeMap and TypeMap

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.

FWIW 'persistant' tends to make me think of things that live past the immediate scope of use - written to disk, or whatever. Maybe FixedAttributes? Though I like Doug's layering idea, too.

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.

"Persistent" is a technical term for data structures that preserve their previous versions when modified. They are immutable and rely on structural sharing to perform updates efficiently without copying the entire structure: https://en.wikipedia.org/wiki/Persistent_data_structure

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.

Sure, but the idea of persistance is any state that outlasts the process that creates it. It could be confusing, even if technically precise. I don't really have a horse in the race, though - just a thought for your consideration.

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.

Is it feasible to merge these two types by adding a way to layer these new Attributes?

No, I don't think merging them is beneficial. The two types are optimized for opposite workloads:

  • Attributes maximizes structural sharing to save memory. They're attached to every endpoint (there can be thousands) and are repeatedly extended by LB policies.
  • CallAttributes is optimized for fast insertion and lookup on the RPC path.

Structural sharing requires values to be Sync. We should avoid imposing that on per-call data so that we can introduce per-call arenas later. Merging the types would also hurt both sides:

  • RPC path: extra atomic refcount operations and extra pointer indirection.
  • Endpoint Attributes: larger per-entry size.

Other gRPC implementations also use separate data structures for the RPC and non-RPC paths:

  • Java: Attributes and CallOptions.
  • Go: attributes.Attributes and context.Context.
  • C++: ChannelArgs and call attributes (ServiceConfigCallData::CallAttributeInterface, read via ClientChannelLbCallState::GetCallAttribute<T>()).

I've renamed Attributes to ImmutableAttributes and CallAttributes to Attributes. I avoided AttributeChain since it leaks an implementation detail that may not be guaranteed in the future (such as if we transition to a persistent tree structure). This implementation detail is also not relevant to end users.

Comment thread grpc/src/call_attributes.rs Outdated
Comment thread grpc/src/call_attributes.rs Outdated
@dfawley dfawley assigned arjan-bal and unassigned dfawley Sep 30, 2026
Comment thread grpc/src/client/channel.rs Outdated
let result = &state.picker.pick(&headers);
let result = state
.picker
.pick(PickOptions::new(&headers, options.attributes_mut()));

@nathanielford nathanielford Sep 30, 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.

Given that this is in a loop, what happens if the pick() both modifies the call attributes and returns a Queue? What is the expected behavior there? Is it fine for the call attributes to aggregate writes to it while in a queuing 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.

Yes, since pickers can mutate the call attributes, mutations made by a queueing picker will get carried over to the next pick call.

I can think of a few solutions to this problem:

  1. "Trust" the queueing picker implementations to be idempotent.
  2. Pass &CallAttributes instead of &mut CallAttributes: If a picker needs to modify the CallAttributes, it must clone the existing ones first, modify the copy and delegate. To propagate changes from pickers to interceptors (C++ does this for session affinity), we will need to use interior mutability. Note that since CallAttributes is !Sync, &CallAttributes is !Send. However, this should not be a problem since the pick method is not async. Config selectors will get mutable references.
  3. Use the existing persistent Attributes struct. The picker takes ownership and returns attributes. The channel can simply discard the attributes returned by a queuing picker. This incurs the overhead of pointer chasing, ref-counting, and slightly higher memory overhead than a mutable, vector-backed data structure.
  4. Have the channel clone the attributes before calling pick. Choose which attributes to keep depending on whether the RPC is queued. This incurs a clone for each RPC.

For comparison, this is how other languages handle this:

  1. Java's CallOptions are immutable; they copy the options list to modify it. This is similar to option 3.
  2. Go uses Context.WithValue to wrap existing contexts. This is similar to option 3.
  3. C++ has mutable call attributes. This is similar to option 1.

@dfawley I wanted to get your thoughts on this.

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.

What about retry? Are we keeping an unmodified copy for retry in case we have to re-pick & re-run interceptors? How does C++ make sure SSA or call-attribute-modifying-interceptors work correctly with retry? (Do they make a copy?)

@arjan-bal arjan-bal Oct 9, 2026 •

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.

What about retry? Are we keeping an unmodified copy for retry in case we have to re-pick & re-run interceptors?

Presently, retry is modeled as an interceptor and invoke takes ownership of the CallOptions. So the CallOptions must be cloned:

let (send_stream, recv_stream) = invoker.invoke(headers.clone(), options.clone()).await;

This will result in the call attributes being cloned as well. C++ passes a mutable reference of the call attributes to filters, so it doesn't require copying them.

How does C++ make sure SSA or call-attribute-modifying-interceptors work correctly with retry?

In C++, the order execution is: config selector → SSA (and other xDS filters) → retry → one LB pick call per attempt.

Changes made before retry stay put. The SSA cluster override and the cookie address list are written once by the config selector and SSA filter, before the retry layer. Every attempt's pick sees the same values.

The LB policy writes to the shared attribute on each attempt. xds_override_host's picker overwrites the actual_address_list_ on each attempt based on whether the SSA override was applied or not.

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 made the attributes ref in PickOptions immutable, so pickers can't mutate them. Only the config selector and interceptors can mutate the call attributes now.

@dfawley dfawley removed their assignment Oct 2, 2026
@arjan-bal arjan-bal assigned dfawley and unassigned arjan-bal Oct 9, 2026

@dfawley dfawley left a comment

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.

Maybe we avoid the churn and keep Attributes as-is, and call these new things CallOptions since....do we have any other intended use for them? Let's discuss offline.

@arjan-bal arjan-bal assigned arjan-bal and unassigned dfawley Oct 14, 2026
@arjan-bal

Copy link
Copy Markdown
Contributor Author

Based on offline discussions, I've moved the accessors to the CallOptions struct to avoid exposing a second type-map struct.

@arjan-bal arjan-bal assigned dfawley and unassigned arjan-bal Oct 14, 2026
nathanielford added a commit to nathanielford/grpc-rust-nford that referenced this pull request Oct 14, 2026
@dfawley dfawley assigned arjan-bal and unassigned dfawley Oct 14, 2026
@arjan-bal arjan-bal changed the title grpc: Add Call Attributes grpc/client: support custom call options Oct 15, 2026
@arjan-bal
arjan-bal merged commit 6c38fac into grpc:master Oct 15, 2026
30 checks passed
@arjan-bal
arjan-bal deleted the call-attributes branch October 15, 2026 09:41
nathanielford added a commit that referenced this pull request Oct 15, 2026
## Motivation

Implementation of [XDS Cluster
Manager](https://github.com/grpc/proposal/blob/master/A31-xds-timeout-support-and-config-selector.md)

## Solution

Leverages ChildManager to manage child policies. Carries functionality
to parse ClusterManager-specific configuration, do the resolver fanout
and picker assemblage and handles the per-rpc routing and enforcement of
drops on missing/unknown attributes/clusters.

### Notes
* Currently this PR relies on two other PRs for refactors that make this
job a lot easier: #2878, #2887.
* Deferring removed-child grace period. See #2890.
* Config Selector being landed is required for correctness around
removal of clusters.

---------

Co-authored-by: Arjan Bal <arjansbal@google.com>
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.

3 participants