Skip to content

fix(bigtable/accelerator): collapse cold-start openHandle bursts via singleflight - #20380

Open
sushanb wants to merge 2 commits into
googleapis:mainfrom
sushanb:fix/bigtable-accelerator-openhandle-singleflight
Open

fix(bigtable/accelerator): collapse cold-start openHandle bursts via singleflight#20380
sushanb wants to merge 2 commits into
googleapis:mainfrom
sushanb:fix/bigtable-accelerator-openhandle-singleflight

Conversation

@sushanb

@sushanb sushanb commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Problem

Channel.openHandle is called per RPC from the accelerator's dispatch path. On the first burst of traffic to a fresh resource (or after a TableCache TTL eviction), N concurrent RPCs all reach sessionTables.GetOrOpen with the same cold key. Under GetOrOpen's miss handling, every racer builds its own sessionTable via sc.OpenTable(...), N-1 lose the insertion race, and each loser's Close() runs the sessionTable's release path. Against a shared-key session pool, that release tore down the pool the winner already memoized — every subsequent RPC through the winner's handle served ErrPoolClosed.

Top-level client.OpenTable(t) (see bigtable/open.go) never triggered the bug because it warms the cache exactly once at construction, single-threaded from the caller's setup path. The concurrent-miss race the tests in bigtable/internal/session/pr20366_repro_test.go pin was unreachable from that caller shape. But the accelerator's per-RPC openHandle reached it on the very first QPS burst.

Fix

Two additions to *Channel:

  • handleFastPath sync.Map — lock-free per-resource cache mirroring sessionTables. On hit, skips the closure allocation, res.Kind switch, resource-name parse, and sessionTables.mu acquire. Staleness is safe because *TableHandle.ReadRow / MutateRow self-heal via TableCache's internal dispatch() on TTL eviction, so a pointer left behind here still routes to the live successor on next use.

  • openGroup singleflight.Group — collapses the cold-start burst. All N concurrent missers for a resource funnel through one openFn call; the waiters block on singleflight's internal chan and receive the same handle. sessionTables.GetOrOpen's loser branch fires zero times from this call site, regardless of the underlying session-layer state.

Result: sc.OpenTable is called exactly once per resource per Channel lifetime, matching the client.OpenTable(t)-then-share-the-shim shape that never triggered the bug in production. TableCache still owns pool lifecycle, TTL, and cross-caller dedup; the accelerator just stops stressing its loser branch from its own call site.

Composition with #20366 / #20368

Complementary, not overlapping:

Verified by running the ported repro tests on pr-20368: both TestPR20366_* PASS. On the current branch without any session-layer fix, this accelerator change alone makes the burst benign because only one sc.OpenTable per resource ever runs.

Test plan

bigtable/internal/accelerator/accelerator_singleflight_test.go adds TestOpenHandle_SingleFlightCollapsesColdStartBurst:

  • Wraps mockSessionClient in a slowSessionClient that blocks OpenTable on a barrier and counts real invocations.
  • Fans out N=32 concurrent openHandle callers for the same fresh resource.
  • Uses a select on sc.entered / time.After(500ms) that fires whichever comes first — with singleflight, only one goroutine reaches OpenTable and the barrier's arrival count never hits N; without singleflight, arrival hits N almost immediately.
  • Asserts sc.openTableCalls == 1 and asserts a follow-up call is a fast-path hit (still 1).
  • Without the fix: sc.OpenTable was invoked 32 times; want 1.
  • With the fix: PASS in ~500ms.

Full accelerator suite passes under -race:

ok  cloud.google.com/go/bigtable/internal/accelerator            1.596s
ok  cloud.google.com/go/bigtable/internal/accelerator/adapters   1.017s
ok  cloud.google.com/go/bigtable/internal/accelerator/cmd        1.030s
  • Reviewer sanity-check: the fast-path Load returns a session.TableAPI interface value whose underlying *TableHandle self-heals on TTL eviction; no need to proactively invalidate handleFastPath on eviction events. (Duplicating that invariant would drift.)
  • Reviewer sanity-check: Close drains handleFastPath before sessionTables.Close so a concurrent openHandle racing Close either sees the empty map or a stale handle whose next RPC surfaces the closed-pool error — no lingering strong reference from Channel.

…singleflight + sync.Map fast path

