Skip to content

Commit 8214e0c

Browse files
committed
grpc, balancer, rls: implement gRFC A110 child channel options (Go)
Introduces the mechanism from gRFC A110 (Child Channel Options) for propagating user-provided DialOptions to internal child channels created by an LB policy (RLS's control channel is the motivating example). Prior to this change, stats handlers or interceptors configured on the parent ClientConn had no way to reach the RLS control channel, so per-attempt telemetry (e.g. grpc.client.attempt.duration) did not cover the RouteLookup RPC. New public API: * grpc.WithChildChannelOptions(opts ...DialOption) DialOption — a parent-channel DialOption that lists DialOptions to be applied to any internal child channels the parent (or its resolvers/LB policies) opens. Opaque to the parent per A110: the options are NOT applied to the parent itself. * balancer.BuildOptions.ChildChannelOptions ([]any, holding grpc.DialOption) — how gRPC hands those options down to LB policies that open their own child channels. Typed as []any to avoid an import cycle with the grpc package; balancers cast each element. RLS's control channel now: * Applies balancer.BuildOptions.ChildChannelOptions as its baseline DialOptions (so user-provided stats handlers / interceptors take effect on RouteLookup RPCs), then layers RLS-mandatory options (authority, creds, service config) on top so they win on conflict. * Adds grpc.WithChildChannelOptions(...) on top so any channels the control channel itself opens continue to inherit the same options. Regression test: TestRLSControlChannelChildChannelOptions covers both the positive case (WithChildChannelOptions -> RouteLookup RPCs appear in grpc.client.attempt.duration) and the negative case (without it, RouteLookup RPCs are NOT observed by the parent's stats handler, enforcing A110's "opaque to P" contract). Verified end-to-end against a real Bigtable DirectPath workload: 99 RouteLookup attempts to dns:///bigtablerls.googleapis.com show up in grpc.client.attempt.duration with method, target, and status labels populated correctly. RELEASE NOTES: * grpc: add WithChildChannelOptions DialOption per gRFC A110 to configure DialOptions that should be applied to internal child channels created by LB policies (e.g. the RLS control channel) or resolvers. * balancer: add BuildOptions.ChildChannelOptions so LB policies that open their own child channels can plumb the parent's child-channel options through. * rls: RouteLookup RPCs issued on the RLS control channel now honor DialOptions passed via grpc.WithChildChannelOptions, so per-attempt telemetry (e.g. grpc.client.attempt.duration) can cover them.
1 parent 9d1988d commit 8214e0c

5 files changed

Lines changed: 168 additions & 8 deletions

File tree

balancer/balancer.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,17 @@ type BuildOptions struct {
216216
// same resolver.Target as passed to the resolver. See the documentation for
217217
// the resolver.Target type for details about what it contains.
218218
Target resolver.Target
219+
// ChildChannelOptions contains DialOptions to apply to any internal child
220+
// channels (for example, an RLS balancer's control channel) created by
221+
// this LB policy. Balancers that open their own child channels should
222+
// pass these options to grpc.NewClient AND propagate them further via
223+
// grpc.WithChildChannelOptions so any nested internal channels also
224+
// inherit them. See gRFC A110: Child Channel Options.
225+
//
226+
// The element type is google.golang.org/grpc.DialOption, typed as any
227+
// here to avoid an import cycle between the balancer and grpc packages.
228+
// Balancers cast each element to grpc.DialOption when passing it on.
229+
ChildChannelOptions []any
219230
}
220231

221232
// Builder creates a balancer.

balancer/rls/control_channel.go

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -138,12 +138,21 @@ func (cc *controlChannel) OnMessage(msg any) {
138138

139139
// dialOpts constructs the dial options for the control plane channel.
140140
func (cc *controlChannel) dialOpts(bOpts balancer.BuildOptions, serviceConfig string) ([]grpc.DialOption, error) {
141+
// Per gRFC A110, user-provided child channel options are applied first as
142+
// a baseline, then RLS's mandatory internal options (authority, creds,
143+
// service config) are applied last so they win on conflict for
144+
// single-value settings. Additive DialOptions (stats handlers,
145+
// interceptors) accumulate from both. WithChildChannelOptions is also
146+
// re-added so any channels this control channel opens itself inherit
147+
// the same set recursively.
148+
dopts := childDialOptionsFromBOpts(bOpts)
149+
141150
// The control plane channel will use the same authority as the parent
142151
// channel for server authorization. This ensures that the identity of the
143152
// RLS server and the identity of the backends is the same, so if the RLS
144153
// config is injected by an attacker, it cannot cause leakage of private
145154
// information contained in headers set by the application.
146-
dopts := []grpc.DialOption{grpc.WithAuthority(bOpts.Authority)}
155+
dopts = append(dopts, grpc.WithAuthority(bOpts.Authority))
147156
if bOpts.Dialer != nil {
148157
dopts = append(dopts, grpc.WithContextDialer(bOpts.Dialer))
149158
}
@@ -180,6 +189,26 @@ func (cc *controlChannel) dialOpts(bOpts balancer.BuildOptions, serviceConfig st
180189
return dopts, nil
181190
}
182191

192+
// childDialOptionsFromBOpts returns the DialOptions to apply to the RLS
193+
// control channel that come from balancer.BuildOptions.ChildChannelOptions
194+
// (per gRFC A110), plus a WithChildChannelOptions wrapper so nested channels
195+
// keep inheriting them. Returns nil when no child options were provided.
196+
func childDialOptionsFromBOpts(bOpts balancer.BuildOptions) []grpc.DialOption {
197+
if len(bOpts.ChildChannelOptions) == 0 {
198+
return nil
199+
}
200+
child := make([]grpc.DialOption, 0, len(bOpts.ChildChannelOptions))
201+
for _, o := range bOpts.ChildChannelOptions {
202+
if do, ok := o.(grpc.DialOption); ok {
203+
child = append(child, do)
204+
}
205+
}
206+
// Include the options twice: once to apply on this channel, and once
207+
// wrapped in WithChildChannelOptions so any channels opened by this
208+
// control channel also inherit them.
209+
return append(child, grpc.WithChildChannelOptions(child...))
210+
}
211+
183212
func (cc *controlChannel) close() {
184213
cc.dropConnStateSubscriber()
185214
cc.cc.Close()

balancer/rls/metrics_test.go

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -373,3 +373,79 @@ func (s) TestRLSFailedRPCMetric(t *testing.T) {
373373
}
374374
}
375375
}
376+
377+
// TestRLSControlChannelChildChannelOptions verifies the gRFC A110 plumbing:
378+
// when a user passes an OpenTelemetry stats handler DialOption via
379+
// grpc.WithChildChannelOptions on the parent channel, the RLS control channel
380+
// records grpc.client.attempt.duration data points for RouteLookup RPCs. The
381+
// negative case is also covered: without WithChildChannelOptions, no
382+
// RouteLookup metric data point should appear (the parent's own OTel handler
383+
// must not leak onto the child channel under A110's "opaque to P" rule).
384+
func (s) TestRLSControlChannelChildChannelOptions(t *testing.T) {
385+
rlsServer, _ := rlstest.SetupFakeRLSServer(t, nil)
386+
rlsConfig := buildBasicRLSConfigWithChildPolicy(t, t.Name(), rlsServer.Address)
387+
backend := &stubserver.StubServer{
388+
EmptyCallF: func(context.Context, *testpb.Empty) (*testpb.Empty, error) {
389+
return &testpb.Empty{}, nil
390+
},
391+
}
392+
if err := backend.StartServer(); err != nil {
393+
t.Fatalf("Failed to start backend: %v", err)
394+
}
395+
defer backend.Stop()
396+
rlsConfig.RouteLookupConfig.DefaultTarget = backend.Address
397+
398+
const rlsMethod = "grpc.lookup.v1.RouteLookupService/RouteLookup"
399+
newClient := func(t *testing.T, useChildOpts bool) *metric.ManualReader {
400+
r := startManualResolverWithConfig(t, rlsConfig)
401+
reader := metric.NewManualReader()
402+
provider := metric.NewMeterProvider(metric.WithReader(reader))
403+
mo := opentelemetry.MetricsOptions{MeterProvider: provider}
404+
otelDO := opentelemetry.DialOption(opentelemetry.Options{MetricsOptions: mo})
405+
dialOpts := []grpc.DialOption{
406+
grpc.WithResolvers(r),
407+
grpc.WithTransportCredentials(insecure.NewCredentials()),
408+
otelDO,
409+
}
410+
if useChildOpts {
411+
dialOpts = append(dialOpts, grpc.WithChildChannelOptions(otelDO))
412+
}
413+
cc, err := grpc.NewClient(r.Scheme()+":///", dialOpts...)
414+
if err != nil {
415+
t.Fatalf("grpc.NewClient failed: %v", err)
416+
}
417+
t.Cleanup(func() { cc.Close() })
418+
419+
ctx, cancel := context.WithTimeout(context.Background(), defaultTestTimeout)
420+
defer cancel()
421+
if _, err := testgrpc.NewTestServiceClient(cc).EmptyCall(ctx, &testpb.Empty{}); err != nil {
422+
t.Fatalf("client.EmptyCall failed: %v", err)
423+
}
424+
return reader
425+
}
426+
seesRouteLookup := func(reader *metric.ManualReader) bool {
427+
ctx, cancel := context.WithTimeout(context.Background(), defaultTestTimeout)
428+
defer cancel()
429+
md, ok := metricsDataFromReader(ctx, reader)["grpc.client.attempt.duration"]
430+
if !ok {
431+
return false
432+
}
433+
hist, ok := md.Data.(metricdata.Histogram[float64])
434+
if !ok {
435+
return false
436+
}
437+
for _, dp := range hist.DataPoints {
438+
if v, ok := dp.Attributes.Value("grpc.method"); ok && v.AsString() == rlsMethod {
439+
return true
440+
}
441+
}
442+
return false
443+
}
444+
445+
if got := seesRouteLookup(newClient(t, true)); !got {
446+
t.Fatalf("with WithChildChannelOptions: grpc.client.attempt.duration missing a data point for method %q", rlsMethod)
447+
}
448+
if got := seesRouteLookup(newClient(t, false)); got {
449+
t.Fatalf("without WithChildChannelOptions: grpc.client.attempt.duration unexpectedly has a data point for method %q; A110 requires child options be opt-in", rlsMethod)
450+
}
451+
}

balancer_wrapper.go

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -86,13 +86,14 @@ func newCCBalancerWrapper(cc *ClientConn) *ccBalancerWrapper {
8686
ccb := &ccBalancerWrapper{
8787
cc: cc,
8888
opts: balancer.BuildOptions{
89-
DialCreds: cc.dopts.copts.TransportCredentials,
90-
CredsBundle: cc.dopts.copts.CredsBundle,
91-
Dialer: cc.dopts.copts.Dialer,
92-
Authority: cc.authority,
93-
CustomUserAgent: cc.dopts.copts.UserAgent,
94-
ChannelzParent: cc.channelz,
95-
Target: cc.parsedTarget,
89+
DialCreds: cc.dopts.copts.TransportCredentials,
90+
CredsBundle: cc.dopts.copts.CredsBundle,
91+
Dialer: cc.dopts.copts.Dialer,
92+
Authority: cc.authority,
93+
CustomUserAgent: cc.dopts.copts.UserAgent,
94+
ChannelzParent: cc.channelz,
95+
Target: cc.parsedTarget,
96+
ChildChannelOptions: childChannelOptionsForBalancer(cc.dopts.childChannelOptions),
9697
},
9798
serializer: grpcsync.NewCallbackSerializer(ctx),
9899
serializerCancel: cancel,
@@ -101,6 +102,21 @@ func newCCBalancerWrapper(cc *ClientConn) *ccBalancerWrapper {
101102
return ccb
102103
}
103104

105+
// childChannelOptionsForBalancer converts a []DialOption into the []any shape
106+
// exposed on balancer.BuildOptions.ChildChannelOptions. The typing indirection
107+
// avoids an import cycle between the balancer and grpc packages. Returns nil
108+
// when there are no options so balancers can nil-check cheaply.
109+
func childChannelOptionsForBalancer(opts []DialOption) []any {
110+
if len(opts) == 0 {
111+
return nil
112+
}
113+
out := make([]any, len(opts))
114+
for i, o := range opts {
115+
out[i] = o
116+
}
117+
return out
118+
}
119+
104120
func (ccb *ccBalancerWrapper) MetricsRecorder() stats.MetricsRecorder {
105121
return ccb.cc.metricsRecorderList
106122
}

dialoptions.go

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,12 @@ type dialOptions struct {
9696
maxCallAttempts int
9797
enableLocalDNSResolution bool // Specifies if target hostnames should be resolved when proxying is enabled.
9898
useProxy bool // Specifies if a server should be connected via proxy.
99+
100+
// childChannelOptions are DialOptions that the parent ClientConn does not
101+
// apply to itself, but that must be plumbed onto any internal child
102+
// channels the parent (or its LB policies/resolvers) creates. See gRFC
103+
// A110: Child Channel Options.
104+
childChannelOptions []DialOption
99105
}
100106

101107
// DialOption configures how we set up the connection.
@@ -630,6 +636,28 @@ func WithChainStreamInterceptor(interceptors ...StreamClientInterceptor) DialOpt
630636
})
631637
}
632638

639+
// WithChildChannelOptions returns a DialOption that specifies DialOptions to
640+
// be applied to any internal child channels created by this ClientConn or by
641+
// its resolvers and LB policies (for example, the RLS balancer's control
642+
// channel or an xDS resolver's control-plane channel).
643+
//
644+
// The options are opaque to the parent ClientConn — they are not applied to
645+
// the parent channel itself. Child channels are also configured to propagate
646+
// these options to their own child channels, so a StatsHandler or
647+
// interceptor set here applies to any depth of nested internal channels.
648+
//
649+
// This implements the Go section of gRFC A110: Child Channel Options.
650+
//
651+
// # Experimental
652+
//
653+
// Notice: This API is EXPERIMENTAL and may be changed or removed in a later
654+
// release.
655+
func WithChildChannelOptions(opts ...DialOption) DialOption {
656+
return newFuncDialOption(func(o *dialOptions) {
657+
o.childChannelOptions = opts
658+
})
659+
}
660+
633661
// WithAuthority returns a DialOption that specifies the value to be used as the
634662
// :authority pseudo-header and as the server name in authentication handshake.
635663
// This overrides all other ways of setting authority on the channel, but can be

0 commit comments

Comments
 (0)