blob: 086f7d6a6de7cb28fd147ea8c7b30871a5b18c4a [file] [edit]
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package bigtable
import (
"context"
"fmt"
"net/url"
"os"
"reflect"
"strconv"
"time"
btpb "cloud.google.com/go/bigtable/apiv2/bigtablepb"
metrics "cloud.google.com/go/bigtable/internal/metrics"
btopt "cloud.google.com/go/bigtable/internal/option"
"cloud.google.com/go/bigtable/internal/session"
btransport "cloud.google.com/go/bigtable/internal/transport"
"cloud.google.com/go/internal/trace"
gax "github.com/googleapis/gax-go/v2"
"google.golang.org/api/option"
"google.golang.org/api/option/internaloption"
gtransport "google.golang.org/api/transport/grpc"
"google.golang.org/grpc"
"google.golang.org/grpc/metadata"
)
const directAccessEnvVar = "CBT_ENABLE_DIRECTPATH"
// Client is a client for reading and writing data to tables in an instance.
//
// A Client is safe to use concurrently, except for its Close method.
type Client struct {
connPool gtransport.ConnPool
client btpb.BigtableClient
project, instance string
appProfile string
metricsTracerFactory *metrics.Factory
retryOption gax.CallOption
executeQueryRetryOption gax.CallOption
featureFlagsMD metadata.MD // Pre-computed feature flags metadata to be sent with each request.
mPool btransport.ManagedChannelPool
// diverter picks between the classic and session data path on every
// Open* return. Initialized with sessionLoad=0.0 so all traffic
// stays on the classic path until the session backend's
// ConfigurationManager bumps the ratio via AddSessionLoadListener.
diverter *btransport.Diverter
// sessionImpl is the session data-plane client. Always constructed
// by NewClientWithConfig so a control-plane session-load bump can
// route traffic to it without a client restart. No RPCs actually
// travel to the session backend until the diverter's SessionLoad
// > 0 — the initial value is 0.0 and only the server-driven
// ClientConfigurationManager writes to it. Session pools + streams
// are lazily materialized on first use, so an idle client pays
// only for one channel pool and one config-poll goroutine.
sessionImpl session.Client
// sessionTables caches per-resource session.TableAPI handles so
// repeat Open* calls return the same handle (and by extension the
// same underlying session pools). session.Client does not cache;
// this Client is responsible. Entries evict on TTL-idle (default
// 1 h) via a background sweeper, or immediately when the caller
// calls Close() on the returned handle. See session_table_cache.go.
sessionTables *sessionTableCache
}
// ClientConfig has configurations for the client.
type ClientConfig struct {
// The id of the app profile to associate with all data operations sent from this client.
// If unspecified, the default app profile for the instance will be used.
AppProfile string
// MetricsProvider controls the built-in client-side metrics.
//
// Leave unset (nil) or set to DefaultMetricsProvider{} to enable the
// built-in Cloud Monitoring exporter (default behavior). Set to
// NoopMetricsProvider{} to disable metrics entirely.
//
// TODO: support user provided meter provider
MetricsProvider MetricsProvider
// DisableDynamicChannelPool disables the dynamic channel resizing based on load
// Dynamic channel resizing is enabled by default to resize based on load and avoid queuing of requests.
DisableDynamicChannelPool bool
// DisableConnectionRecycler disables the automatic preemptive refresh of connection.
// Preemptive connection is default to true
DisableConnectionRecycler bool
// DisableDirectAccess disables direct access by default.
DisableDirectAccess bool
}
// MetricsProvider is a wrapper for the built-in metrics meter provider.
// Type alias to the internal metrics package's interface — callers keep
// using bigtable.MetricsProvider while the implementation lives in
// bigtable/internal/metrics so both classic and session data planes can
// share it without an import cycle.
type MetricsProvider = metrics.MetricsProvider
// DefaultMetricsProvider enables the built-in Cloud Monitoring metrics
// exporter (the same behavior as leaving ClientConfig.MetricsProvider
// nil). Type alias to the internal metrics package's implementation.
type DefaultMetricsProvider = metrics.DefaultMetricsProvider
// NoopMetricsProvider disables the built-in metrics. Type alias to the
// internal metrics package's implementation.
type NoopMetricsProvider = metrics.NoopMetricsProvider
// NewClient creates a new Client for a given project and instance.
// The default ClientConfig will be used.
func NewClient(ctx context.Context, project, instance string, opts ...option.ClientOption) (*Client, error) {
return NewClientWithConfig(ctx, project, instance, ClientConfig{}, opts...)
}
// NewClientWithConfig creates a new client with the given config.
func NewClientWithConfig(ctx context.Context, project, instance string, config ClientConfig, opts ...option.ClientOption) (*Client, error) {
clientCreationTimestamp := time.Now()
metricsProvider := config.MetricsProvider
if emulatorAddr := os.Getenv("BIGTABLE_EMULATOR_HOST"); emulatorAddr != "" {
// Do not emit metrics when emulator is being used
metricsProvider = NoopMetricsProvider{}
}
// Create a OpenTelemetry metrics configuration
metricsTracerFactory, err := metrics.NewFactory(ctx, project, instance, config.AppProfile, metricsProvider, opts...)
if err != nil {
return nil, err
}
o, err := btopt.DefaultClientOptions(prodAddr, mtlsProdAddr, Scope, clientUserAgent)
if err != nil {
return nil, err
}
// for otel metrics
if metricsTracerFactory.Enabled {
if len(metricsTracerFactory.ClientOpts) > 0 {
o = append(o, metricsTracerFactory.ClientOpts...)
}
}
// Add gRPC client interceptors to supply Google client information. No external interceptors are passed.
o = append(o, btopt.ClientInterceptorOptions(nil, nil)...)
o = append(o, option.WithGRPCDialOption(grpc.WithStatsHandler(metrics.SharedStatsHandler)))
// Default to a connection pool that can be overridden. Raised from 4 to
// defaultBigtableConnPoolSize to compensate for dynamic channel pool
// scaling being disabled by default
// (see https://github.com/googleapis/google-cloud-go/issues/14582).
o = append(o,
option.WithGRPCConnectionPool(defaultBigtableConnPoolSize),
// Set the max size to correspond to server-side limits.
option.WithGRPCDialOption(grpc.WithDefaultCallOptions(grpc.MaxCallSendMsgSize(1<<28), grpc.MaxCallRecvMsgSize(1<<28))),
)
var directAccessOptions = []option.ClientOption{
internaloption.EnableDirectPath(true),
internaloption.EnableDirectPathXds(),
internaloption.AllowHardBoundTokens("ALTS"),
}
// Allow non-default service account in DirectPath.
o = append(o, internaloption.AllowNonDefaultServiceAccount(true))
o = append(o, opts...)
o = append(o, internaloption.EnableNewAuthLibrary())
o = append(o, internaloption.EnableJwtWithScope())
// RetryInfo is unconditionally on; retryer + on-wire flag agree via
// NewFeatureFlagsProto (feature_flags.go) and defaultRetryOption.
retryOption := defaultRetryOption
executeQueryRetryOption := defaultExecuteQueryRetryOption
// Build feature-flags proto + header MD ONCE. Both are threaded
// into session.NewClient below so classic and session ship
// byte-identical bigtable-features headers AND session's
// OpenSessionRequest.Flags reuses the same proto reference —
// zero drift by construction, no re-marshal in session.
featureFlagsProto := btransport.NewFeatureFlagsProto(btransport.FeatureFlagsInput{
ClientSideMetricsEnabled: metricsTracerFactory.Enabled,
EnableDirectAccess: isDirectAccessEnabled(config),
})
directAccessMD := btransport.MarshalFeatureFlagsMD(featureFlagsProto)
var mPool btransport.ManagedChannelPool
enableBigtableConnPool := btopt.EnableBigtableConnectionPool()
grpcConnOptType := reflect.TypeOf(option.WithGRPCConn(nil))
for _, opt := range opts {
if reflect.TypeOf(opt) == grpcConnOptType {
enableBigtableConnPool = false
break
}
}
if !enableBigtableConnPool {
// Use the regular ConnPool
// For regular ConnPool the Direct Access is off by default so we need to check the env var again.
if enabled, _ := strconv.ParseBool(os.Getenv(directAccessEnvVar)); enabled {
o = append(o, directAccessOptions...)
}
}
poolConfig := btransport.ChannelPoolConfig{
AppProfile: config.AppProfile,
DisableDynamicChannelPool: config.DisableDynamicChannelPool,
DisableConnectionRecycler: config.DisableConnectionRecycler,
DisableDirectAccess: config.DisableDirectAccess,
}
mPool, err = btransport.CreateAndStartManagedChannelPool(
ctx,
project,
instance,
poolConfig,
metricsTracerFactory.OtelMeterProvider,
o,
directAccessOptions,
directAccessMD,
clientCreationTimestamp,
enableBigtableConnPool,
)
if err != nil {
return nil, err
}
c := &Client{
connPool: mPool.Pool,
client: btpb.NewBigtableClient(mPool.Pool),
project: project,
instance: instance,
appProfile: config.AppProfile,
metricsTracerFactory: metricsTracerFactory,
retryOption: retryOption,
executeQueryRetryOption: executeQueryRetryOption,
featureFlagsMD: directAccessMD,
mPool: mPool,
diverter: btransport.NewDiverter(0.0),
}
// Session data-plane backend construction has two guardrails so it
// can't interfere with the classic path in test / emulator setups:
//
// 1. If the caller passed option.WithGRPCConn(conn) — i.e. handed
// us one pre-dialed *grpc.ClientConn to use for everything —
// skip session entirely. Two independent backends can't
// reasonably share one physical conn; running the session
// dialer against a pre-dialed conn either gets both backends
// entangled on the same underlying transport (so a
// session-side teardown propagates a "connection is closing"
// error to classic RPCs — the conformance test-proxy failure
// mode) or double-dials the same fake server. Same guard
// covers BIGTABLE_EMULATOR_HOST since DefaultClientOptions
// sets WithGRPCConn internally in that mode.
//
// 2. On session.NewClient failure, tear down the classic pool
// since we won't return c to the caller.
//
// When both guardrails pass, wire AddSessionLoadListener so the
// server-driven SessionLoad from ClientConfigurationManager retargets
// traffic through this Client's Diverter — a control-plane update
// then shifts traffic across every open TableShim without a client
// restart.
// Inspect the merged option list (o), not the raw caller opts.
// BIGTABLE_EMULATOR_HOST injects option.WithGRPCConn inside
// btopt.DefaultClientOptions (option.go:113), so it lives in o —
// callers never pass it explicitly. Reading only from opts misses
// the emulator conn and lets session.NewClient dial an empty
// resolver target (fails with "passthrough: received empty target
// in Build()"), breaking every emulator-based test.
preDialed := false
if uResolver, resErr := internaloption.NewUnsafeResolver(o...); resErr == nil {
preDialed = uResolver.ResolvedGRPCConnIsCustom()
}
if !preDialed {
// Pass the fully-merged option list (o), not the raw caller
// opts. gtransport.Dial needs the DefaultClientOptions merged
// in (endpoint, scopes, user-agent, interceptors) — passing
// bare opts leaves the resolver target empty and the dial
// aborts with "passthrough: received empty target in Build()".
sc, sessionErr := session.NewClient(ctx, project, instance, config.AppProfile, metricsProvider, featureFlagsProto, o...)
if sessionErr != nil {
// Best-effort cleanup of the classic pool since we won't
// return c to the caller. Go through the ManagedChannelPool
// wrapper so any wrapper-owned cleanup — metrics reporter,
// connection recycler, dynamic scale monitor — winds down
// too. Matches (*Client).Close.
_ = mPool.Close()
return nil, fmt.Errorf("bigtable: session.NewClient: %w", sessionErr)
}
sc.AddSessionLoadListener(c.diverter.SetSessionLoad)
c.sessionImpl = sc
// Per-resource TableAPI cache with TTL-on-idle eviction. The
// cache is opener-agnostic — each getOrCreateSession* helper
// in open.go passes its own openFn per call, using the fully-
// qualified resource name as the cache key. Only constructed
// when the session backend is actually wired — a sweeper
// goroutine over an always-empty map would be dead weight.
c.sessionTables = newSessionTableCache(sessionTableCacheTTL, sessionTableCacheSweepInt, nil /* time.Now */)
}
return c, nil
}
// Close closes the Client.
func (c *Client) Close() error {
if c.metricsTracerFactory != nil {
c.metricsTracerFactory.Shutdown()
}
// Close the per-resource cache first so its sweeper goroutine
// stops and every cached handle sees Close() before we tear down
// the session client that owns their pools. sessionTables is nil
// on hand-built Clients that skipped session-backend wiring; the
// cache's own close() nil-checks for that.
c.sessionTables.close()
// Then the session backend — its bookkeeping (session pools,
// ConfigurationManager poller) winds down before we drop the
// shared gRPC channels. Any error is aggregated with the classic
// pool's Close error.
var sessionErr error
if c.sessionImpl != nil {
sessionErr = c.sessionImpl.Close()
}
if err := c.mPool.Close(); err != nil {
if sessionErr != nil {
return fmt.Errorf("bigtable.Client.Close: classic pool: %w; session: %v", err, sessionErr)
}
return err
}
return sessionErr
}
func (c *Client) fullInstanceName() string {
return fmt.Sprintf("projects/%s/instances/%s", c.project, c.instance)
}
func (c *Client) fullTableName(table string) string {
return fmt.Sprintf("projects/%s/instances/%s/tables/%s", c.project, c.instance, table)
}
func (c *Client) fullAuthorizedViewName(table string, authorizedView string) string {
return fmt.Sprintf("projects/%s/instances/%s/tables/%s/authorizedViews/%s", c.project, c.instance, table, authorizedView)
}
func (c *Client) fullMaterializedViewName(materializedView string) string {
return fmt.Sprintf("projects/%s/instances/%s/materializedViews/%s", c.project, c.instance, materializedView)
}
func (c *Client) reqParamsHeaderValTable(table string) string {
return fmt.Sprintf("table_name=%s&app_profile_id=%s", url.QueryEscape(c.fullTableName(table)), url.QueryEscape(c.appProfile))
}
func (c *Client) reqParamsHeaderValInstance() string {
return fmt.Sprintf("name=%s&app_profile_id=%s", url.QueryEscape(c.fullInstanceName()), url.QueryEscape(c.appProfile))
}
// PingAndWarm pings the server and warms up the connection.
func (c *Client) PingAndWarm(ctx context.Context) (err error) {
md := metadata.Join(metadata.Pairs(
resourcePrefixHeader, c.fullInstanceName(),
requestParamsHeader, c.reqParamsHeaderValInstance(),
), c.featureFlagsMD)
ctx = mergeOutgoingMetadata(ctx, md)
ctx = trace.StartSpan(ctx, "cloud.google.com/go/bigtable/PingAndWarm")
defer func() { trace.EndSpan(ctx, err) }()
mt := c.newBuiltinMetricsTracer(ctx, "", false)
defer mt.RecordOperationCompletion()
ctx = metrics.NewContext(ctx, mt)
err = c.pingerWithMetadata(ctx)
statusCode, statusErr := metrics.ConvertToGrpcStatusErr(err)
mt.SetCurrOpStatus(statusCode)
return statusErr
}
func (c *Client) pingerWithMetadata(ctx context.Context) (err error) {
req := &btpb.PingAndWarmRequest{
Name: c.fullInstanceName(),
AppProfileId: c.appProfile,
}
err = gaxInvokeWithRecorder(ctx, "PingAndWarm", func(ctx context.Context, headerMD, trailerMD *metadata.MD, _ gax.CallSettings) error {
var err error
_, err = c.client.PingAndWarm(ctx, req, grpc.Header(headerMD), grpc.Trailer(trailerMD))
return err
})
return err
}
func (c *Client) newBuiltinMetricsTracer(ctx context.Context, table string, isStreaming bool) *metrics.Tracer {
return c.metricsTracerFactory.CreateTracer(ctx, table, isStreaming)
}
// isDirectAccessEnabled resolves whether this client advertises
// DirectAccess capability on the wire AND dials DirectPath. Single
// source of truth threaded into both the classic and session
// FeatureFlags builders so header bits stay in lockstep with the
// dial-time switch. CBT_ENABLE_DIRECTPATH overrides
// ClientConfig.DisableDirectAccess when set; unset falls back to
// the config default.
func isDirectAccessEnabled(config ClientConfig) bool {
if os.Getenv(directAccessEnvVar) == "" {
return !config.DisableDirectAccess
}
res, _ := strconv.ParseBool(os.Getenv(directAccessEnvVar))
return res
}