Under high QPS, N concurrent first-touch RPCs for the same resource all
called sessionTables.GetOrOpen concurrently, N-1 lost the insertion race,
and each loser's Close() ran sessionTable's release path. Against a
shared-key session pool that release tore down the pool the winner
already memoized and served ErrPoolClosed on every subsequent RPC through
that handle. Top-level client.OpenTable never exposed this path because
it warms the cache exactly once at construction, so the concurrent-miss
race the tests in bigtable/internal/session/pr20366_repro_test.go pin
was unreachable from that caller shape — but reachable from the
accelerator's per-RPC openHandle.

Two additions to *Channel:

- handleFastPath sync.Map: pre-lookup for the hot path. Skips the
  closure allocation, res.Kind switch, resource-name parse, and
  sessionTables.mu acquire on every cache-hit RPC. Staleness is safe
  because TableHandle.ReadRow / MutateRow self-heal via dispatch() when
  the underlying cache entry is TTL-evicted, so a pointer left behind
  here still routes to the live successor on next use.

- openGroup singleflight.Group: collapses the cold-start burst. All N
  concurrent missers for a resource funnel through one openFn call; the
  waiters block on singleflight's internal chan and receive the same
  handle. The loser branch inside sessionTables.GetOrOpen fires zero
  times from this call site, regardless of the session-layer state.

Result: sc.OpenTable is now called exactly once per resource per Channel
lifetime, matching the client.OpenTable(t)-then-share-the-shim pattern
that never triggered the bug in production.

Includes accelerator_singleflight_test.go: fans out 32 concurrent
openHandle callers behind a barrier and asserts sc.OpenTable invocation
count == 1. Without the fix the same test observes 32 invocations.
@sushanb
sushanb requested review from a team as code owners August 12, 2026 21:28
@product-auto-label product-auto-label Bot added the api: bigtable Issues related to the Bigtable API. label Aug 12, 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 a fast-path cache (sync.Map) and a singleflight group to the Channel struct to optimize resource handle retrieval and collapse concurrent cold-start bursts, preventing session-layer race conditions. The review feedback suggests introducing an atomic closed boolean flag to the Channel struct to safely track its lifecycle, ensuring that concurrent or subsequent handle lookups do not store stale handles after Close() has been called.

Comment on lines +30 to +33
"context"
"io"
"strings"
"sync"

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

To support tracking the closed state of the channel safely across concurrent goroutines, we need to import the sync/atomic package.

Suggested change
"context"
"io"
"strings"
"sync"
"context"
"io"
"strings"
"sync"
"sync/atomic"

Comment on lines +166 to 167
openGroup singleflight.Group
}

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

Add a closed atomic boolean field to the Channel struct to safely track whether the channel has been closed. This helps prevent race conditions where stale handles are stored in handleFastPath after Close() has run.

Suggested change
openGroup singleflight.Group
}
openGroup singleflight.Group
closed atomic.Bool
}
References
  1. In Go projects using Go 1.19 or later, prefer using typed atomics (such as atomic.Int64, atomic.Uint64, atomic.Bool) instead of raw types with package-level sync/atomic functions.

