docs(bigtable): document implicit session.Client sharing behavior - #20337
docs(bigtable): document implicit session.Client sharing behavior#20337sushanb wants to merge 3 commits into
Conversation
…s with matching identity
Two bigtable.NewClient calls with the same (project, instance,
appProfile, endpoint) tuple now automatically share ONE underlying
session.Client, one gRPC channel pool, and one
ClientConfigurationManager poll goroutine. Each bigtable.Client still
has its own classic gRPC ConnPool, metrics tracer factory, Diverter,
and per-Client session.TableCache; sharing is scoped to the session
data plane only.
Wire diagram: NewClientWithConfig now calls acquireSharedSession
(new shared_session.go intern cache) in place of session.NewClient.
The cache dedups on sharedKey{project, instance, appProfile,
endpoint}; endpoint is resolved via
internaloption.UnsafeResolver.ResolvedGRPCEndpoint so
option.WithEndpoint and (indirectly) option.WithUniverseDomain both
feed into the identity. On cache hit the session's refcount is
incremented; the underlying session.Client only tears down when the
LAST bigtable.Client using it releases via Close.
Fan-out for the Diverter: each bigtable.Client registers its own
SessionLoad listener on the shared session.Client's config manager
(session.Client.AddSessionLoadListener already supports multiple
listeners) and stores the unregister thunk on the Client. Close
unregisters BEFORE releasing so a late config-poll firing during
teardown cannot fire against a Diverter whose owning Client is on the
way out.
Guardrail: two NewClient calls with matching sharedKey but different
MetricsProvider / FeatureFlags settings return an error at NewClient
time (sessionFingerprint compared on cache hit). Callers get a
diff-style message naming the diverging fields so they can normalize.
DisableSession=true and preDialed callers skip the shared cache
entirely — unchanged behavior. The accelerator package is untouched;
it dials session.NewClient directly with its own lifecycle.
Java-parity note: matches the intent of google-cloud-java PR googleapis#13829
(BigtableDataClientFactory session support). Go does it implicitly
via a package-level intern cache rather than introducing a new public
factory type so callers get sharing for free.
Trade-off: two bigtable.Clients sharing one session.Client merge
their session-level dispatch metrics into a single per-session histogram
(the dispatch-metrics counters live on the shared *sessionClient).
This matches Java parity and is accepted as an implicit-sharing
consequence.
Tests:
- Cache basics: same key → same instance, different keys →
distinct, refcount release semantics.
- ForceCloseSharedSessions all-at-once teardown (exposed for tests
and drastic shutdown).
- Incompatible-options error path (no refcount increment, no rebuild).
- Close-and-reopen freshness.
- Concurrent 10-way acquire dedupes to a single build, single Close.
- Build error not cached (retry works).
All existing bigtable/internal/session/, bigtable/internal/transport/,
and bigtable/ tests pass under -race.
Adds a benchmark file covering the shared-session intern cache introduced in PR googleapis#20335. Exercises acquireSharedSession directly with a fake build closure so nothing dials real gRPC — the same test pattern shared_session_test.go uses. Between iterations ForceCloseSharedSessions() resets the cache so iteration N doesn't inherit state from iteration N-1. Five benchmarks: - BenchmarkAcquireSharedSession_x100_Shared — 100 acquires against ONE sharedKey; asserts build fires exactly once. - BenchmarkAcquireSharedSession_x100_Distinct — 100 acquires against 100 sharedKeys; asserts build fires 100 times (linear-scale baseline). - BenchmarkRelease_SharedRefcount — pure refcount-decrement path (Close on a non-last holder). - BenchmarkRelease_LastHolderTeardown — last-holder branch that deletes the entry and calls sc.Close. - BenchmarkAcquireSharedSession_Concurrent — 32-way parallel acquire storm on one key; asserts single build under contention. Sample numbers (INTEL(R) XEON(R) PLATINUM 8581C, -benchtime=1s): Shared_x100 11.68 us/op 13,632 B/op 304 allocs/op Distinct_x100 36.67 us/op 51,112 B/op 710 allocs/op RefcountRelease 73.6 ns/op 0 B/op 0 allocs/op LastHolder 328 ns/op 0 B/op 0 allocs/op Concurrent 236 ns/op 204 B/op 3 allocs/op Sharing cuts per-100-client acquire cost by ~3x on both wall time and bytes allocated, and the refcount fast path is allocation-free. This is a lower bound on the real savings — these numbers exclude the classic gRPC dial + channel pool + config-poll goroutine that sharing eliminates entirely for cache hits.
Adds user-facing documentation for the process-wide session.Client sharing introduced in PR googleapis#20335. - CHANGES.md: Unreleased-section entry noting the new sharing behavior and the incompatible-options guardrail. - NewClient godoc: one paragraph explaining that two NewClient calls with matching identity share the underlying session.Client, and pointing at the opt-outs (distinct app profile, DisableSession). - New example_shared_session_test.go with two Godoc examples: * ExampleNewClient_multipleClientsShare — the happy path. * ExampleNewClientWithConfig_incompatibleOptions — the guardrail that rejects mismatched options against a shared session. Compile-only examples (no `// Output:` line) matching the existing ExampleCheckDirectAccessSupported pattern in the package — these are documentation, not integration tests.
There was a problem hiding this comment.
Code Review
This pull request introduces process-wide implicit sharing of the underlying session.Client across NewClient calls targeting the same project, instance, app profile, and endpoint, reducing connection costs to O(1). The review feedback highlights several critical areas for improvement: first, holding the global mutex while executing the build callback (which performs a gRPC dial) creates a significant performance bottleneck, which could be resolved using a coordination mechanism like singleflight; second, EnableDebug and other feature flags are missing from the sessionFingerprint, allowing mismatched configurations to be silently ignored; and third, the metricsProviderKind type switch should be updated to handle pointer types (e.g., *NoopMetricsProvider) to avoid preventing sharing when pointer and value types are mixed.
| func acquireSharedSession(key sharedKey, fp sessionFingerprint, build func() (session.Client, error)) (session.Client, func() error, error) { | ||
| sharedSessionsMu.Lock() | ||
| defer sharedSessionsMu.Unlock() | ||
| if entry, ok := sharedSessions[key]; ok { | ||
| if entry.fingerprint != fp { | ||
| return nil, nil, fmt.Errorf( | ||
| "bigtable: NewClient called with same (%s) but incompatible options (%s); "+ | ||
| "to use different options, use different resource identifiers", | ||
| key, entry.fingerprint.diff(fp)) | ||
| } | ||
| entry.refs++ | ||
| return entry.sc, releaseFor(key), nil | ||
| } | ||
| sc, err := build() | ||
| if err != nil { | ||
| return nil, nil, err | ||
| } | ||
| sharedSessions[key] = &refcountedSession{sc: sc, fingerprint: fp, refs: 1} | ||
| return sc, releaseFor(key), nil | ||
| } |
There was a problem hiding this comment.
Holding the global sharedSessionsMu mutex while calling the build() callback (which performs a gRPC dial) is a significant performance bottleneck and violates the general rule against calling external callbacks while holding internal locks. If one dial hangs or takes several seconds, all other concurrent NewClient calls across the entire process (even for completely different projects or instances) will be blocked.
Consider using a coordination mechanism like golang.org/x/sync/singleflight or a map of pending builds/channels to ensure that:
- Callers targeting different keys can dial concurrently without blocking each other.
- Concurrent callers targeting the same key still deduplicate to a single dial.
References
- Avoid calling external callbacks while holding internal mutex locks to prevent potential deadlocks or lock inversion.
- Avoid performing slow operations, such as gRPC channel creation, while holding an exclusive lock, as this can block other operations and slow down the entire client.
| type sessionFingerprint struct { | ||
| metricsProviderKind string | ||
| clientSideMetricsEnabled bool | ||
| enableDirectAccess bool | ||
| } |
There was a problem hiding this comment.
The changelog and documentation state that mismatched EnableDebug or FeatureFlags will return an error at NewClient time. However, sessionFingerprint only contains metricsProviderKind, clientSideMetricsEnabled, and enableDirectAccess.
If EnableDebug or other feature flags are configured differently between two clients targeting the same identity, they will currently share the same session, and the second client's configuration will be silently ignored. Please add EnableDebug and any other relevant feature flags to sessionFingerprint to ensure they are compared and mismatched configurations are correctly rejected.
| func metricsProviderKind(mp MetricsProvider) string { | ||
| switch mp.(type) { | ||
| case nil, DefaultMetricsProvider: | ||
| return "default" | ||
| case NoopMetricsProvider: | ||
| return "noop" | ||
| default: | ||
| return fmt.Sprintf("%T", mp) | ||
| } | ||
| } |
There was a problem hiding this comment.
The type switch in metricsProviderKind currently only checks for the value types DefaultMetricsProvider and NoopMetricsProvider. If a caller passes a pointer (e.g., &NoopMetricsProvider{}), it will fall into the default case and return "*bigtable.NoopMetricsProvider" instead of "noop". This prevents two otherwise identical clients from sharing a session if one passes a value and the other passes a pointer.
We should update the type switch to handle both value and pointer types.
| func metricsProviderKind(mp MetricsProvider) string { | |
| switch mp.(type) { | |
| case nil, DefaultMetricsProvider: | |
| return "default" | |
| case NoopMetricsProvider: | |
| return "noop" | |
| default: | |
| return fmt.Sprintf("%T", mp) | |
| } | |
| } | |
| func metricsProviderKind(mp MetricsProvider) string { | |
| switch mp.(type) { | |
| case nil, DefaultMetricsProvider, *DefaultMetricsProvider: | |
| return "default" | |
| case NoopMetricsProvider, *NoopMetricsProvider: | |
| return "noop" | |
| default: | |
| return fmt.Sprintf("%T", mp) | |
| } | |
| } |
Documents the process-wide session.Client sharing behavior introduced in #20335 and benchmarked in #20336.
Changes
Compile-only examples (no `// Output:` line) — matches the existing `ExampleCheckDirectAccessSupported` pattern.
Stacked on
This PR's diff currently includes both of the above; once they merge this PR will be rebased onto `main` and only the docs changes will remain.