Visitar URL original
feat(bigtable): TableShim fallback to classic on session UNIMPLEMENTED by sushanb · Pull Request #20269 · googleapis/google-cloud-go · GitHub
Skip to content

feat(bigtable): TableShim fallback to classic on session UNIMPLEMENTED - #20269

Merged
sushanb merged 6 commits into
googleapis:mainfrom
sushanb:feat/bigtable-tableshim-unimplemented-fallback
Jul 30, 2026
Merged

sushanb merged 6 commits into
googleapis:mainfrom
sushanb:feat/bigtable-tableshim-unimplemented-fallback

Conversation

@sushanb

@sushanb sushanb commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Summary

When the session-path RPC returns `codes.Unimplemented`, `TableShim`
transparently re-issues the request on the classic path AND flips a
sticky per-resource breaker so future calls skip the session route.

Why

Session data plane rolls out per-AFE. Backends that don't yet
implement session RPCs return UNIMPLEMENTED — today those errors fail
the user's request outright even though the classic path works fine.
This CL keeps the request successful and stops us from repeatedly
paying an RPC round-trip to a backend that can't handle it.

Design

Axis Choice Rationale
Signal `status.Code(err) == codes.Unimplemented` on outermost session err `consecutiveFailureError.GRPCStatus()` already preserves the code end-to-end; routing dispatches directly.
Placement `TableShim.ReadRow` / `.Apply` TableShim owns routing; `sessionTable` stays agnostic. Java parity (`DivertingUnaryCallable`, not the retry interceptor).
Granularity Per-`TableShim` (per-resource) Two tables in the same Client trip independently. Same as Java per-`SessionPool`.
Stickiness Forever within shim lifetime UNIMPLEMENTED is a backend capability signal, not transient. Fresh shim after `sessionTableCache` TTL-evict (~1h idle) starts un-tripped → implicit rollout recovery.
Concurrency `CompareAndSwap(false, true)` on `atomic.Bool` Coalesces concurrent Unimplemented responses so a follow-up debug tag / metric fires exactly once per resource.

What's out of scope

  • Threshold-based tolerance (Java uses 30 consecutive). Our signal is deterministic — a single Unimplemented is authoritative. Follow-up if we later want to tolerate transient Unimplemented across a rolling deploy.
  • Debug tag / metric — `RecordDebugTag` isn't exported publicly from `transport` yet. `tripSessionBreaker` is structured (CAS return) so a fire-once observability hook plugs in cleanly when the export lands.
  • `ReadRows` / `SampleRowKeys` / `ApplyBulk` / `ApplyReadModifyWrite` — already route classic-only.

Also closes the `TODO(mutianf)` in `session_pool_lifecycle.go` that
asked for exactly this routing flip. Comment updated to point at
TableShim as the owner.

Files

  • `bigtable/table_shim.go` — `sessionUnimplemented atomic.Bool` field, `tripSessionBreaker` helper, extended `useSession()`, fallback hooks in `ReadRow` + `Apply`.
  • `bigtable/table_shim_test.go` — 4 new deterministic tests + updated doc on `TestTableShim_SessionErrorNotRetriedOnClassic` naming the Unimplemented exception.
  • `bigtable/internal/transport/session_pool_lifecycle.go` — TODO(mutianf) → comment describing the TableShim breaker.

Test plan

  • `go build ./...` clean
  • `go vet ./...` clean
  • `goimports -l` clean
  • `go test -race -count=1 -short -timeout=180s ./ ./internal/transport/ ./internal/session/` — green, modulo pre-existing `TestIntegration_NewClientWithEmulatorHost` failure on `upstream/main` (unrelated: emulator-host resolver plumbing)
  • New tests fail as expected if I revert the fallback branch — verified by dry-running with the `status.Code == Unimplemented` check commented out

When the session-path RPC (SessionReadRow or SessionMutateRow) returns
gRPC codes.Unimplemented, TableShim transparently re-issues the same
request on the classic path and returns classic's result. A sticky
per-resource breaker (atomic.Bool) flips on the first Unimplemented so
subsequent calls skip the session path entirely — no point retrying a
backend that already said it doesn't understand this RPC.

