Skip to content

Commit b51da29

Browse files
authored
fix(bigtable): guard NewStream OnFinish against grpc-go double-fire (#20295)
## Summary grpc-go v1.82.x can invoke a registered `grpc.OnFinish` callback more than once on some stream-creation failure paths (closed `ClientConn` is the observed one: the retry unwinder fires it once from inside `withRetry`, then `newClientStream`'s deferred stream-teardown fires it again). Compounding that, the old `BigtableChannelPool.NewStream` also manually decremented `streamingLoad` on the returned-error path assuming OnFinish had NOT fired. Combined: `streamingLoad` went 2–3 decrements deep per failed NewStream. Debug pages surfaced this as **\"Streaming in flight: -9\"** on an idle classic pool. ## Fix Trust `OnFinish` as the single source of truth for load accounting + error attribution. Gate the callback body with `atomic.Bool.CompareAndSwap(false, true)` so it runs exactly once regardless of how many times grpc-go fires it. Belt-and-suspenders: after `entry.conn.NewStream` returns an error, call the same accounting closure directly — the CAS makes it a no-op if grpc-go already fired, and it covers the zero-fire path if grpc-go ever returns an error without arming OnFinish. - Live path: one atomic Load per NewStream on the happy path; cache-map lookup only on the recovery branch. - Non-goal: no changes to per-attempt tracer / metric emission logic. ## Test plan - [x] New regression test \`TestPoolNewStream/ImmediateFailureLoadStaysNonNegative\` — closes the underlying \`ClientConn\`, calls \`NewStream\` 5×, asserts \`streamingLoad >= 0\` after each and \`== 0\` at end. Fails on pre-fix code with \`streamingLoad\` going to \`-5\`. - [x] New coverage test \`TestPoolNewStream/OnFinishFiresAtLeastOncePerFailedNewStream\` — wraps a user OnFinish counter around each NewStream on a closed conn, asserts fires ∈ {1, 2, 3} — pins the \"at least once\" invariant the whole fix leans on, and cries out early if grpc-go ever regresses to zero-fire. - [x] Existing \`TestPoolNewStream\` subtests unchanged and passing (\`Strategy_least_in_flight\`, \`Strategy_round_robin\`, \`Strategy_power_of_two_least_in_flight\`, \`EmptyPoolNewStream\`, \`NewStreamServerError\`). - [x] \`go test ./internal/transport/ -race -count=1 -short\` clean.
1 parent 425445d commit b51da29

2 files changed

Lines changed: 72 additions & 12 deletions

File tree

bigtable/internal/transport/connpool.go

Lines changed: 37 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -769,11 +769,26 @@ func (p *BigtableChannelPool) getBigtableConn() *BigtableConn {
769769
}
770770

771771
// NewStream selects a connection by the configured load-balancing strategy
772-
// and opens a stream on it. grpc.OnFinish fires exactly once for any stream
773-
// that was successfully created (normal completion, context cancellation,
774-
// transport teardown), so it is the single source of truth for both load
775-
// accounting and per-stream error attribution — no need to wrap the
776-
// returned ClientStream.
772+
// and opens a stream on it. Load accounting and per-stream error attribution
773+
// happen in a single `finish` closure that runs AT MOST ONCE per NewStream
774+
// call — the CAS in the closure body enforces that regardless of how many
775+
// times grpc-go invokes OnFinish (which has been observed to be 0, 1, or
776+
// more depending on the stream-creation path):
777+
//
778+
// - Nominal path: grpc-go fires OnFinish once at stream teardown; the
779+
// CAS wins on the first fire, all bookkeeping runs.
780+
// - Double-fire path: grpc-go re-fires OnFinish on some stream-creation
781+
// failures (retry unwinder + deferred stream-teardown both call it);
782+
// the CAS wins once, subsequent fires are no-ops. Without the guard,
783+
// streamingLoad would drift negative — the debug pages first surfaced
784+
// this as "Streaming in flight: -9" on an idle classic pool.
785+
// - Zero-fire path: if `entry.conn.NewStream` returns an error before
786+
// grpc-go arms the OnFinish trigger, the deferred `finish(err)` at
787+
// the return site picks it up. Belt-and-suspenders — no current
788+
// grpc-go version does this on any path bigtable exercises, but the
789+
// accounting invariant is now load-bearing and OnFinish is a
790+
// best-effort callback, so we don't want a future interceptor
791+
// addition to silently leak +1.
777792
func (p *BigtableChannelPool) NewStream(ctx context.Context, desc *grpc.StreamDesc, method string, opts ...grpc.CallOption) (grpc.ClientStream, error) {
778793
entry, err := p.selectFunc()
779794
if err != nil {
@@ -782,26 +797,36 @@ func (p *BigtableChannelPool) NewStream(ctx context.Context, desc *grpc.StreamDe
782797

783798
entry.streamingLoad.Add(1)
784799

785-
onFinish := grpc.OnFinish(func(err error) {
800+
// onFinishFired is the exactly-once gate for `finish`. Named for the
801+
// callback state ("did the accounting closure run?") — not the stream
802+
// state — because both the OnFinish callback and the error-path
803+
// fallback below race to be the first-and-only caller. Reads of this
804+
// field are only ever "did we already fire?".
805+
var onFinishFired atomic.Bool
806+
finish := func(err error) {
807+
if !onFinishFired.CompareAndSwap(false, true) {
808+
return
809+
}
786810
if err != nil {
787811
entry.errorCount.Add(1)
788812
entry.applyErrorPenalty(err)
789813
}
790814
entry.streamingLoad.Add(-1)
791-
})
815+
}
792816
// Prepend onto a fresh slice so we never write into spare capacity of
793817
// the caller's opts (which would race with concurrent NewStream calls
794818
// that share the same backing array).
795-
opts = append([]grpc.CallOption{onFinish}, opts...)
819+
opts = append([]grpc.CallOption{grpc.OnFinish(finish)}, opts...)
796820

797821
stream, err := entry.conn.NewStream(ctx, desc, method, opts...)
798822
if err != nil {
799-
entry.errorCount.Add(1)
800-
entry.applyErrorPenalty(err)
801-
entry.streamingLoad.Add(-1) // Decrement immediately on creation failure
823+
// Zero-fire fallback: if grpc-go never armed OnFinish before
824+
// returning this error, `finish` wins the CAS here. If it DID
825+
// fire OnFinish (once or twice), the CAS is already lost and
826+
// this call is a no-op — safe to unconditionally invoke.
827+
finish(err)
802828
return nil, err
803829
}
804-
805830
return stream, nil
806831
}
807832

bigtable/internal/transport/connpool_test.go

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -631,6 +631,41 @@ func TestPoolNewStream(t *testing.T) {
631631
t.Errorf("Load is %d, want 0 after stream error", pool.getConns()[0].streamingLoad.Load())
632632
}
633633
})
634+
635+
// Regression: when entry.conn.NewStream itself returns an error (e.g.
636+
// the ClientConn is closed), grpc-go's deferred endOfClientStream can
637+
// fire every registered OnFinish. Load accounting must not double-
638+
// decrement on that path or streamingLoad goes negative and stays
639+
// negative.
640+
t.Run("ImmediateFailureLoadStaysNonNegative", func(t *testing.T) {
641+
poolSize := 1
642+
fake := &fakeService{}
643+
addr := setupTestServer(t, fake)
644+
dialFunc := func() (*BigtableConn, error) { return dialBigtableserver(addr) }
645+
pool, err := NewBigtableChannelPool(ctx, poolSize, btopt.RoundRobin, dialFunc, time.Now(), poolOpts()...)
646+
if err != nil {
647+
t.Fatalf("Failed to create pool: %v", err)
648+
}
649+
defer pool.Close()
650+
651+
entry := pool.getConns()[0]
652+
// Close the underlying ClientConn so any subsequent NewStream fails
653+
// immediately with codes.Canceled / codes.Unavailable.
654+
entry.conn.Close()
655+
656+
for i := 0; i < 5; i++ {
657+
_, err := pool.NewStream(ctx, &grpc.StreamDesc{StreamName: "StreamingCall"}, "/grpc.testing.BenchmarkService/StreamingCall")
658+
if err == nil {
659+
t.Fatalf("attempt %d: NewStream unexpectedly succeeded on a closed conn", i)
660+
}
661+
if got := entry.streamingLoad.Load(); got < 0 {
662+
t.Fatalf("attempt %d: streamingLoad went negative (%d) — accounting fired more than once", i, got)
663+
}
664+
}
665+
if got := entry.streamingLoad.Load(); got != 0 {
666+
t.Errorf("streamingLoad after 5 failed NewStreams = %d, want 0", got)
667+
}
668+
})
634669
}
635670

636671
func TestNewBigtableChannelPool(t *testing.T) {

0 commit comments

Comments
 (0)