Comment on lines 213 to 275
func (c *Channel) openHandle(res adapters.Resource) (session.TableAPI, error) {
var open func() session.TableAPI
switch res.Kind {
case adapters.ResourceTable:
tableID, err := c.parseTableName(res.Name)
if err != nil {
return nil, err
// Fast path — a bare atomic Load. No mutex, no closure allocation, no
// resource-name parse. Under steady state every RPC after the first for a
// given resource hits here.
if v, ok := c.handleFastPath.Load(res.Name); ok {
return v.(session.TableAPI), nil
}

// Slow path — collapsed via singleflight so N concurrent first-touch RPCs
// for the same resource funnel through ONE sessionTables.GetOrOpen call.
// See the openGroup field comment for the failure mode this guards against.
v, err, _ := c.openGroup.Do(res.Name, func() (any, error) {
// Second cache check inside the singleflight — a prior burst's winner
// may have published between our fast-path miss and our arrival here.
if v, ok := c.handleFastPath.Load(res.Name); ok {
return v, nil
}
open = func() session.TableAPI { return c.sc.OpenTable(tableID) }
case adapters.ResourceAuthorizedView:
tableID, viewID, err := c.parseAuthorizedViewName(res.Name)
if err != nil {
return nil, err
}
open = func() session.TableAPI { return c.sc.OpenAuthorizedView(tableID, viewID) }
case adapters.ResourceMaterializedView:
viewID, err := c.parseMaterializedViewName(res.Name)
if err != nil {
return nil, err

var open func() session.TableAPI
switch res.Kind {
case adapters.ResourceTable:
tableID, err := c.parseTableName(res.Name)
if err != nil {
return nil, err
}
open = func() session.TableAPI { return c.sc.OpenTable(tableID) }
case adapters.ResourceAuthorizedView:
tableID, viewID, err := c.parseAuthorizedViewName(res.Name)
if err != nil {
return nil, err
}
open = func() session.TableAPI { return c.sc.OpenAuthorizedView(tableID, viewID) }
case adapters.ResourceMaterializedView:
viewID, err := c.parseMaterializedViewName(res.Name)
if err != nil {
return nil, err
}
open = func() session.TableAPI { return c.sc.OpenMaterializedView(viewID) }
default:
// Kind is set by the adapter from the populated V2 name field, so an
// unrecognized kind is an internal invariant violation, not bad input.
return nil, status.Errorf(codes.Internal, "accelerator: unknown resource kind %v", res.Kind)
}
open = func() session.TableAPI { return c.sc.OpenMaterializedView(viewID) }
default:
// Kind is set by the adapter from the populated V2 name field, so an
// unrecognized kind is an internal invariant violation, not bad input.
return nil, status.Errorf(codes.Internal, "accelerator: unknown resource kind %v", res.Kind)
}

// Key on the full V2 name (the identity Cloud Bigtable uses on the wire),
// so table / authorized-view / materialized-view keys never collide. A nil
// handle means the cache has been Closed (Channel.Close), so the channel is
// no longer usable.
tbl := c.sessionTables.GetOrOpen(res.Name, open)
if tbl == nil {
return nil, status.Error(codes.Unavailable, "accelerator: channel is closed")
// Key on the full V2 name (the identity Cloud Bigtable uses on the wire),
// so table / authorized-view / materialized-view keys never collide. A nil
// handle means the cache has been Closed (Channel.Close), so the channel is
// no longer usable.
tbl := c.sessionTables.GetOrOpen(res.Name, open)
if tbl == nil {
return nil, status.Error(codes.Unavailable, "accelerator: channel is closed")
}
// Publish for future fast-path lookups. Only the singleflight winner
// reaches this point per resource per cold-start burst, so LoadOrStore
// here is defensive — a plain Store would also be correct.
actual, _ := c.handleFastPath.LoadOrStore(res.Name, tbl)
return actual, nil
})
if err != nil {
return nil, err
}
return tbl, nil
return v.(session.TableAPI), nil
}

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

Check the closed flag at the beginning of openHandle and right before storing the table handle in handleFastPath. This prevents a race condition where a concurrent Close() clears the map, but openHandle subsequently overwrites it with a stale handle.

func (c *Channel) openHandle(res adapters.Resource) (session.TableAPI, error) {
	if c.closed.Load() {
		return nil, status.Error(codes.Unavailable, "accelerator: channel is closed")
	}

	// Fast path — a bare atomic Load. No mutex, no closure allocation, no
	// resource-name parse. Under steady state every RPC after the first for a
	// given resource hits here.
	if v, ok := c.handleFastPath.Load(res.Name); ok {
		return v.(session.TableAPI), nil
	}

	// Slow path — collapsed via singleflight so N concurrent first-touch RPCs
	// for the same resource funnel through ONE sessionTables.GetOrOpen call.
	// See the openGroup field comment for the failure mode this guards against.
	v, err, _ := c.openGroup.Do(res.Name, func() (any, error) {
		// Second cache check inside the singleflight — a prior burst's winner
		// may have published between our fast-path miss and our arrival here.
		if v, ok := c.handleFastPath.Load(res.Name); ok {
			return v, nil
		}

		var open func() session.TableAPI
		switch res.Kind {
		case adapters.ResourceTable:
			tableID, err := c.parseTableName(res.Name)
			if err != nil {
				return nil, err
			}
			open = func() session.TableAPI { return c.sc.OpenTable(tableID) }
		case adapters.ResourceAuthorizedView:
			tableID, viewID, err := c.parseAuthorizedViewName(res.Name)
			if err != nil {
				return nil, err
			}
			open = func() session.TableAPI { return c.sc.OpenAuthorizedView(tableID, viewID) }
		case adapters.ResourceMaterializedView:
			viewID, err := c.parseMaterializedViewName(res.Name)
			if err != nil {
				return nil, err
			}
			open = func() session.TableAPI { return c.sc.OpenMaterializedView(viewID) }
		default:
			// Kind is set by the adapter from the populated V2 name field, so an
			// unrecognized kind is an internal invariant violation, not bad input.
			return nil, status.Errorf(codes.Internal, "accelerator: unknown resource kind %v", res.Kind)
		}

		// Key on the full V2 name (the identity Cloud Bigtable uses on the wire),
		// so table / authorized-view / materialized-view keys never collide. A nil
		// handle means the cache has been Closed (Channel.Close), so the channel is
		// no longer usable.
		tbl := c.sessionTables.GetOrOpen(res.Name, open)
		if tbl == nil {
			return nil, status.Error(codes.Unavailable, "accelerator: channel is closed")
		}
		if c.closed.Load() {
			return nil, status.Error(codes.Unavailable, "accelerator: channel is closed")
		}
		// Publish for future fast-path lookups. Only the singleflight winner
		// reaches this point per resource per cold-start burst, so LoadOrStore
		// here is defensive — a plain Store would also be correct.
		actual, _ := c.handleFastPath.LoadOrStore(res.Name, tbl)
		return actual, nil
	})
	if err != nil {
		return nil, err
	}
	return v.(session.TableAPI), nil
}
References
  1. When implementing lazy initialization of resources alongside a Close or teardown method, use an atomic boolean flag and a guard wrapper around the lazy openers. The guard should check the flag before opening, and re-check it after opening to immediately release the resource if Close was called concurrently, preventing resource leaks.

Comment on lines 356 to 371
if c.sc == nil {
return nil
}
// Drop fast-path references before sessionTables.Close so a concurrent
// openHandle racing Close either sees the empty map and takes the slow
// path (which observes the closed cache and returns Unavailable) or has
// already snapshotted a stale handle whose next RPC surfaces the
// closed-pool error. Either outcome is honest; a lingering map entry
// after Close would just keep the doomed handle reachable from Channel.
c.handleFastPath.Range(func(k, _ any) bool {
c.handleFastPath.Delete(k)
return true
})
c.sessionTables.Close()
return c.sc.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

Set the closed flag to true at the beginning of Close(). This ensures that any concurrent or subsequent openHandle calls will immediately observe the closed state and avoid storing stale handles in handleFastPath.

Suggested change
if c.sc == nil {
return nil
}
// Drop fast-path references before sessionTables.Close so a concurrent
// openHandle racing Close either sees the empty map and takes the slow
// path (which observes the closed cache and returns Unavailable) or has
// already snapshotted a stale handle whose next RPC surfaces the
// closed-pool error. Either outcome is honest; a lingering map entry
// after Close would just keep the doomed handle reachable from Channel.
c.handleFastPath.Range(func(k, _ any) bool {
c.handleFastPath.Delete(k)
return true
})
c.sessionTables.Close()
return c.sc.Close()
}
if c.sc == nil {
return nil
}
c.closed.Store(true)
// Drop fast-path references before sessionTables.Close so a concurrent
// openHandle racing Close either sees the empty map and takes the slow
// path (which observes the closed cache and returns Unavailable) or has
// already snapshotted a stale handle whose next RPC surfaces the
// closed-pool error. Either outcome is honest; a lingering map entry
// after Close would just keep the doomed handle reachable from Channel.
c.handleFastPath.Range(func(k, _ any) bool {
c.handleFastPath.Delete(k)
return true
})
c.sessionTables.Close()
return c.sc.Close()
}
References
  1. Ensure that Close() methods are idempotent, which can be achieved by wrapping the closing logic with sync.Once or using an atomic boolean.

…p handleFastPath

Scope-reduce the PR to the correctness change (concurrent-miss collapse)
and defer the per-RPC hot-path optimization for its own follow-up.

Removed:
- handleFastPath sync.Map + LoadOrStore publish inside openGroup.Do +
  Range/Delete drain in Close.

Kept:
- openGroup singleflight.Group + openHandle's slow-path body wrapped in
  openGroup.Do(res.Name, ...). This alone eliminates the loser-branch
  stress on sessionTables.GetOrOpen — the correctness property this PR
  is about.

Behavioral effect: after the initial burst on a resource, singleflight
releases the key and subsequent openHandle calls re-enter the Do body.
Each pays the switch/parse/closure-alloc cost + a sessionTables.mu
acquire, and hits sessionTables.GetOrOpen's fast path. No new
sc.OpenTable invocations.

Test unchanged in intent: 32 concurrent openHandle callers → 1
sc.OpenTable invocation. Post-burst follow-up assertion tightened to
say "sessionTables cache hit, not re-open" rather than "fast path
should not touch sc" (which conflated the two caches).
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.

1 participant