Motivation: session data plane rolls out per-AFE. Some backends do not
yet implement session RPCs and return UNIMPLEMENTED — today those
errors fail the user's request outright. This CL keeps the request
successful (via classic) and stops retrying session on that resource
until a fresh shim materializes (e.g. after the sessionTableCache
TTL-evicts this handle in bigtable.Client).

Design shape:
  * Signal: status.Code(err) == codes.Unimplemented on the outermost
    error from session.TableAPI. Session-side wrappers already
    preserve the code via consecutiveFailureError.GRPCStatus() (see
    session_pool.go), so routing dispatches directly on it.
  * Placement: TableShim owns routing; sessionTable stays agnostic.
    Mirrors Java's DivertingUnaryCallable placement, not the retry
    interceptor.
  * Granularity: per-resource (per TableShim). Two tables in the same
    Client trip independently. Same as Java per-SessionPool.
  * Stickiness: forever within the shim's lifetime. UNIMPLEMENTED is
    a backend capability signal, not a transient failure — no counter,
    no threshold. Fresh shim (new OpenTable after TTL-evict) starts
    un-tripped for the implicit rollout-recovery path.
  * Coalescing: tripSessionBreaker uses CompareAndSwap so a debug tag
    / metric wired on the winning CAS (follow-up) fires exactly once
    per resource under concurrent Unimplemented responses.

What is NOT in scope:
  * Threshold-based tolerance (Java uses 30). Our signal is
    deterministic; a single Unimplemented is enough. Follow-up if we
    later want to tolerate transient Unimplemented across a rolling
    deploy.
  * Debug tag / metric — RecordDebugTag isn't publicly exported from
    transport yet. tripSessionBreaker is structured (CompareAndSwap
    return) so a fire-once observability hook plugs in cleanly when
    the export lands.
  * ReadRows / SampleRowKeys / ApplyBulk / ApplyReadModifyWrite —
    these already route classic-only.

Also closes the TODO(mutianf) in session_pool_lifecycle.go that asked
for exactly this routing flip. Updated the comment to point at
TableShim as the owner.

Tests (bigtable/table_shim_test.go, all deterministic, -race clean):
  * TestTableShim_ReadRow_UnimplementedFallsBackToClassic
  * TestTableShim_Apply_UnimplementedFallsBackToClassic
  * TestTableShim_BreakerTrippedSkipsSession — 10 calls after trip,
    session invoker sees 1 attempt total
  * TestTableShim_NonUnimplementedError_DoesNotFallBack — regression
    over 7 gRPC codes; breaker must not trip on non-Unimplemented
  * Existing TestTableShim_SessionErrorNotRetriedOnClassic still
    holds (uses plain errors.New — status.Code = Unknown, no
    fallback); doc updated to name the Unimplemented exception.

Verified:
  * go build ./... clean
  * go vet ./... clean
  * go test -race -count=1 -short -timeout=180s ./ ./internal/transport/ ./internal/session/ — green modulo pre-existing TestIntegration_NewClientWithEmulatorHost on upstream/main (unrelated: emulator-host resolver plumbing)
@sushanb
sushanb requested review from a team as code owners July 30, 2026 17:25
@product-auto-label product-auto-label Bot added the api: bigtable Issues related to the Bigtable API. label Jul 30, 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 implements a transparent fallback to the classic gRPC path when the session-path RPC returns codes.Unimplemented (indicating the backend is not rolled out). It introduces a sticky, per-resource atomic breaker (sessionUnimplemented) in TableShim to short-circuit future session calls, along with comprehensive unit tests. The review feedback suggests returning a boolean from tripSessionBreaker to expose the winning CAS for future observability, and using errors.As via a helper function to robustly detect wrapped codes.Unimplemented errors.

Comment thread bigtable/table_shim.go Outdated
Comment on lines 181 to 183
func (t *TableShim) tripSessionBreaker() {
t.sessionUnimplemented.CompareAndSwap(false, true)
}

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

The PR description mentions that tripSessionBreaker is structured with a CAS return so a fire-once observability hook can plug in cleanly. However, the current implementation of tripSessionBreaker has a void return signature and does not return the boolean result of CompareAndSwap.

