Skip to content

Commit 7ee5e44

Browse files
authored
feat(bigtable): add ClientConfig.DisableSession to opt out of session backend (#20297)
1. This option will be deprecated. 2. Keeping this ClientOption as a hatch in the client release. In future, we will create the session client regardless of this flag. so it should be fine.
1 parent b51da29 commit 7ee5e44

3 files changed

Lines changed: 234 additions & 12 deletions

File tree

bigtable/client.go

Lines changed: 38 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -57,14 +57,18 @@ type Client struct {
5757
// stays on the classic path until the session backend's
5858
// ConfigurationManager bumps the ratio via AddSessionLoadListener.
5959
diverter *btransport.Diverter
60-
// sessionImpl is the session data-plane client. Always constructed
61-
// by NewClientWithConfig so a control-plane session-load bump can
62-
// route traffic to it without a client restart. No RPCs actually
63-
// travel to the session backend until the diverter's SessionLoad
64-
// > 0 — the initial value is 0.0 and only the server-driven
65-
// ClientConfigurationManager writes to it. Session pools + streams
66-
// are lazily materialized on first use, so an idle client pays
67-
// only for one channel pool and one config-poll goroutine.
60+
// sessionImpl is the session data-plane client. Constructed by
61+
// NewClientWithConfig by default so a control-plane session-load
62+
// bump can route traffic to it without a client restart. Nil when
63+
// the caller pre-dialed a custom gRPC conn or opted out via
64+
// ClientConfig.DisableSession — every downstream reader
65+
// (open.go's getOrCreateSession*, Client.Close) nil-guards. When
66+
// non-nil, no RPCs actually travel to the session backend until
67+
// the diverter's SessionLoad > 0 — the initial value is 0.0 and
68+
// only the server-driven ClientConfigurationManager writes to it.
69+
// Session pools + streams are lazily materialized on first use,
70+
// so an idle client pays only for one channel pool and one
71+
// config-poll goroutine.
6872
sessionImpl session.Client
6973
// sessionTables caches per-resource session.TableAPI handles so
7074
// repeat Open* calls return the same handle (and by extension the
@@ -100,6 +104,25 @@ type ClientConfig struct {
100104

101105
// DisableDirectAccess disables direct access by default.
102106
DisableDirectAccess bool
107+
108+
// DisableSession, when true, tells NewClientWithConfig to skip
109+
// constructing the session data-plane client entirely. sessionImpl
110+
// and sessionTables are left nil; the client runs classic-only and
111+
// pays none of the session infrastructure's per-connection background
112+
// goroutines or gRPC-channel cost.
113+
//
114+
// Effect on Table: Open() returns a *Table whose divertible field is
115+
// still built (Diverter is a classic-side concern), but its session
116+
// side is nil, so TableShim.useSession() reports false and every
117+
// Apply / ReadRow routes to the classic path unconditionally.
118+
//
119+
// Intended for callers who have specific reasons to opt out of the
120+
// session data plane — running against a backend that doesn't
121+
// support it, benchmarking classic-only baselines, or resource-
122+
// constrained environments where the +N goroutines + +MB RSS of an
123+
// idle session pool matter. Default (false) keeps the current
124+
// behavior: session client is always constructed.
125+
DisableSession bool
103126
}
104127

105128
// MetricsProvider is a wrapper for the built-in metrics meter provider.
@@ -279,7 +302,13 @@ func NewClientWithConfig(ctx context.Context, project, instance string, config C
279302
if uResolver, resErr := internaloption.NewUnsafeResolver(o...); resErr == nil {
280303
preDialed = uResolver.ResolvedGRPCConnIsCustom()
281304
}
282-
if !preDialed {
305+
// Skip session-client construction when either (a) caller pre-dialed
306+
// a custom conn (session.NewClient can't dial through it) or (b)
307+
// caller opted out via ClientConfig.DisableSession. In both cases
308+
// sessionImpl + sessionTables stay nil; every downstream caller
309+
// (open.go's getOrCreateSession*, Client.Close) already nil-guards
310+
// on that state.
311+
if !preDialed && !config.DisableSession {
283312
// Pass the fully-merged option list (o), not the raw caller
284313
// opts. gtransport.Dial needs the DefaultClientOptions merged
285314
// in (endpoint, scopes, user-agent, interceptors) — passing
Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
// Copyright 2026 Google LLC
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package bigtable
16+
17+
import (
18+
"context"
19+
"net"
20+
"testing"
21+
"time"
22+
23+
btpb "cloud.google.com/go/bigtable/apiv2/bigtablepb"
24+
"google.golang.org/api/option"
25+
"google.golang.org/grpc"
26+
"google.golang.org/grpc/credentials/insecure"
27+
"google.golang.org/grpc/test/bufconn"
28+
)
29+
30+
// TestClientConfig_DisableSession_SkipsSessionInit pins the
31+
// DisableSession=true gate: NewClientWithConfig must NOT construct
32+
// the session data-plane client, and every downstream sessionImpl /
33+
// sessionTables field must be nil.
34+
//
35+
// The test uses WithContextDialer (not WithGRPCConn) so preDialed
36+
// stays false — otherwise the preDialed path would also skip
37+
// session.NewClient and the test would not isolate the DisableSession
38+
// gate. With DisableSession=true AND preDialed=false, only the
39+
// DisableSession gate can be responsible for sessionImpl being nil.
40+
//
41+
// The bufconn has no server behind it. If the gate is broken,
42+
// session.NewClient would try to reach a non-responsive backend and
43+
// the 5-second context would time out — the test would fail with a
44+
// deadline error, distinguishing "gate broken" from "gate worked."
45+
func TestClientConfig_DisableSession_SkipsSessionInit(t *testing.T) {
46+
lis := bufconn.Listen(1024 * 1024)
47+
t.Cleanup(func() { _ = lis.Close() })
48+
49+
// Serve a minimal fake so the classic pool's Prime (PingAndWarm)
50+
// succeeds; without it, mPool never finishes construction and this
51+
// test would fail on the classic side, not the DisableSession gate.
52+
grpcSrv := grpc.NewServer()
53+
t.Cleanup(grpcSrv.Stop)
54+
btpb.RegisterBigtableServer(grpcSrv, newFakeBigtableServer(t))
55+
go func() { _ = grpcSrv.Serve(lis) }()
56+
57+
dialer := func(ctx context.Context, _ string) (net.Conn, error) {
58+
return lis.DialContext(ctx)
59+
}
60+
61+
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
62+
defer cancel()
63+
64+
c, err := NewClientWithConfig(ctx, "test-project", "test-instance", ClientConfig{
65+
DisableSession: true,
66+
MetricsProvider: NoopMetricsProvider{},
67+
},
68+
option.WithEndpoint("passthrough:///bufnet"),
69+
option.WithGRPCDialOption(grpc.WithContextDialer(dialer)),
70+
option.WithGRPCDialOption(grpc.WithTransportCredentials(insecure.NewCredentials())),
71+
option.WithoutAuthentication(),
72+
)
73+
if err != nil {
74+
t.Fatalf("NewClientWithConfig with DisableSession=true failed: %v", err)
75+
}
76+
t.Cleanup(func() { _ = c.Close() })
77+
78+
if c.sessionImpl != nil {
79+
t.Errorf("sessionImpl = %T, want nil (DisableSession=true must skip session.NewClient)", c.sessionImpl)
80+
}
81+
if c.sessionTables != nil {
82+
t.Errorf("sessionTables = %v, want nil (DisableSession=true must skip cache init)", c.sessionTables)
83+
}
84+
}
85+
86+
// TestClientConfig_DisableSession_ClientCloseWorks confirms Client.Close
87+
// on a DisableSession client is a clean no-op on the session side —
88+
// every downstream nil-guard fires as intended (client.go:322 comment
89+
// promises "sessionTables is nil ... the cache's own close() nil-checks
90+
// for that"; client.go:329 wraps sessionImpl.Close in a nil check).
91+
func TestClientConfig_DisableSession_ClientCloseWorks(t *testing.T) {
92+
lis := bufconn.Listen(1024 * 1024)
93+
t.Cleanup(func() { _ = lis.Close() })
94+
95+
// Serve a minimal fake so the classic pool's Prime (PingAndWarm)
96+
// succeeds; without it, mPool never finishes construction and this
97+
// test would fail on the classic side, not the DisableSession gate.
98+
grpcSrv := grpc.NewServer()
99+
t.Cleanup(grpcSrv.Stop)
100+
btpb.RegisterBigtableServer(grpcSrv, newFakeBigtableServer(t))
101+
go func() { _ = grpcSrv.Serve(lis) }()
102+
103+
dialer := func(ctx context.Context, _ string) (net.Conn, error) {
104+
return lis.DialContext(ctx)
105+
}
106+
107+
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
108+
defer cancel()
109+
110+
c, err := NewClientWithConfig(ctx, "test-project", "test-instance", ClientConfig{
111+
DisableSession: true,
112+
MetricsProvider: NoopMetricsProvider{},
113+
},
114+
option.WithEndpoint("passthrough:///bufnet"),
115+
option.WithGRPCDialOption(grpc.WithContextDialer(dialer)),
116+
option.WithGRPCDialOption(grpc.WithTransportCredentials(insecure.NewCredentials())),
117+
option.WithoutAuthentication(),
118+
)
119+
if err != nil {
120+
t.Fatalf("NewClientWithConfig: %v", err)
121+
}
122+
123+
// Explicit Close — must not panic on the nil sessionImpl / sessionTables.
124+
if err := c.Close(); err != nil {
125+
t.Errorf("Close on DisableSession client returned err = %v, want nil", err)
126+
}
127+
// Second Close must also be safe (idempotent per session_pool_lifecycle
128+
// / mPool.Close conventions).
129+
_ = c.Close()
130+
}
131+
132+
// TestClientConfig_DisableSession_OpenTableRoutesClassicOnly confirms
133+
// that on a DisableSession client, TableShim's session side is nil, so
134+
// useSession() reports false and every Apply / ReadRow routes to the
135+
// classic path unconditionally — regardless of the Diverter's
136+
// SessionLoad. This is the load-bearing contract for callers that
137+
// opt out: they get classic behavior even if some future code path
138+
// bumps the Diverter.
139+
func TestClientConfig_DisableSession_OpenTableRoutesClassicOnly(t *testing.T) {
140+
lis := bufconn.Listen(1024 * 1024)
141+
t.Cleanup(func() { _ = lis.Close() })
142+
143+
// Serve a minimal fake so the classic pool's Prime (PingAndWarm)
144+
// succeeds; without it, mPool never finishes construction and this
145+
// test would fail on the classic side, not the DisableSession gate.
146+
grpcSrv := grpc.NewServer()
147+
t.Cleanup(grpcSrv.Stop)
148+
btpb.RegisterBigtableServer(grpcSrv, newFakeBigtableServer(t))
149+
go func() { _ = grpcSrv.Serve(lis) }()
150+
151+
dialer := func(ctx context.Context, _ string) (net.Conn, error) {
152+
return lis.DialContext(ctx)
153+
}
154+
155+
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
156+
defer cancel()
157+
158+
c, err := NewClientWithConfig(ctx, "test-project", "test-instance", ClientConfig{
159+
DisableSession: true,
160+
MetricsProvider: NoopMetricsProvider{},
161+
},
162+
option.WithEndpoint("passthrough:///bufnet"),
163+
option.WithGRPCDialOption(grpc.WithContextDialer(dialer)),
164+
option.WithGRPCDialOption(grpc.WithTransportCredentials(insecure.NewCredentials())),
165+
option.WithoutAuthentication(),
166+
)
167+
if err != nil {
168+
t.Fatalf("NewClientWithConfig: %v", err)
169+
}
170+
t.Cleanup(func() { _ = c.Close() })
171+
172+
// Force the Diverter into session-preferring mode. If DisableSession
173+
// works as advertised, useSession() still reports false because
174+
// TableShim.session is nil.
175+
if c.diverter != nil {
176+
c.diverter.SetSessionLoad(1.0)
177+
}
178+
179+
tblAPI := c.OpenTable("mytable")
180+
shim, ok := tblAPI.(*TableShim)
181+
if !ok {
182+
t.Fatalf("OpenTable returned %T, want *TableShim", tblAPI)
183+
}
184+
if shim.session != nil {
185+
t.Errorf("shim.session = %T, want nil (DisableSession clients must not wire a session TableAPI)", shim.session)
186+
}
187+
if shim.useSession() {
188+
t.Error("shim.useSession() = true, want false (nil session side must gate to classic)")
189+
}
190+
}

bigtable/open.go

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -64,9 +64,12 @@ func (c *Client) buildDivertible(t *Table, openSession func() session.TableAPI)
6464
// OpenTable opens a table. Returns a TableShim that routes each RPC via
6565
// the Client's Diverter — with sessionLoad=0.0 every call lands on the
6666
// classic path. The session TableAPI is wired from the Client's
67-
// sessionImpl (always constructed by NewClientWithConfig); server-driven
68-
// SessionLoad updates from ClientConfigurationManager retarget traffic
69-
// without re-opening the table.
67+
// sessionImpl when present; it's nil when the caller pre-dialed a
68+
// custom gRPC conn or opted out via ClientConfig.DisableSession, in
69+
// which case the shim runs classic-only and useSession() reports
70+
// false. Server-driven SessionLoad updates from
71+
// ClientConfigurationManager retarget traffic without re-opening the
72+
// table.
7073
func (c *Client) OpenTable(table string) TableAPI {
7174
classic := &tableImpl{Table{
7275
c: c,

0 commit comments

Comments
 (0)