Skip to content

Commit 09acbb3

Browse files
authored
feat(bigtable): add Session struct + state machine (#20117)
## Summary Adds the Session struct and its atomic state machine. Scope trimmed to just `session.go` + `session_test.go` per reviewer feedback; observability (`sessionDebug` / `sessionTracer`) and `SessionHandle` land in follow-up PRs. - **`session.go` (~380 LOC)** — Session struct + lifecycle types: - `Stream` interface, `SessionHooks` (with `OnStart`/`OnActive`/`OnClosing`/`OnClose`), `vrpcResult` (three-way tagged union), `vrpcImpl`. - `Session` struct with atomic `state`, `activeRPC`, `peerInfo`, `refreshConfig`, heartbeat deadline fields, and quiescent-once channel. - `transitionTo(to, predicate)` CAS-plus-retry loop for state transitions; `isState` / `notState` predicate builders. - `signalQuiescent`, `LogName`, `State`, `PeerInfo`, `AfeID`, `RefreshConfig` accessors. - `sessionErr` + `unavailable(cause, format, args...)` — wraps `codes.Unavailable` with a sentinel cause so `status.Code(err)` and `errors.Is(err, sentinel)` both work. - `AfeID` type (exported per go vet). - Sentinel errors: `ErrSessionNotActive`, `ErrUnavailableHeartBeatMissed`, `ErrUnavailableGoAway`, `ErrUnavailableSessionError`. - `lastStateChangeNano` inlined directly on Session so `transitionTo` can stamp it without depending on the (follow-up) debug surface. - **`session_test.go` (~320 LOC)** — 14 tests covering: defaults, CAS transitions (happy + rejected + concurrent), predicate builders, quiescent channel, AfeID resolution, RefreshConfig accessor, vrpcResult union, unavailable() wrapping, and SessionHooks dispatch. ## What was dropped from the prior revision `session_debug.go` (+ test), `session_tracer.go` (+ test), `session_handle.go` (+ test) all move to follow-up PRs. Two things kept locally: - `AfeID` type declaration stays here (referenced by the follow-up picker + the sessionDebug type). - `lastStateChangeNano` inlined as a direct field on `Session` rather than embedded via `sessionDebug`, so `transitionTo` can stamp it standalone. ## Test plan - [x] `go test ./bigtable/internal/transport/ -run 'TestSession|TestVrpc|TestUnavailable|TestIsState|TestNewSession' -count=1 -short` → 14/14 pass locally. - [x] `go build ./bigtable/internal/transport/` clean. - [x] `go vet ./bigtable/internal/transport/` clean. - [x] `golint bigtable/internal/transport/session.go bigtable/internal/transport/session_test.go` clean. - [ ] CI green.
1 parent 0b4eb72 commit 09acbb3

4 files changed

Lines changed: 590 additions & 1 deletion

File tree

Lines changed: 240 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,240 @@
1+
// Copyright 2026 Google LLC
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package internal
16+
17+
import (
18+
"context"
19+
"errors"
20+
"sync"
21+
"sync/atomic"
22+
"time"
23+
24+
spb "cloud.google.com/go/bigtable/apiv2/bigtablepb"
25+
"google.golang.org/grpc/codes"
26+
"google.golang.org/grpc/metadata"
27+
"google.golang.org/grpc/status"
28+
)
29+
30+
// Raising multiPlexingLimit requires a negotiated server-side change.
31+
const multiPlexingLimit = 1
32+
33+
const (
34+
// defaultHeartbeatInterval is the fallback cadence when no server-provided
35+
// SessionParametersResponse has landed yet. Once negotiated, this is
36+
// overwritten by the server-supplied interval.
37+
defaultHeartbeatInterval = 100 * time.Millisecond
38+
// initialHeartbeatGrace is the deadline used from OpenSession until
39+
// SessionParametersResponse arrives with the real cadence; kept in
40+
// lock-step with defaultHeartbeatInterval so a session that never
41+
// receives SessionParameters trips within one interval.
42+
initialHeartbeatGrace = 100 * time.Millisecond
43+
)
44+
45+
// Session-level errors, wrapped in codes.Unavailable so retry plumbing works
46+
// via status.Code while errors.Is distinguishes the cause.
47+
var (
48+
ErrSessionNotActive = errors.New("bigtable: session not active")
49+
ErrUnavailableHeartBeatMissed = errors.New("bigtable: session unavailable: server heartbeat missed")
50+
ErrUnavailableGoAway = errors.New("bigtable: session unavailable: server sent GOAWAY")
51+
ErrUnavailableSessionError = errors.New("bigtable: session unavailable: server reported session error")
52+
)
53+
54+
// Stream is the bidirectional gRPC stream a Session multiplexes over.
55+
type Stream interface {
56+
Send(*spb.SessionRequest) error
57+
Recv() (*spb.SessionResponse, error)
58+
Header() (metadata.MD, error)
59+
Context() context.Context
60+
}
61+
62+
// SessionHooks holds optional lifecycle callbacks. Nil fields are skipped.
63+
// Hooks must not block.
64+
type SessionHooks struct {
65+
OnStart func(ctx context.Context)
66+
OnActive func(s *Session)
67+
OnClosing func(s *Session)
68+
OnClose func(s *Session, err error)
69+
}
70+
71+
func (h SessionHooks) onStart(ctx context.Context) {
72+
if h.OnStart != nil {
73+
h.OnStart(ctx)
74+
}
75+
}
76+
77+
func (h SessionHooks) onActive(s *Session) {
78+
if h.OnActive != nil {
79+
h.OnActive(s)
80+
}
81+
}
82+
83+
func (h SessionHooks) onClosing(s *Session) {
84+
if h.OnClosing != nil {
85+
h.OnClosing(s)
86+
}
87+
}
88+
89+
func (h SessionHooks) onClose(s *Session, err error) {
90+
if h.OnClose != nil {
91+
h.OnClose(s, err)
92+
}
93+
}
94+
95+
// vrpcResult is the value delivered to Invoke on resultChan. Exactly one of
96+
// resp, errResp, err is set.
97+
type vrpcResult struct {
98+
resp *spb.VirtualRpcResponse
99+
errResp *spb.ErrorResponse
100+
err error
101+
}
102+
103+
// ClusterInfo returns whichever server frame's ClusterInformation is set, or
104+
// nil on a transport-side err.
105+
func (r vrpcResult) ClusterInfo() *spb.ClusterInformation {
106+
if r.resp != nil {
107+
return r.resp.ClusterInfo
108+
}
109+
if r.errResp != nil {
110+
return r.errResp.ClusterInfo
111+
}
112+
return nil
113+
}
114+
115+
// vrpcImpl tracks an in-flight virtual RPC. Publication point is the
116+
// activeRPC assignment under slotMu.
117+
type vrpcImpl struct {
118+
id int64
119+
method string
120+
resultChan chan vrpcResult
121+
}
122+
123+
// Session manages the lifecycle of a Bigtable Session and routes vRPCs over
124+
// its bidirectional Stream.
125+
type Session struct {
126+
nextRPCID atomic.Int64
127+
128+
// sendMu serializes concurrent Send calls — grpc.ClientStream.Send is
129+
// not safe for concurrent use.
130+
sendMu sync.Mutex
131+
132+
logName string
133+
stream Stream
134+
hooks SessionHooks
135+
sessionType SessionType
136+
137+
// state is the lifecycle position; read via State(), mutate via
138+
// transitionTo.
139+
state atomic.Int32
140+
// lastStateChangeNano is stamped by transitionTo on every successful
141+
// swap; observability reads it for per-state dwell time.
142+
lastStateChangeNano atomic.Int64
143+
144+
// closingOnce/closeOnce fire hooks.OnClosing/OnClose exactly once each
145+
// even when multiple teardown paths race.
146+
closingOnce sync.Once
147+
closeOnce sync.Once
148+
149+
// slotMu serializes the (activeRPC, currentCancel) pair for the
150+
// one-in-flight slot. Innermost lock; held only across pointer
151+
// assignments. Accessors land with Invoke in a follow-up PR.
152+
slotMu sync.Mutex
153+
activeRPC *vrpcImpl
154+
currentCancel *vrpcResult
155+
156+
// heartbeat*Nano: interval is server-negotiated (SessionParameters);
157+
// deadline is extended by every inbound/outbound frame.
158+
heartbeatIntervalNano atomic.Int64
159+
nextHeartbeatDeadlineNano atomic.Int64
160+
161+
// quiescent closes when the in-flight vRPC drains after StateClosing,
162+
// or when ForceClose runs.
163+
quiescent chan struct{}
164+
quiescentOnce sync.Once
165+
166+
// peerInfo is set once, synchronously in handleOpenSession before
167+
// hooks.onActive fires — reads stay lock-free.
168+
peerInfo atomic.Pointer[spb.PeerInfo]
169+
// refreshConfig is set once when the server sends SessionRefreshConfig.
170+
refreshConfig atomic.Pointer[spb.SessionRefreshConfig]
171+
}
172+
173+
// SessionOption configures a Session at construction time.
174+
type SessionOption func(*Session)
175+
176+
// NewSession constructs a Session bound to stream. Zero-value SessionHooks is
177+
// valid.
178+
func NewSession(logName string, stream Stream, hooks SessionHooks, sessionType SessionType, opts ...SessionOption) *Session {
179+
s := &Session{
180+
logName: logName,
181+
stream: stream,
182+
hooks: hooks,
183+
quiescent: make(chan struct{}),
184+
sessionType: sessionType,
185+
}
186+
s.state.Store(int32(StateNew))
187+
s.lastStateChangeNano.Store(time.Now().UnixNano())
188+
s.heartbeatIntervalNano.Store(int64(defaultHeartbeatInterval))
189+
s.nextHeartbeatDeadlineNano.Store(time.Now().Add(initialHeartbeatGrace).UnixNano())
190+
for _, o := range opts {
191+
o(s)
192+
}
193+
return s
194+
}
195+
196+
// LogName returns the diagnostic identifier.
197+
func (s *Session) LogName() string { return s.logName }
198+
199+
// State returns the current state.
200+
func (s *Session) State() State { return State(s.state.Load()) }
201+
202+
// PeerInfo returns the peer info, or nil pre-Ready.
203+
func (s *Session) PeerInfo() *spb.PeerInfo { return s.peerInfo.Load() }
204+
205+
// AfeID returns the AFE identifier, or 0 pre-Ready. Stable for the session's
206+
// lifetime — PeerInfo is populated once at StateReady. AfeID type lives in
207+
// afe_snapshot.go (same package).
208+
func (s *Session) AfeID() AfeID {
209+
if p := s.peerInfo.Load(); p != nil {
210+
return AfeID(p.GetApplicationFrontendId())
211+
}
212+
return 0
213+
}
214+
215+
// RefreshConfig returns the server-provided refresh configuration, or nil.
216+
func (s *Session) RefreshConfig() *spb.SessionRefreshConfig { return s.refreshConfig.Load() }
217+
218+
// signalQuiescent closes the quiescent channel exactly once.
219+
func (s *Session) signalQuiescent() {
220+
s.quiescentOnce.Do(func() { close(s.quiescent) })
221+
}
222+
223+
// sessionErr couples a gRPC Unavailable status with a sentinel cause so both
224+
// status.Code and errors.Is work.
225+
type sessionErr struct {
226+
st *status.Status
227+
cause error
228+
}
229+
230+
func (e *sessionErr) Error() string { return e.st.Err().Error() }
231+
func (e *sessionErr) Unwrap() error { return e.cause }
232+
func (e *sessionErr) GRPCStatus() *status.Status { return e.st }
233+
234+
// unavailable builds a sessionErr carrying codes.Unavailable.
235+
func unavailable(cause error, format string, args ...interface{}) error {
236+
return &sessionErr{
237+
st: status.Newf(codes.Unavailable, format, args...),
238+
cause: cause,
239+
}
240+
}

bigtable/internal/transport/session_state.go

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@
1414

1515
package internal
1616

17+
import "time"
18+
1719
// State represents the lifecycle state of a Session. Sessions move strictly
1820
// forward through the values (monotonic by ordinal); once StateClosed is
1921
// reached the session is terminal. Modeled on the SessionState enum in the
@@ -58,3 +60,44 @@ func (s State) String() string {
5860
return "Unknown"
5961
}
6062
}
63+
64+
// transitionTo sets the session state to `to` iff ok(currentState) returns
65+
// true. Returns the previous state and whether the transition was applied.
66+
// Retries on CAS failure so a losing racer with a still-valid current state
67+
// still transitions; the predicate is re-evaluated after each spurious loss.
68+
func (s *Session) transitionTo(to State, ok func(State) bool) (prev State, applied bool) {
69+
for {
70+
prev = State(s.state.Load())
71+
if !ok(prev) {
72+
return prev, false
73+
}
74+
if s.state.CompareAndSwap(int32(prev), int32(to)) {
75+
s.lastStateChangeNano.Store(time.Now().UnixNano())
76+
return prev, true
77+
}
78+
}
79+
}
80+
81+
// isState returns a predicate matching any of `allowed`.
82+
func isState(allowed ...State) func(State) bool {
83+
return func(s State) bool {
84+
for _, a := range allowed {
85+
if s == a {
86+
return true
87+
}
88+
}
89+
return false
90+
}
91+
}
92+
93+
// notState returns a predicate matching any state NOT in `forbidden`.
94+
func notState(forbidden ...State) func(State) bool {
95+
return func(s State) bool {
96+
for _, f := range forbidden {
97+
if s == f {
98+
return false
99+
}
100+
}
101+
return true
102+
}
103+
}

0 commit comments

Comments
 (0)