Let's update the signature to return bool so that future observability hooks can easily detect the winning CAS.

Additionally, to robustly handle wrapped gRPC errors (e.g., if the error is wrapped with %w by middleware or interceptors), we should use errors.As instead of status.Code(err). We can define a helper function isUnimplemented here to encapsulate this logic.

Note: Please ensure "errors" is added to the import block at the top of the file.

Suggested change
func (t *TableShim) tripSessionBreaker() {
t.sessionUnimplemented.CompareAndSwap(false, true)
}
func (t *TableShim) tripSessionBreaker() bool {
return t.sessionUnimplemented.CompareAndSwap(false, true)
}
func isUnimplemented(err error) bool {
var s interface{ GRPCStatus() *status.Status }
return errors.As(err, &s) && s.GRPCStatus().Code() == codes.Unimplemented
}

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.

Obsolete after the interceptor refactor (61f1f9b chain): tripSessionBreaker is gone; the CAS now lives inside session.UnimplementedErrorInterceptor.RecordOutcome and is called on threshold-crossing. If we want to wire a fire-once metric later, the same CAS return can be exposed there — cheaper than plumbing a bool up through TableShim.

On errors.As vs status.Code: they behave identically for wrapped gRPC errors. grpc-go's status.FromError walks the error chain via errors.As looking for the GRPCStatus() *Status interface (see grpc-go status.go), so status.Code(err) already handles %w-wrapped errors. Keeping the canonical helper.

Comment thread bigtable/table_shim.go Outdated
Comment on lines +99 to +102
if status.Code(err) == codes.Unimplemented {
t.tripSessionBreaker()
return t.classic.ReadRow(ctx, row, opts...)
}

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

Use the isUnimplemented helper to robustly check for codes.Unimplemented even if the error is wrapped.

Suggested change
if status.Code(err) == codes.Unimplemented {
t.tripSessionBreaker()
return t.classic.ReadRow(ctx, row, opts...)
}
if isUnimplemented(err) {
t.tripSessionBreaker()
return t.classic.ReadRow(ctx, row, opts...)
}

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.

Obsolete after the interceptor refactor: the ReadRow fallback branch moved into session.InterceptUnimplemented (61f1f9b chain), and it uses status.Code(err) == codes.Unimplemented — which already walks %w-wrapped errors via grpc-go's status.FromError → errors.As lookup for the GRPCStatus interface. Same behavior as the suggested helper, just via the canonical grpc-go path.

Comment thread bigtable/table_shim.go Outdated
Comment on lines +134 to +137
if status.Code(err) == codes.Unimplemented {
t.tripSessionBreaker()
return t.classic.Apply(ctx, row, m, opts...)
}

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

Use the isUnimplemented helper to robustly check for codes.Unimplemented even if the error is wrapped. Additionally, since this is a shim layer, ensure that nil arguments (such as nil mutations) are explicitly checked and routed to paths that safely handle them to avoid nil pointer dereferences.

		if m == nil {
			return fmt.Errorf("mutation cannot be nil")
		}
		if isUnimplemented(err) {
			t.tripSessionBreaker()
			return t.classic.Apply(ctx, row, m, opts...)
		}
References
  1. When implementing a shim or proxy layer, ensure that nil arguments (such as nil mutations) are explicitly checked and routed to paths that safely handle them (e.g., returning an error) to avoid nil pointer dereferences and preserve existing error handling behavior.

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.

Two parts:

isUnimplemented helper: obsolete after the interceptor refactor — the fallback branch moved into session.InterceptUnimplemented (61f1f9b chain), and it uses status.Code(err) == codes.Unimplemented which already walks %w-wrapped errors via grpc-go's status.FromError internals.

Nil-mutation check: not needed at this specific line. Apply already handles nil at the top:

if m == nil || m.isConditional {
    return t.classic.Apply(ctx, row, m, opts...)
}

The interceptor branch is only reachable when m != nil. Classic's Apply owns nil-handling semantics (existing contract); duplicating a nil-check here would change user-visible behavior for nil mutations.

sushanb added 2 commits July 30, 2026 17:41
…im breaker trips

