Skip to content

feat(bigtable): wire session.Client into Open* via TableShim - #20262

Open
sushanb wants to merge 19 commits into
googleapis:mainfrom
sushanb:feat/bigtable-client-session-backend
Open

feat(bigtable): wire session.Client into Open* via TableShim#20262
sushanb wants to merge 19 commits into
googleapis:mainfrom
sushanb:feat/bigtable-client-session-backend

Conversation

@sushanb

@sushanb sushanb commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Summary

Follow-up to #20256 (Diverter wiring). Makes OpenTable /
OpenAuthorizedView / OpenMaterializedView 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 preserved, existing
tests unchanged.

What changes

Client struct gains three fields:

sessionImpl     session.Client
sessionTablesMu sync.Mutex
sessionTables   map[string]session.TableAPI  // per-resource cache

ClientConfig gains EnableSessionPool bool.

NewClientWithConfig — when EnableSessionPool = true:

  1. Calls session.NewClient(ctx, project, instance, appProfile, metricsProvider, opts...) with the same option set as the classic pool (endpoint / credentials / dial hooks match).
  2. Wires sc.AddSessionLoadListener(c.diverter.SetSessionLoad) so the server-driven ClientConfigurationManager shifts traffic across every open TableShim at once.
  3. If session.NewClient fails, closes the classic pool and 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 shared gRPC channels drop.

open.go — three getOrCreateSession* helpers cache per-resource session TableAPIs.
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 views

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 the helper's return value to NewTableShim instead of nil.

Tests (bigtable/open_test.go — 5 new, 10 total)

  • TestOpenTable_WithSessionBackend_WiresSessionTableAPI — with the session backend wired, OpenTable's shim carries a non-nil session.TableAPI produced by sessionImpl.OpenTable(table).
  • 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.TableAPI instances. 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 5 tests (nil-session shape) still pass — they validate the EnableSessionPool = false default path.

Behavior at a glance

Config OpenTable returns Routing
EnableSessionPool: false (default) TableShim{classic=tableImpl, session=nil, diverter} Always classic (nil-session short-circuit; diverter never consulted)
EnableSessionPool: true TableShim{classic=tableImpl, session=cached session.TableAPI, diverter} Diverter ratio decides classic vs session per call

Depends on

Test plan

  • go build ./... clean
  • go vet ./... clean
  • goimports -l bigtable/{client,open,open_test}.go — no output
  • go test ./bigtable/ -run 'TestOpen|TestGetOrCreate' -count=1 -v — 10/10 pass

Not in scope (follow-ups)

  • Making Client.Open() *Table (bare *Table return) also route through the shim — Option B pattern with Table.divertible field + tableImpl.ReadRow/applyClassic recursion break. Deferred to its own PR for review isolation.
  • Client accessor methods for the debug interfaces (SessionDebug(), ChannelDebug(), ConfigDebug()) — needed by a future debugview PR; deferred.

sushanb added 11 commits July 29, 2026 16:18
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.
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).
@sushanb
sushanb requested review from a team as code owners July 29, 2026 20:07
@product-auto-label product-auto-label Bot added the api: bigtable Issues related to the Bigtable API. label Jul 29, 2026

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread bigtable/client.go Outdated
if sessionErr != nil {
// Best-effort cleanup of the classic pool since we won't
// return c to the caller.
_ = mPool.Pool.Close()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Calling mPool.Pool.Close() directly bypasses the ManagedChannelPool wrapper's Close() method, which may perform additional cleanup or resource tracking. It is safer and more consistent with Client.Close() to call mPool.Close() instead.

Suggested change
_ = mPool.Pool.Close()
_ = mPool.Close()

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

sushanb added 2 commits July 29, 2026 20:08
…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.
Comment thread bigtable/client.go Outdated
// server-driven traffic shaping; it does not by itself send any
// traffic to the session backend (the initial SessionLoad is
// 0.0).
EnableSessionPool bool

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if v, ok := os.LookupEnv("CBT_FORCE_SESSION"); ok {
it is here.

Okay I will remove the vairable then.

Comment thread bigtable/client.go
mPool: mPool,
diverter: btransport.NewDiverter(0.0),
}, nil
sessionTables: make(map[string]session.TableAPI),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do they every expires? I think we want a LRU cache instead of keeping these entries forever?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).
@sushanb

sushanb commented Jul 29, 2026

Copy link
Copy Markdown
Contributor Author

Scope narrowed in 18cb28a2f7: dropped ClientConfig.EnableSessionPool. NewClientWithConfig now always constructs a session.Client; the diverter is always wired up.

Rationale:

  • The opt-in split was cosmetic. Initial SessionLoad = 0.0, so zero RPCs hit the session data plane until a control-plane bump — the flag added no isolation, just a footgun.
  • Removing it lets a control-plane SessionLoad update route traffic mid-life with no client restart or user config change. That's the whole point of server-driven shaping.
  • One less thing a caller can forget to flip.

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.

Tests unchanged in intent; the nil sessionImpl guard tests stay — hand-built or emulator Clients can still have nil sessionImpl, and every getOrCreateSession* short-circuits on nil. Net diff: +54/-54 across client.go, open.go, open_test.go.

sushanb added 4 commits July 29, 2026 21:14
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).
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.
sushanb added a commit that referenced this pull request Jul 30, 2026
…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.
sushanb added a commit that referenced this pull request Jul 30, 2026
…+ 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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

api: bigtable Issues related to the Bigtable API.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants