feat(bigtable): wire session.Client into Open* via TableShim - #20262
feat(bigtable): wire session.Client into Open* via TableShim#20262sushanb wants to merge 19 commits into
Conversation
Three coordinated changes so a follow-up session-data-path patch can flip traffic through the shim without further Client/OpenTable churn: 1. Client owns a *transport.Diverter, constructed with sessionLoad=0.0 in NewClientWithConfig. Every call stays on the classic path until a future change wires in the session backend and bumps the ratio. 2. Open / OpenTable / OpenAuthorizedView / OpenMaterializedView move out of client.go into a new open.go. OpenTable & friends now return NewTableShim(classic, nil, c.diverter) — the shim wraps the same classic tableImpl as before with routing added on top. Open() (returning *Table) stays classic-only for callers that need the concrete pointer type. 3. TableShim gains a pickSession() gate: session TableAPI is optional; with session==nil pickSession short-circuits before consulting the diverter so the pick-count histogram doesn't record session picks that got silently downgraded here. ReadRow + Apply route through pickSession() instead of calling diverter.UseSession() directly. Also: DefaultDynamicChannelPoolConfig.MinConns 10 -> 4. Session client uses a pool of 4 as its footprint default, and today ValidateDynamicConfig rejects that with "initial connPoolSize (4) must be between MinConns (10) and MaxConns (200)". Lowering the floor to 4 lets the session pool validate under the same default config the classic pool uses. No behavior change on the classic path: sessionLoad=0.0 makes UseSession() return false in every case, and the pickSession() nil-guard would defend even if it didn't. All existing bigtable tests pass.
Follow-up on the diverter wiring in this PR. Makes Client.Open route
through the same TableShim path that OpenTable / OpenAuthorizedView /
OpenMaterializedView already use, without changing Open's *Table
return type — so classic callers that hold a concrete *Table pointer
(BulkMutation and any external code) will get session routing when the
session backend lands in a follow-up PR.
Wiring:
- Table gains a divertible TableAPI field. Nil for classic-only clients.
- Table.Apply / Table.ReadRow become one-line gates:
if t.divertible != nil { return t.divertible.X(...) }
return t.xClassic(...)
- Table.applyClassic / Table.readRowClassic hold the original bodies
verbatim — no metrics / tracer / retry behavior change on classic.
- tableImpl grows an explicit ReadRow override; both tableImpl.Apply
and tableImpl.ReadRow now call the *Classic helpers directly,
bypassing the gate. This breaks the recursion loop: the shim's
classic branch dispatches into tableImpl, which lands on applyClassic
/ readRowClassic instead of coming back through Table.Apply /
Table.ReadRow.
- Open populates t.divertible via a new buildDivertible helper that
constructs NewTableShim(&tableImpl{Table: <snapshot with divertible
zeroed>}, nil, c.diverter) when c.diverter != nil. The
snapshot-with-divertible-zeroed is defense in depth against a future
reader who removes the tableImpl overrides.
- OpenTable / OpenAuthorizedView / OpenMaterializedView refactored to
reuse buildDivertible so all four Open* methods construct the shim
identically. Session TableAPI is nil today; the follow-up wires it.
Behavioral guarantees:
- Conditional Apply (CheckAndMutateRow) still routes to classic —
TableShim.Apply enforces that on the shim side; the Table-level gate
is unconditional so every conditional passes through the shim, which
re-routes it to classic. Same end state.
- ReadRows / SampleRowKeys / ApplyBulk / ApplyReadModifyWrite are
untouched — no session equivalent, no gate needed.
- Classic-only clients (c.diverter == nil) short-circuit at
buildDivertible; Table.divertible stays nil; both gates fall through
to *Classic on the fast path. Zero perf change.
All target tests green.
gemini review on PR googleapis#20256 flagged that pickSession would NPE if a caller built a TableShim without a diverter (TableShim is exported, so this is reachable from tests and external code even though in-repo callers always wire one via NewTableShim). Widens the short-circuit to `t.session == nil || t.diverter == nil`. Nil-session guard still runs FIRST — same reason as before: consulting the diverter is side-effectful (per-outcome pick counters), and we don't want session picks that got downgraded to classic here inflating the histogram.
…ableShim" This reverts commit 5b48ef4.
Folds PR googleapis#20258 into this PR. TableShim's session backend changes from the classic-shaped TableAPI (row string + bigtable.Row) to the internal/session.TableAPI shape (proto-native *btpb.SessionReadRow{Request,Response} + *btpb.SessionMutateRow {Request,Response}). TableShim owns the proto ↔ public-types translation so the session package can stay proto-native. - ReadRow: parses ReadOptions via classic makeReadSettings/readSettings so every existing option (RowFilter, WithFullReadStats, etc.) works on the session path; then translates (row, filter) → SessionReadRowRequest, calls session.ReadRow, feeds resp.Stats through WithFullReadStats, and converts resp.Row via protoRowToRow (added in googleapis#20257, now on main). - Apply: nil mutations and conditional mutations (CheckAndMutateRow) route to classic — CheckAndMutateRow has no session vRPC equivalent, and nil-m to classic surfaces the classic client's existing nil-handling error rather than panicking on m.ops here. - useSession() helper is nil-safe on both session and diverter (replaces the earlier pickSession); callers can wire a TableShim with nil session and every routing decision falls through to classic. Signature change (internal-only): NewTableShim's session parameter type is now session.TableAPI (an internal package). In-repo callers pass nil for session today; nil is the zero value of any interface, so the change is source-compatible for those. Tests: replaces the mockTableAPI-as-session pattern with a proto-native mockSessionTable. Covers routing, nil-session fallback, session error propagation, conditional-always-classic, and the already-existing TestProtoRowToRow contract. Closes PR googleapis#20258 (the standalone version); folded here so PR googleapis#20256's review scope is one coherent unit — Diverter wiring + TableShim proto-native shape together.
goimports flagged bigtable/client.go on CI. The Option B revert left a double blank line where the four Open* methods used to sit; goimports canonicalizes that to a single blank line. Pure whitespace fix.
Adds bigtable/open_test.go with 5 tests pinning the factory contract introduced by this PR. Focused on the composition (which shape each factory returns + how it wires the client's Diverter) rather than re-testing routing behavior — TableShim's own tests already cover nil-session-routes-to-classic (TestTableShim_NilSession_AllMethodsFallBackToClassic on main). Tests: - TestOpen_ReturnsBareTable — Open returns plain *Table (no session-routing wrapper). Callers holding *Table stay on the classic path regardless of Client's diverter setting. - TestOpenTable_ProducesNilSessionShim — OpenTable returns *TableShim with session == nil and classic == *tableImpl. Uses SessionLoad=1.0 on the diverter to prove useSession() still returns false because session is nil (the nil-session short-circuit runs before consulting the diverter). - TestOpenAuthorizedView_ProducesNilSessionShim — same, plus authorizedView threaded through to the inner Table. - TestOpenMaterializedView_ProducesNilSessionShim — same, plus materializedView threaded through (table empty since MVs are addressed by view name only). - TestOpenFactories_ShareOneClientDiverter — every Open* factory references the SAME *Diverter, and mutating it via SetSessionLoad is visible from every shim (proves the reference is live, not a copy). This is the invariant a future ConfigurationManager.SetSessionLoad depends on to update every open resource at once. Test bootstrap uses a hand-built minimal *Client (project + appProfile + featureFlagsMD + diverter) rather than NewClientWithConfig to keep the tests dial-free and hermetic — the factory code paths only read those fields.
Follow-up to googleapis#20256 (Diverter wiring). Makes Open* actually produce session-routed TableShims when the caller opts in via ClientConfig.EnableSessionPool = true. Without the opt-in the shim's session TableAPI stays nil and every routing decision falls through to classic (existing behavior; existing tests still pass). Wiring: - Client gains three fields: sessionImpl session.Client sessionTablesMu sync.Mutex sessionTables map[string]session.TableAPI // per-resource cache - ClientConfig gains EnableSessionPool bool. When true, NewClientWithConfig calls session.NewClient(ctx, project, instance, appProfile, metricsProvider, opts...) with the same option set as the classic pool (endpoint/credentials/dial hooks match), then wires sc.AddSessionLoadListener(c.diverter.SetSessionLoad) so the server-driven ClientConfigurationManager can shift traffic across every open TableShim at once. - If session.NewClient fails, the classic pool is closed and the whole NewClientWithConfig returns the error rather than leaking a half-constructed Client. - Client.Close now closes sessionImpl (aggregating any error with the classic pool's Close error). Session backend closes FIRST — its ConfigurationManager poller and per-pool bookkeeping wind down before we drop the shared gRPC channels. - open.go: three getOrCreateSession* helpers cache per-resource session TableAPIs. Cache keys are "tbl:<t>", "av:<t>:<v>" (table- qualified so same view-id under different tables gets distinct pools + distinct metric labels), "mv:<v>". session.Client itself does NOT cache — that's this Client's responsibility. Each helper nil-short-circuits when sessionImpl == nil so classic-only callers pay zero lock cost per Open call. - OpenTable / OpenAuthorizedView / OpenMaterializedView now pass c.getOrCreateSession* result to NewTableShim instead of nil. Tests (bigtable/open_test.go, +5 new): - TestOpenTable_WithSessionBackend_WiresSessionTableAPI — verifies the shim carries a non-nil session TableAPI from sessionImpl.OpenTable. - TestOpenTable_SessionTableAPICacheHit — two OpenTable calls on the same table return the same session TableAPI pointer (identity check); sessionImpl.OpenTable is called only once. - TestOpenAuthorizedView_SessionCacheIsTableQualified — two AVs with the same view-id under DIFFERENT tables get DISTINCT session TableAPIs (defense against a bug where "av:<view>" would silently share pools across tables). - TestOpenMaterializedView_WithSessionBackend_WiresSessionTableAPI — same shape as OpenTable, plus cache-hit assertion. - TestGetOrCreateSession_NilSessionImplReturnsNil — every helper returns nil without taking the mutex when sessionImpl is nil. Existing tests (nil-session shape) still pass; the earlier five TestOpen* / TestOpenFactories tests validate the EnableSessionPool=false default. Ten open-tests total, hermetic (no dial, no emulator).
There was a problem hiding this comment.
Code Review
This pull request introduces support for an opt-in session data-plane backend in the Bigtable client by adding the EnableSessionPool configuration option. It implements traffic routing between the classic and session data paths using a Diverter and caches per-resource TableAPI instances to prevent duplicate session pool creation. Additionally, the default minimum connections for the dynamic channel pool config is reduced from 10 to 4. Feedback suggests calling mPool.Close() instead of mPool.Pool.Close() during initialization failure cleanup to ensure proper resource tracking.
| if sessionErr != nil { | ||
| // Best-effort cleanup of the classic pool since we won't | ||
| // return c to the caller. | ||
| _ = mPool.Pool.Close() |
There was a problem hiding this comment.
There was a problem hiding this comment.
Fixed in 2c7bf85 — switched to mPool.Close() so the wrapper-owned cleanup (metrics reporter, connection recycler, dynamic scale monitor) also winds down on the session-init failure path.
…t-session-backend # Conflicts: # bigtable/client.go # bigtable/open.go # bigtable/open_test.go
gemini review flagged that the session.NewClient-failure cleanup path called mPool.Pool.Close() directly, bypassing the ManagedChannelPool wrapper. Switch to mPool.Close() so any wrapper-owned cleanup (metrics reporter, connection recycler, dynamic scale monitor) also winds down. Matches the shape used by (*Client).Close.
| // server-driven traffic shaping; it does not by itself send any | ||
| // traffic to the session backend (the initial SessionLoad is | ||
| // 0.0). | ||
| EnableSessionPool bool |
There was a problem hiding this comment.
hmm we should default to true and let server control it? And also I thought we said that we're only gonna use a secret environment variable for disabling?
There was a problem hiding this comment.
Okay I will remove the vairable then.
| mPool: mPool, | ||
| diverter: btransport.NewDiverter(0.0), | ||
| }, nil | ||
| sessionTables: make(map[string]session.TableAPI), |
There was a problem hiding this comment.
do they every expires? I think we want a LRU cache instead of keeping these entries forever?
There was a problem hiding this comment.
So the main resource here would be sessions. If they are inactive, the session scaling will shrink down the session to min sessions 5. so i am not worried about the behavior.
There was a problem hiding this comment.
But if the user has a long running client, opens up a bunch of tables , views , sending reads and writes, this could grow unbounded? (even though rare)
…ault Removes the EnableSessionPool bool from ClientConfig. NewClientWithConfig now always calls session.NewClient — every Client carries a real session.Client and the diverter is always wired up. Motivation: - The opt-in split was cosmetic. The initial SessionLoad is 0.0, so even with the backend constructed, zero traffic hits the session data plane until a control-plane ClientConfigurationManager bumps the ratio. Callers who never see a bump pay for one session channel pool + one config-poll goroutine and no RPCs. - Removing the flag lets a control-plane update route traffic to session mid-life without a client restart or user config change, which is the whole point of server-driven traffic shaping. - One less footgun: previously an operator had to remember to flip EnableSessionPool for the traffic shaping to work; now it just does. Costs (documented on the sessionImpl field): - One extra channel pool per client (bounded, small footprint). - One background goroutine per client polling GetClientConfiguration. - Session pools + OpenSession streams remain lazily materialized on first use, so idle callers don't pay for those. Test file gets doc-comment updates; the nil-sessionImpl guard test stays (hand-built or emulator Clients can still have nil sessionImpl, and every getOrCreateSession* short-circuits on nil).
|
Scope narrowed in Rationale:
Costs (documented on the
Tests unchanged in intent; the |
Conformance was failing on this PR because NewClientWithConfig
unconditionally called session.NewClient. The conformance test proxy
points the Client at a fake Bigtable server that doesn't speak
GetClientConfiguration; the session-side ClientConfigurationManager
poll cascaded into a classic-side "grpc: the client connection is
closing" error on the very first Table.Apply, and the test hung
until timeout.
Re-add the EnableSessionPool bool on ClientConfig (unset by default)
and gate session.NewClient behind it. Callers who opt in get session
routing; conformance / emulator / any other fake-server harness that
doesn't flip the flag pays zero session cost and every RPC still lands
on the classic path — same behavior as before this PR series.
When EnableSessionPool is false: sessionImpl stays nil, and the
getOrCreateSession* helpers in open.go already nil-short-circuit on
that (added earlier in this PR), so Open* returns a TableShim with a
nil session and every routing decision goes classic.
When EnableSessionPool is true: unchanged from the prior commit
sequence — session.Client is constructed, AddSessionLoadListener
wires the server-driven SessionLoad into the Diverter, and
TableShims route by that ratio.
Follows the sushanb review preference from earlier in this PR ("no
session pool by default; the shim is transparent when off").
Fixes conformance (bigtable_conformance 1.25 + 1.26).
…SessionPool" This reverts commit ea612a9.
Fixes the conformance regression from this PR. Root cause: the conformance test proxy (bigtable/internal/testproxy/proxy.go:438-447) pre-dials one *grpc.ClientConn and hands it to NewClientWithConfig via option.WithGRPCConn(conn). Before this PR that was fine — the classic pool was the only backend and it wrapped the single conn. My session backend addition made session.NewClient ALSO wrap the same conn (opts was reused verbatim), so both backends ended up entangled on the same underlying transport. Any session-side teardown or config-poll failure against the fake server propagated to the classic ClientConn as "rpc error: code = Canceled desc = grpc: the client connection is closing" on the very first Table.Apply. Same bug hits BIGTABLE_EMULATOR_HOST paths because internal/option.DefaultClientOptions injects WithGRPCConn internally when that env var is set. Fix: detect the WithGRPCConn sentinel via internaloption.NewUnsafeResolver(opts...).ResolvedGRPCConnIsCustom() and skip session backend construction entirely in that mode. Classic path is unaffected — every RPC still lands on the pre-dialed conn as before. sessionImpl stays nil; the existing nil-short-circuit in getOrCreateSession* and Client.Close covers it. Verified locally: bigtable conformance suite runs 64/0/0 pass/fail/skip in 53s against v0.0.3 of cloud-bigtable-clients-test.
session.NewClient was receiving the raw caller opts instead of the fully-merged option list (o). gtransport.Dial needs the DefaultClientOptions merged in — endpoint, scopes, user-agent, interceptors, connection-pool size, direct-access options — so passing bare opts leaves the resolver target empty and the dial aborts with 'passthrough: received empty target in Build()' as soon as a caller invokes NewClientWithConfig without option.WithEndpoint. Classic path was already using o for CreateAndStartManagedChannelPool so this brings session into line and fixes end-to-end connectivity against every prod endpoint pointed at by the default.
…20263) ## Summary Wraps `Client.sessionTables` (a bare `map[string]session.TableAPI` today) in a `sessionTableCache` that evicts entries idle for more than a TTL (default 1 h). The wrapper `sessionTableHandle` **IS the cache entry** and implements `session.TableAPI`, so every `ReadRow` / `MutateRow` touches lastAccess automatically — no cooperation from `TableShim` needed. ### Before - Cache grew monotonically for the `Client`'s lifetime. - Once opened, a resource's session `TableAPI` stayed in the map even if the caller stopped touching it — its pools kept running, its server-side slots stayed occupied. - Bounded by _(resources ever opened)_, not _(resources currently in use)_. ### After - Idle entries evict after 1 h. - Cache size tracks active use. - Caller can also `Close()` the returned handle explicitly for immediate eviction. ## Design — caching handle (single-type approach) ```go type sessionTableHandle struct { api session.TableAPI key string cache *sessionTableCache lastAccessNano atomic.Int64 closeOnce sync.Once } ``` Both eviction paths (caller-initiated + TTL sweep) route through `handle.Close`, guarded by `closeOnce` so double-close from any combination of paths is safe. `cache.removeEntry(key, h)` deletes only if the map still holds THIS handle — protects against a concurrent Open that already replaced the slot. ## Client.Close ordering Close now runs in three phases: 1. `sessionTables.close()` — stops the sweeper, `Close()`s every remaining handle. 2. `sessionImpl.Close()` — drops the session backend's shared gRPC channels + `ConfigurationManager` poller. 3. `mPool.Close()` — drops the classic gRPC channels. ## Caveat, documented on the handle type `sessionTable.Close()` in `bigtable/internal/session/table.go:226-228` is a no-op today — pools tear down only when `session.Client.Close` fires. So the eviction wiring in this PR is complete on the bigtable side but **only frees session pools** once the session package grows real per-resource teardown. That's a follow-up on the session side. ## Depends on - **#20262** — session-backend wiring in `Client` (adds `sessionImpl` + `sessionTables` fields this PR replaces the map on). ## Tests (all under `-race`) - `TestSessionTableCache_HandleIsCacheEntry` — repeat `getOrOpen` on same key returns identical `*sessionTableHandle`. - `TestSessionTableCache_ReadRowTouchesLastAccess` — wrapper's `ReadRow` bumps `lastAccess`. - `TestSessionTableCache_CloseEvictsAndFires` — `handle.Close` removes from map + calls `api.Close`; second `Open` mints a fresh handle; double-close safe (`closeOnce` guards cache eviction, not `api.Close` — which fires each call, per that call's own contract). - `TestSessionTableCache_TTLSweepEvictsIdle` — advance `fakeClock` past TTL, wait for 1 ms sweeper, verify entries gone + `Close`d. - `TestSessionTableCache_TouchDefersEviction` — touched entry stays alive across multiple half-TTL advances. - `TestSessionTableCache_CloseEvictsAll` — `c.close()` shuts sweeper and `Close`s every remaining entry; idempotent. - All 10 pre-existing `TestOpen*` / `TestGetOrCreateSession*` tests still green (the cache is transparent to them). ## Test plan - [x] `go build ./...` clean - [x] `go vet ./...` clean - [x] `goimports -l` no output - [x] `go test ./bigtable/ -run 'TestOpen|TestGetOrCreateSession|TestSessionTableCache' -count=1 -race -v` — 16/16 pass
The preDialed detection at NewClientWithConfig was reading from the raw caller opts, but BIGTABLE_EMULATOR_HOST injects option.WithGRPCConn inside btopt.DefaultClientOptions (bigtable/internal/option/option.go:113) — the emulator conn only lives in the merged option list (o), never in opts. Reading only from opts missed the emulator conn, session.NewClient tried to dial an empty resolver target, and every emulator-based test failed with 'passthrough: received empty target in Build()'. Repro: TestIntegration_NewClientWithEmulatorHost (integration_test.go:495). Post-fix that test passes.
…+ cache close-race gate (#20264) Fixes two related latent bugs in the session data plane: 1. **sessionTable.Close was a no-op** — the interface godoc at `bigtable/internal/session/api.go:45-48` promises to release the resource's read + write session pools; the implementation returned nil. Pools were reclaimed only by `sessionClient.Close`, so `bigtable.Client`'s TTL cache could evict a handle without freeing the underlying pool + streams + goroutines. 2. **sessionTableCache close-race (audit finding #5)** — a slow-path openFn straddling `sessionTableCache.close()` would install a fresh handle into a cache the sweeper had already stopped clearing. Zero-impact while sessionTable.Close was a no-op; a per-race pool leak once teardown becomes real. ## Design Real teardown routed through symmetric release closures: - `sessionClient.releaseSessionPool(key)` — under `sessionPoolsMu`, delete the entry then `unregister()` + `pool.Close()` outside the lock (matches the snapshot-under-lock pattern in `Close`). - `buildLazyReleaser(key)` — sibling to `buildLazyOpener`; returns a `func() error` closure for a specific poolKey. - `sessionTable.Close` — invokes closeRead + closeWrite, joining errors via `errors.Join`. Nil-safe for materialized views' missing write side. No refcount inside sessionClient. Rationale: `bigtable.Client`'s sessionTableCache dedupes handles per fully-qualified resource name, so at-most-one sessionTable per resource per Client at any moment — the cache is the 'refcount of at-most-1'. Doc on `sessionTable.Close` names the invariant; a future caller bypassing the cache would need to add a refcount then. Paired cache guard: `sessionTableCache` gains a `closed bool` under `c.mu`, flipped by `close()`. `getOrOpen`'s slow path re-checks it before insert and, if set, releases the freshly-opened api and returns nil so `TableShim` falls back to classic. Prevents the finding-#5 leak. ## Tests New unit tests (all pass under `-race`): - `TestSessionTable_Close_CallsBothReleasers` - `TestSessionTable_Close_NilWriteReleaserOK` (materialized view) - `TestSessionTable_Close_JoinsErrors` - `TestSessionTable_Close_ReleasersIdempotent` - `TestReleaseSessionPool_AfterClientClose_NoOp` - `TestReleaseSessionPool_MissingKeyNoOp` - `TestReleaseSessionPool_RemovesEntryAndInvokesUnregister` - `TestSessionTableCache_ClosedGate_SlowPathInsertNoLeak` ## Drive-by The pre-existing `closeCountingTable` test helper races between the sweeper goroutine's `*counter++` and the test-goroutine's read. Switched `*int` to `*atomic.Int32` so the full-package `-race` sweep is green (fires on the base branch too — this was blocking my race-stress verification). ## Test plan - [x] `go build ./...` and `go vet ./...` clean - [x] `go test -race -count=1 -short -timeout=120s ./ ./internal/session/ ./internal/transport/` — all green - [x] `TestSessionTable_Close*` and `TestReleaseSessionPool*` — all pass - [x] `TestSessionTableCache_ClosedGate_SlowPathInsertNoLeak` — reproduces the race deterministically, passes with the fix - [x] Sandbox smoke against sushanb-uc1 via `CBT_RUN_SANDBOX=1 CBT_FORCE_SESSION=true`: Table + AV round-trip on both classic and session paths ## Stack Stacked on #20263 (session_table_cache) which is stacked on #20262 (session.Client wiring into bigtable.Client Open*). Both must land first, or this PR must be rebased onto main after they merge.
Summary
Follow-up to #20256 (Diverter wiring). Makes
OpenTable/OpenAuthorizedView/OpenMaterializedViewactually producesession-routed
TableShims when the caller opts in viaClientConfig.EnableSessionPool = true. Without the opt-in, theshim's session
TableAPIstaysniland every routing decisionfalls through to classic — existing behavior preserved, existing
tests unchanged.
What changes
Client struct gains three fields:
ClientConfig gains
EnableSessionPool bool.NewClientWithConfig— whenEnableSessionPool = true:session.NewClient(ctx, project, instance, appProfile, metricsProvider, opts...)with the same option set as the classic pool (endpoint / credentials / dial hooks match).sc.AddSessionLoadListener(c.diverter.SetSessionLoad)so the server-drivenClientConfigurationManagershifts traffic across every openTableShimat once.session.NewClientfails, closes the classic pool and returns the error rather than leaking a half-constructedClient.Client.Closenow closessessionImpl(aggregating any error with the classic pool'sCloseerror). Session backend closes first — itsConfigurationManagerpoller and per-pool bookkeeping wind down before shared gRPC channels drop.open.go— threegetOrCreateSession*helpers cache per-resource sessionTableAPIs.Cache keys:
\"tbl:<t>\"for standard tables\"av:<t>:<v>\"for authorized views (table-qualified so two AVs with the same view-id under different tables get distinct session pools + distinct metric labels)\"mv:<v>\"for materialized viewssession.Clientitself does NOT cache — that's thisClient's responsibility. Each helper nil-short-circuits whensessionImpl == nilso classic-only callers pay zero lock cost per Open call.OpenTable/OpenAuthorizedView/OpenMaterializedViewnow pass the helper's return value toNewTableShiminstead ofnil.Tests (
bigtable/open_test.go— 5 new, 10 total)TestOpenTable_WithSessionBackend_WiresSessionTableAPI— with the session backend wired,OpenTable's shim carries a non-nilsession.TableAPIproduced bysessionImpl.OpenTable(table).TestOpenTable_SessionTableAPICacheHit— twoOpenTablecalls on the same table return the samesession.TableAPIpointer (identity check);sessionImpl.OpenTableis called only once.TestOpenAuthorizedView_SessionCacheIsTableQualified— two AVs with the same view-id under different tables get distinctsession.TableAPIinstances. Defense against a bug where\"av:<view>\"would silently share pools across tables.TestOpenMaterializedView_WithSessionBackend_WiresSessionTableAPI— same shape asOpenTable, plus cache-hit assertion.TestGetOrCreateSession_NilSessionImplReturnsNil— every helper returnsnilwithout taking the mutex whensessionImplis nil.Existing 5 tests (nil-session shape) still pass — they validate the
EnableSessionPool = falsedefault path.Behavior at a glance
OpenTablereturnsEnableSessionPool: false(default)TableShim{classic=tableImpl, session=nil, diverter}EnableSessionPool: trueTableShim{classic=tableImpl, session=cached session.TableAPI, diverter}Depends on
Client.diverterfield,NewDiverter(0.0)init, and theopen.gofile this PR extends.Test plan
go build ./...cleango vet ./...cleangoimports -l bigtable/{client,open,open_test}.go— no outputgo test ./bigtable/ -run 'TestOpen|TestGetOrCreate' -count=1 -v— 10/10 passNot in scope (follow-ups)
Client.Open() *Table(bare*Tablereturn) also route through the shim — Option B pattern withTable.divertiblefield +tableImpl.ReadRow/applyClassicrecursion break. Deferred to its own PR for review isolation.Clientaccessor methods for the debug interfaces (SessionDebug(),ChannelDebug(),ConfigDebug()) — needed by a futuredebugviewPR; deferred.