Java parity: match SessionPoolImpl's MAX_CONSECUTIVE_UNIMPLEMENTED_FAILURES = 30
(ShimImpl.java:77). Previously a single Unimplemented flipped the sticky
per-resource breaker; now the counter must reach threshold. Per-call
fallback rescue is unchanged — every Unimplemented still transparently
re-issues on classic, so user-visible behavior is 'always succeeds via
classic' regardless of how close the breaker is to tripping.

The counter follows Java's reset semantics, adapted from Java's session-
close observation point to our per-op response point:
  - session succeeds → count = 0
  - session errors with any non-Unimplemented code → count = 0
    (RPC is understood; failure is transport/app-level)
  - session errors with Unimplemented → count++; if >= threshold, trip

Both reset conditions are proof the wire understood the RPC, so any
prior Unimplemented streak must have been transient or from a different
AFE in the pool. Prevents monotonically-growing 'lifetime Unimplemented'
that would trip long-running clients from scattered transient failures.

New helper on TableShim:

  func (t *TableShim) recordSessionOutcome(err error) {
      if err == nil || status.Code(err) != codes.Unimplemented {
          t.unimplementedCount.Store(0)
          return
      }
      if n := t.unimplementedCount.Add(1); n >= sessionUnimplementedThreshold {
          t.sessionUnimplemented.CompareAndSwap(false, true)
      }
  }

Called by ReadRow and Apply immediately after the session RPC, before
the per-call fallback decision. Replaces the earlier tripSessionBreaker
(which flipped the bit on every Unimplemented).

sessionUnimplementedThreshold is a package-level var = 30 (not const)
so tests can drop it to 1 or 3 for fast breaker-trip assertions without
exposing a public setter — same pattern as sessionTableCacheTTL /
sessionTableCacheSweepInt in session_table_cache.go.

New tests (all deterministic, -race clean):
  * TestTableShim_UnimplementedTripsBreakerAtThreshold — pins the
    counter transition: N-1 Unimplemented do NOT trip; the Nth does.
    Uses threshold=3 to test the boundary explicitly.
  * TestTableShim_UnimplementedCounterResetsOnSuccess — 2 unimpl +
    1 success + 2 unimpl; total 4 unimpl but never 3 consecutive;
    breaker stays untripped.
  * TestTableShim_UnimplementedCounterResetsOnNonUnimplementedError
    — same shape but reset via an Unavailable error.
  * TestTableShim_BreakerTrippedSkipsSession — updated to override
    threshold to 1 via new withThreshold(t, v) helper.

All 4 pre-existing Unimplemented-fallback tests unchanged (per-call
rescue behavior is unchanged; only the sticky-trip condition moved
from 'first' to 'threshold-th').
…on.UnimplementedErrorInterceptor

Turns the ad-hoc counter+trip+fallback logic that had accreted in
TableShim into a single reusable type owned by the internal/session
package. TableShim's ReadRow and Apply now route their session call
through session.InterceptUnimplemented; useSession() gates on the
interceptor's Bypass() rather than a TableShim-local atomic bit.

Why the refactor:

  * Explicit pattern name — reviewers see 'UnimplementedErrorInterceptor'
    and immediately know it's a per-op wrapper with fallback semantics.
    The prior inline shape (a field + a helper + two ad-hoc branches
    in ReadRow / Apply) was easy to miss.
  * Testability in isolation — the interceptor's counter/reset/trip
    contract can be unit-tested against a pure error input without
    standing up a TableShim. (Not exercised in this CL — existing
    TableShim tests cover the wiring — but reasonable to grow direct
    interceptor tests as behaviour expands.)
  * Reusability — any future routing surface with a session/classic
    split (e.g. an authorized-view shim, a materialized-view shim,
    a bulk-op wrapper) can wire the same interceptor. The concrete
    session/classic knowledge stays in the caller's closures.

Where the interceptor lives:

  bigtable/internal/session/unimplemented_interceptor.go — belongs
  under internal/session because (a) the codes.Unimplemented signal
  is a session-plane concept and (b) it's already a dependency of
  every consumer that would use it (TableShim already imports
  internal/session for TableAPI).

Public surface:

  type UnimplementedErrorInterceptor struct { ... }
  func NewUnimplementedErrorInterceptor() *UnimplementedErrorInterceptor
  func (i *UnimplementedErrorInterceptor) Bypass() bool
  func (i *UnimplementedErrorInterceptor) Count() int32
  func (i *UnimplementedErrorInterceptor) RecordOutcome(err error)
  func InterceptUnimplemented[T any](i *UnimplementedErrorInterceptor,
      sessionOp func() (T, error), classicOp func() (T, error)) (T, error)
  var UnimplementedThreshold int32 = 30

  InterceptUnimplemented is a free function because Go 1.24 still
  forbids methods with additional type parameters. When the language
  lifts that, it becomes (i *UnimplementedErrorInterceptor) Intercept[T].

Behavior unchanged:

  * Per-call rescue on any codes.Unimplemented — still runs classicOp
    and returns its result.
  * Reset semantics — success or non-Unimplemented error resets the
    consecutive count to 0.
  * Threshold — still 30 (matches Java ShimImpl.java:77).
  * Sticky trip — still one-way via CompareAndSwap; useSession()
    short-circuits after trip.

Test-file mechanical churn:

  * Renamed local vars from 'session' to 'sess' throughout so the
    new 'session' import isn't shadowed. All 13 TableShim tests still
    green under -race.
  * withThreshold(t, v) helper now flips session.UnimplementedThreshold
    (was the package-local sessionUnimplementedThreshold).
…uage refs

Interceptor, TableShim, session_pool_lifecycle, and test-file comment
tightening. No behavior change. Removes Java citations, per-comparison
prose, and the wall-of-text field / test docstrings that had accreted
while iterating; keeps the WHY (invariants, non-obvious
constraints) and drops the WHAT-already-visible-from-code.
// UnimplementedThreshold is the number of consecutive
// codes.Unimplemented responses required to trip the sticky breaker.
// Var (not const) so tests can lower it without a public setter.
var UnimplementedThreshold int32 = 30

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.

why not const

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.

Done in 61f1f9b. Turned it into session.DefaultUnimplementedThreshold (const int32 = 30) and threaded the actual value through the interceptor constructor: NewUnimplementedErrorInterceptor(threshold int32), stored on the struct, read by RecordOutcome as i.threshold. Production callers pass DefaultUnimplementedThreshold; tests build their own interceptor with a smaller value via the withThreshold(t, shim, N) helper (which now swaps the shim's interceptor rather than mutating a global). No behavior change.

sushanb added 2 commits July 30, 2026 20:22
…default

Address sushanb review comment on PR googleapis#20269: 'why not const' for
session.UnimplementedThreshold.

The var-not-const shape existed only so tests could override the
threshold at runtime. Cleaner design: pass the threshold to the
interceptor's constructor. Callers use session.DefaultUnimplementedThreshold
(const int32 = 30) in production; tests build the interceptor with
their own value.

Changes:
  * session.UnimplementedThreshold var → session.DefaultUnimplementedThreshold const.
  * NewUnimplementedErrorInterceptor(threshold int32) — threshold
    stored on the struct; RecordOutcome compares against i.threshold.
  * TableShim's NewTableShim passes DefaultUnimplementedThreshold.
  * withThreshold test helper now swaps the shim's interceptor for
    one built with a smaller threshold, instead of mutating a global.
    Signature: withThreshold(t, TableAPI, threshold).

No behavior change in production. All 13 TableShim tests still green
under -race.
Previous conformance failure was a flake in the Go testproxy's
rowToProto (bigtable/internal/testproxy/proxy.go:53) — iterates a
Go map with randomized order, so the returned btpb.Row.Families
sequence flips ~50% of the time on 2-family rows and trips
TestReadRow_NoRetry_CommitInSeparateChunk's protocmp order-sensitive
assertion. Not caused by this PR.
@sushanb
sushanb merged commit 36540af into googleapis:main Jul 30, 2026
19 of 20 checks passed
sushanb pushed a commit that referenced this pull request Aug 3, 2026
🤖 I have created a release *beep* *boop*
---


##
[1.52.0](bigtable/v1.51.0...bigtable/v1.52.0)
(2026-08-03)


### Features

* **bigtable:** Add AFE picker (Simple / LeastInFlight / LeastLatency)
([#20204](#20204))
([bcbf714](bcbf714))
* **bigtable:** Add ClientConfig.DisableSession to opt out of session
backend
([#20297](#20297))
([7ee5e44](7ee5e44))
* **bigtable:** Add getClientConfigDirectAccessChecker for session pools
([#20209](#20209))
([3b8d30a](3b8d30a))
* **bigtable:** Add NoOpChannelPrimer for session channel pools
([#20208](#20208))
([d055a8a](d055a8a))
* **bigtable:** Add per-AFE sessionList for the two-tier session pool
([#20224](#20224))
([dbf0c3f](dbf0c3f))
* **bigtable:** Add protoRowToRow conversion helper for TableShim
([#20257](#20257))
([1297143](1297143))
* **bigtable:** Add Session debug surface (observability fields +
methods)
([#20211](#20211))
([d8d3e16](d8d3e16))
* **bigtable:** Add Session lifecycle (Start, Close, ForceClose,
readLoop, heartBeatLoop)
([#20215](#20215))
([b9e53c6](b9e53c6))
* **bigtable:** Add Session struct + state machine
([#20117](#20117))
([09acbb3](09acbb3))
* **bigtable:** Add session.Config.EnableDebug to gate sessionz debug
state
([#20247](#20247))
([ce74c31](ce74c31))
* **bigtable:** Add SessionClient + SessionTable + lazyPool
([#20228](#20228))
([ab2c96c](ab2c96c))
* **bigtable:** Add SessionPoolImpl (two-tier pool + scaling + debug)
([#20225](#20225))
([683eda8](683eda8))
* **bigtable:** Rename session pool display to
<resource-id>-<PERM>
([#20248](#20248))
([35e146e](35e146e))
* **bigtable:** Route Client.Open()-returned *Table through the Diverter
([#20273](#20273))
([2b81c7d](2b81c7d))
* **bigtable:** State-based classification for abnormal session close
([#20243](#20243))
([f2905b7](f2905b7))
* **bigtable:** TableShim fallback to classic on session UNIMPLEMENTED
([#20269](#20269))
([36540af](36540af))
* **bigtable:** TTL-on-idle cache for per-resource session.TableAPI
([#20263](#20263))
([00b2a49](00b2a49))
* **bigtable:** Wire Diverter on Client and route Open* via TableShim
([#20256](#20256))
([b32fbd7](b32fbd7))


### Bug Fixes

* **bigtable:** AFE picker latency signal — subtract poolWait and
compute TransportLatency = wire − backend at source
([#20281](#20281))
([bb8c4d5](bb8c4d5))
* **bigtable:** Guard NewStream OnFinish against grpc-go double-fire
([#20295](#20295))
([b51da29](b51da29))
* **bigtable:** Real per-resource pool teardown on sessionTable.Close +
cache close-race gate
([#20264](#20264))
([599aea9](599aea9))
* **bigtable:** Session.durations / session.uptime — set explicit
histogram bucket boundaries
([#20276](#20276))
([97eee22](97eee22))
* **bigtable:** SessionTableHandle self-heals across cache eviction
([#20296](#20296))
([0dd98cd](0dd98cd))
* **bigtable:** Translate ctx errors to gRPC status on session vRPC
([#20299](#20299))
([0f3b2a5](0f3b2a5))
* **bigtable:** Treat PingAndWarm NotFound as a successful prime
([#20219](#20219))
([a1557ad](a1557ad))


### Performance Improvements

* **bigtable:** Delete periodic Tick loop; sizing is event-driven
([#20285](#20285))
([2c096bd](2c096bd))
* **bigtable:** Drop pick_lost_race debug tag from CheckoutSession hot
path
([#20280](#20280))
([bd0e400](bd0e400))

---
This PR was generated with [Release
Please](https://github.com/googleapis/release-please). See
[documentation](https://github.com/googleapis/release-please#release-please).

Co-authored-by: release-please[bot] <55107282+release-please[bot]@users.noreply.github.com>
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