feat(bigtable): add per-AFE sessionList for the two-tier session pool - #20224
Conversation
First of five PRs porting the session pool infrastructure from `feat/bigtable-sessionz-debug` (which powers the recycle-repro fleet). Stacks on googleapis#20215 (Session lifecycle, merged). ## What lands **New file: `session_list.go`** (~600 LOC) — the per-pool sessionList data structure that groups sessions by the AFE (Application Front End) their handshake landed on. Consumed by the two-tier picker (K-choice-over-AFEs → dequeue-idle-session) in a follow-up PR. Key types: - `AfeID` (int64) — AFE identifier from the server's PeerInfo header at session-open. 0 is the sentinel "unknown" bucket for handshakes that did not carry a peer-info header. - `AfeSnapshot` — value-typed view of an afeHandle for pickers to score without holding sl.mu (Checkout re-resolves by ID; no *afeHandle escapes). - `AfeSnapshotRow` — debug-UI row emitted by `sessionList.Snapshot()` (consumed by afez/sessionz in a later debug PR). - `afeHandle` (unexported) — per-AFE bucket: FIFO idle queue, refCount (idle + inFlight + closing), two PeakEwma trackers. - `SessionHandle` — pool bookkeeping wrapper around Session. Carries `inExpectedCount` (I5 guard against WSC retry storm) and `activated / closingRecorded / closeRecorded` dedup flags for the pool's per-session hook chain (Phase-1/Phase-2 close dedup). - `sessionList` (unexported) — the state machine, guarded by sl.mu. The state model documents six invariants (I1-I6) that every method preserves: I1 inExpectedCount ⇒ handleToAfe[sh] != nil I2 readyCount == count of inExpectedCount handles I3 afesWithReady == {afe : len(afe.sessions) > 0} I4 afe.refCount == count of handleToAfe entries pointing at afe I5 sh in afe.sessions ⇒ handleToAfe[sh]==afe AND inExpectedCount I6 refCount-- only on OnSessionClosed (Closing keeps slot warm) Lock order: sl.mu ONLY. `RecordVRpcOutcome` deliberately drops sl.mu between the map lookup and the PeakEwma.Update so the hot vRPC-outcome path doesn't serialize on it. Consolidates AFE types (AfeID + AfeSnapshot) that previously lived in `afe_snapshot.go` — sessionList owns all AFE-bucket concepts. Deletes `afe_snapshot.go`. **New file: `session_list_test.go`** (~770 LOC) — I1-I6 coverage plus per-method tests (OnSessionStarted / Checkout / ReleaseToPool / OnSessionClosing / OnSessionClosed / RecordVRpcOutcome / ReadyAfes / Snapshot / AllHandles / Prune) and a concurrency stress test covering the documented lock-drop path in RecordVRpcOutcome. **Edits to `debug_tracer.go`** — three new tag constants for sessionList bookkeeping violations (all unreachable-under-invariants, kept as belt-and-suspenders): - `tagSessionListStartedNilSession` - `tagSessionListRefcountUnderflow` - `tagSessionListReadyCountUnderflow` **Edit to `session.go`** — one-line comment retarget on `AfeID()` (type now lives in `session_list.go`, not the deleted `afe_snapshot.go`). ## Test plan - [x] `go build ./internal/transport/` passes - [x] `go vet ./internal/transport/` clean - [x] `go test ./internal/transport/ -race -count=1 -short -timeout=120s` passes — 20+ new sessionList tests + all pre-existing.
There was a problem hiding this comment.
Code Review
This pull request introduces a new sessionList component in bigtable/internal/transport/session_list.go along with comprehensive unit tests. This component manages session grouping by Application Frontend (AFE) identifiers and tracks latency metrics using PeakEWMA. Additionally, it updates session references and adds new debug tracer tags. The review feedback suggests a modernizing improvement to use atomic.Int64 instead of raw int64 fields for tracking session metrics in SessionHandle, which aligns with modern Go practices (Go 1.19+) and enhances type safety.
| outstanding int64 | ||
| lastActivity int64 // UnixNano timestamp of the last completed call | ||
| picks int64 // Number of times the picker has picked this handle. |
There was a problem hiding this comment.
Since the project uses Go 1.19+ (as evidenced by the use of atomic.Bool in this struct), we can use atomic.Int64 instead of raw int64 fields. This is more idiomatic, type-safe, and prevents accidental non-atomic access to these fields.
| outstanding int64 | |
| lastActivity int64 // UnixNano timestamp of the last completed call | |
| picks int64 // Number of times the picker has picked this handle. | |
| outstanding atomic.Int64 | |
| lastActivity atomic.Int64 // UnixNano timestamp of the last completed call | |
| picks atomic.Int64 // Number of times the picker has picked this handle. |
There was a problem hiding this comment.
If we use atomic I think we can simplify incrementing these variables too?
There was a problem hiding this comment.
Applied in 830f494 — swapped the three fields to atomic.Int64. Nice catch — the align64 marker on typed atomics also removes the 32-bit ARM alignment concern that raw int64 had to hand-hoist for. Kept the method wrappers (IncOutstanding, IncPicks, DecOutstanding, GetLastActivity) since DecOutstanding pairs the decrement with a lastActivity stamp and GetLastActivity handles the UnixNano→time.Time + zero-case conversion that raw .Load() can't.
There was a problem hiding this comment.
Done in 830f494 — swapped all three fields to atomic.Int64 and simplified the accessors. Cleaner shape; also fixes the goimports vet failure (trailing blank line at EOF).
| func (h *SessionHandle) IncOutstanding() { | ||
| atomic.AddInt64(&h.outstanding, 1) | ||
| } | ||
|
|
||
| // IncPicks increments the cumulative pick counter. Called from | ||
| // CheckoutSession on every successful pick so pool callers don't reach | ||
| // into the handle's atomic field directly (same shape as IncOutstanding). | ||
| func (h *SessionHandle) IncPicks() { | ||
| atomic.AddInt64(&h.picks, 1) | ||
| } | ||
|
|
||
| // DecOutstanding decrements outstanding calls and stamps lastActivity. | ||
| // The pool wakes waiters and returns the session to its AFE queue from | ||
| // Invoke's defer, so this method no longer signals directly. | ||
| func (h *SessionHandle) DecOutstanding() { | ||
| atomic.AddInt64(&h.outstanding, -1) | ||
| atomic.StoreInt64(&h.lastActivity, time.Now().UnixNano()) | ||
| } | ||
|
|
||
| // GetLastActivity returns the time of the last activity. | ||
| func (h *SessionHandle) GetLastActivity() time.Time { | ||
| nano := atomic.LoadInt64(&h.lastActivity) | ||
| if nano == 0 { | ||
| return time.Time{} | ||
| } | ||
| return time.Unix(0, nano) | ||
| } |
There was a problem hiding this comment.
Update the methods to use the new atomic.Int64 API, which is cleaner and more idiomatic.
func (h *SessionHandle) IncOutstanding() {
h.outstanding.Add(1)
}
// IncPicks increments the cumulative pick counter. Called from
// CheckoutSession on every successful pick so pool callers don't reach
// into the handle's atomic field directly (same shape as IncOutstanding).
func (h *SessionHandle) IncPicks() {
h.picks.Add(1)
}
// DecOutstanding decrements outstanding calls and stamps lastActivity.
// The pool wakes waiters and returns the session to its AFE queue from
// Invoke's defer, so this method no longer signals directly.
func (h *SessionHandle) DecOutstanding() {
h.outstanding.Add(-1)
h.lastActivity.Store(time.Now().UnixNano())
}
// GetLastActivity returns the time of the last activity.
func (h *SessionHandle) GetLastActivity() time.Time {
nano := h.lastActivity.Load()
if nano == 0 {
return time.Time{}
}
return time.Unix(0, nano)
}There was a problem hiding this comment.
Applied in 830f494 — method bodies now use h.field.Add(1) / .Store(v) / .Load().
Three Igor nits from the PR-1 (bigtable-session-list) review landing now that PR-1 is upstream. Keeps sessionz consistent with the upstreamable version so the future rebase is clean. 1. File-level "sl.mu deref" note hoisted from Checkout's godoc into the top block near I1-I6. Reads better at file level — the lock- drop is a whole-file invariant, not a Checkout property. 2. dropMembershipLocked — inverted to bail BEFORE readyCount-- when the assert would fail. Mirrors OnSessionClosed's refCount underflow pattern; counter no longer corrupts to -1 on the bookkeeping-drift branch. Strict improvement over the prior "decrement then log if negative" shape. 3. New TestSessionList_AllHandles_ReflectsMembership pins the h1 Idle / h2 Closed / h3 Closing membership contract — sessionz's per- session UI iterates AllHandles(), so an implementation slip that dropped Closing handles would silently blank rows. Two PR-1 nits skipped as sessionz-inapplicable: - AfeSnapshotRow.ID: int64→AfeID — sessionz uses unexported afeID; an exported struct field of an unexported type is un-idiomatic. Sync when sessionz rebases onto the upstream afeID→AfeID export rename. - session.go AfeID() comment retarget — sessionz never had the stale afe_snapshot.go reference to begin with.
| outstanding int64 | ||
| lastActivity int64 // UnixNano timestamp of the last completed call | ||
| picks int64 // Number of times the picker has picked this handle. |
There was a problem hiding this comment.
If we use atomic I think we can simplify incrementing these variables too?
Addresses gemini-code-assist + mutianf comments on PR googleapis#20224: swap the three raw int64 fields (outstanding, lastActivity, picks) for typed atomic.Int64. Cleaner API surface (h.field.Add(1) / .Store(v) / .Load() vs atomic.AddInt64(&h.field, 1)); prevents accidental non-atomic access; carries the align64 marker so 64-bit fields are 8-byte aligned on 32-bit ARM without hand-hoisting. Also trim trailing blank line at EOF (goimports vet fix — the `bigtable/internal/transport/session_list.go` entry in vet.sh's `goimports -l .` output on the initial PR-1 push). All 5 access sites on this branch are in session_list.go itself; the migration is self-contained. Method wrappers (IncOutstanding / IncPicks / DecOutstanding / GetLastActivity) preserved — DecOutstanding pairs the decrement with the lastActivity stamp and GetLastActivity handles the UnixNano→time.Time + zero-case conversion, so both add value over raw .Load().
Sync with PR-1 (bigtable-session-list @ 830f494) — addresses gemini-code-assist + mutianf comments on googleapis#20224 asking for typed atomics on the three raw int64 counter fields. - `outstanding`, `lastActivity`, `picks`: `int64` → `atomic.Int64` - Method bodies switched to `.Add(1)` / `.Store(v)` / `.Load()` - Sessionz has 3 additional call sites vs PR-1 (session_pool.go scale-down check, session_pool_debug.go slow-vRPC log, session_snapshot.go SessionHandle.Snapshot); all migrated. - session_snapshot.go's `sync/atomic` import dropped (no bare atomic.* funcs remain there after the migration). Cleaner API surface; carries the align64 marker so 64-bit fields are 8-byte aligned on 32-bit ARM without hand-hoisting. Same atomicity + memory-ordering semantics as the raw atomic.*Int64 they replace. Method wrappers (IncOutstanding / IncPicks / DecOutstanding / GetLastActivity) preserved on SessionHandle — DecOutstanding pairs dec + lastActivity stamp; GetLastActivity handles the UnixNano→ time.Time + zero-case conversion. Both add value over raw .Load().
Second of five PRs porting the session pool infrastructure from `feat/bigtable-sessionz-debug` (which powers the recycle-repro fleet). Stacks on googleapis#20224 (per-AFE sessionList). ## What lands **SessionPoolImpl** — the concrete two-tier read/write session pool for one resource. ~4000 LOC across five files: - `session_pool.go` (~550 LOC) — struct + constructor + Invoke + CheckoutSession (waiter queue, deadline propagation) + pluggable picker (Simple / LeastInFlight / LeastLatency) via the AFE picker from PR googleapis#20204. - `session_pool_lifecycle.go` (~530 LOC) — SessionHooks wiring (onStart/onActive/onClosing/onClose), consecutive-failure breaker, Close (5-phase teardown), and the WaitGoroutines / spawns.Wait choreography that guarantees no session-owned goroutine outlives the pool. - `session_pool_scaling.go` (~310 LOC) — Tick loop, createSession (dial + OpenSession + hook registration), pendingStarts / startingSessions accounting so scale-up decisions never double-count in-flight opens. Uses the channel-pool pick hint (`ChannelPickHintInto`, added to connpool.go) to attribute each session to its underlying channel. - `session_pool_debug.go` (~415 LOC) — PoolSnapshot / slow-vRPC ring / per-close-reason counters / scaling-history buffer / pickHistory ring — the input to the sessionz / afez / loadz debug pages (landing in a later PR). - `session_snapshot.go` (~590 LOC) — the value-typed snapshot record the debug surface consumes; no live locks escape. - Five matching `_test.go` files (~2200 LOC): pool lifecycle, scaling, consecutive-failure breaker, AFE integration, debug surface, snapshot rendering, plus a K-choice bench. **Session helpers added** (pool-facing additions to files already touched by prior PRs, isolated to keep the diff readable): - `Session.loops sync.WaitGroup` + `WaitGoroutines()` — pool teardown blocks on this so readLoop / heartbeatLoop and their notifyClosed → recordClose callback chains fully unwind before Close returns. Prevents session goroutines from racing metric-var writes across test boundaries. - `Session.closeErr atomic.Pointer[error]` + `setCloseErr()` / `closeError()` — preserves the raw Recv error handed to handleClose. Pool surfaces this on consecutive-failure breaker trips so operators see the underlying server rejection (e.g. FailedPrecondition when the resource is still being created) instead of only the sentinel. **Supporting additions to existing files** (minimal, isolated): - `afe_picker.go` — const `defaultAfeRandomSubsetSize = 2` (power-of-two-choices K-choice default; matches Java). - `debug_tracer.go` — three new tag constants: `tagSessionPoolCreatePanic` (distinguishes recovered panic from plain error return), `tagSessionPoolConsecutiveFailuresTripped` (breaker drain fired), `tagSessionPoolCheckoutFailedCINil` (Invoke returned InvokeResult{} with nil ClusterInfo — dominates the nil-ClusterInfo population during cold-start / pool-close bursts). - `connpool.go` — `ChannelPickHintInto(ctx, *atomic.Int32)` context helper used by createSession to link each session to its channel (surfaced in sessionz / channelz in a later PR). No-op when the channel pool doesn't consume the hint. ## What does NOT land yet - The `SessionPool` / `Invoker` interfaces (follow-up PR alongside sessionClient / sessionTable — those consumers land in PR-3+). - The `SessionPoolImpl` factory / `NewSessionPool` wiring against `bigtable.Client` (PR-3). - Debug pages (sessionz / afez / flightz / loadz — landing under `bigtable/debugview/` in a later PR). ## Test plan - [x] `go build ./...` passes - [x] `go vet ./internal/transport/` clean - [x] `go test ./internal/transport/ -race -count=1 -short -skip 'AfeLbSim' -timeout=180s` passes (32s wall)
Second of five PRs porting the session pool infrastructure from `feat/bigtable-sessionz-debug` (which powers the recycle-repro fleet). Stacks on googleapis#20224 (per-AFE sessionList). ## What lands **SessionPoolImpl** — the concrete two-tier read/write session pool for one resource. ~4000 LOC across five files: - `session_pool.go` (~550 LOC) — struct + constructor + Invoke + CheckoutSession (waiter queue, deadline propagation) + pluggable picker (Simple / LeastInFlight / LeastLatency) via the AFE picker from PR googleapis#20204. - `session_pool_lifecycle.go` (~530 LOC) — SessionHooks wiring (onStart/onActive/onClosing/onClose), consecutive-failure breaker, Close (5-phase teardown), and the WaitGoroutines / spawns.Wait choreography that guarantees no session-owned goroutine outlives the pool. - `session_pool_scaling.go` (~310 LOC) — Tick loop, createSession (dial + OpenSession + hook registration), pendingStarts / startingSessions accounting so scale-up decisions never double-count in-flight opens. Uses the channel-pool pick hint (`ChannelPickHintInto`, added to connpool.go) to attribute each session to its underlying channel. - `session_pool_debug.go` (~415 LOC) — PoolSnapshot / slow-vRPC ring / per-close-reason counters / scaling-history buffer / pickHistory ring — the input to the sessionz / afez / loadz debug pages (landing in a later PR). - `session_snapshot.go` (~590 LOC) — the value-typed snapshot record the debug surface consumes; no live locks escape. - Five matching `_test.go` files (~2200 LOC): pool lifecycle, scaling, consecutive-failure breaker, AFE integration, debug surface, snapshot rendering, plus a K-choice bench. **Session helpers added** (pool-facing additions to files already touched by prior PRs, isolated to keep the diff readable): - `Session.loops sync.WaitGroup` + `WaitGoroutines()` — pool teardown blocks on this so readLoop / heartbeatLoop and their notifyClosed → recordClose callback chains fully unwind before Close returns. Prevents session goroutines from racing metric-var writes across test boundaries. - `Session.closeErr atomic.Pointer[error]` + `setCloseErr()` / `closeError()` — preserves the raw Recv error handed to handleClose. Pool surfaces this on consecutive-failure breaker trips so operators see the underlying server rejection (e.g. FailedPrecondition when the resource is still being created) instead of only the sentinel. **Supporting additions to existing files** (minimal, isolated): - `afe_picker.go` — const `defaultAfeRandomSubsetSize = 2` (power-of-two-choices K-choice default; matches Java). - `debug_tracer.go` — three new tag constants: `tagSessionPoolCreatePanic` (distinguishes recovered panic from plain error return), `tagSessionPoolConsecutiveFailuresTripped` (breaker drain fired), `tagSessionPoolCheckoutFailedCINil` (Invoke returned InvokeResult{} with nil ClusterInfo — dominates the nil-ClusterInfo population during cold-start / pool-close bursts). - `connpool.go` — `ChannelPickHintInto(ctx, *atomic.Int32)` context helper used by createSession to link each session to its channel (surfaced in sessionz / channelz in a later PR). No-op when the channel pool doesn't consume the hint. ## What does NOT land yet - The `SessionPool` / `Invoker` interfaces (follow-up PR alongside sessionClient / sessionTable — those consumers land in PR-3+). - The `SessionPoolImpl` factory / `NewSessionPool` wiring against `bigtable.Client` (PR-3). - Debug pages (sessionz / afez / flightz / loadz — landing under `bigtable/debugview/` in a later PR). ## Test plan - [x] `go build ./...` passes - [x] `go vet ./internal/transport/` clean - [x] `go test ./internal/transport/ -race -count=1 -short -skip 'AfeLbSim' -timeout=180s` passes (32s wall)
…#20225) ## Summary Second of five PRs porting the session pool infrastructure from `feat/bigtable-sessionz-debug` (which powers the recycle-repro fleet). Adds `SessionPoolImpl`: the concrete two-tier read/write session pool for one resource. ~4000 LOC across five files plus matching tests. ## Stack - [ ] PR-1: sessionList (#20224) — per-AFE bucketing data structure. **Not yet merged.** - [x] **PR-2 (this)** — SessionPoolImpl (pool + scaling + debug + snapshot). - [ ] PR-3 — `SessionPool` / `Invoker` interfaces + `sessionClient` / `sessionTable` factory wiring. - [ ] PR-4 — Debug pages (`sessionz` / `afez` / `flightz` / `loadz` under `bigtable/debugview/`). - [ ] PR-5 — `bigtable.Client` integration + release notes. **Because PR-1 has not landed, this PR is opened against `main` and the diff includes PR-1's commits.** Once #20224 merges the base can be re-targeted (or this branch rebased) so only PR-2's own delta shows. ## What lands **SessionPoolImpl** (5 files, ~2400 LOC prod + ~2200 LOC tests): - `session_pool.go` — struct + constructor + `Invoke` + `CheckoutSession` (waiter queue, deadline propagation) + pluggable picker via the AFE picker from #20204. - `session_pool_lifecycle.go` — `SessionHooks` wiring, consecutive-failure breaker, `Close` (5-phase teardown), `WaitGoroutines` / `spawns.Wait` choreography so no session-owned goroutine outlives the pool. - `session_pool_scaling.go` — `Tick` loop, `createSession` (dial + `OpenSession` + hook registration), `pendingStarts` / `startingSessions` accounting so scale-up decisions never double-count in-flight opens. Uses the channel-pool pick hint (`ChannelPickHintInto`, added to `connpool.go`) to attribute each session to its underlying channel. - `session_pool_debug.go` — `PoolSnapshot` / slow-vRPC ring / per-close-reason counters / scaling-history buffer / `pickHistory` ring — the input to the sessionz / afez / loadz debug pages (landing in a later PR). - `session_snapshot.go` — the value-typed snapshot record the debug surface consumes; no live locks escape. **Session helpers added** (pool-facing additions to files already touched by prior PRs, kept minimal): - `Session.loops sync.WaitGroup` + `WaitGoroutines()` — pool teardown blocks on this so `readLoop` / `heartbeatLoop` and their `notifyClosed → recordClose` callback chains fully unwind before `Close` returns. Prevents session goroutines from racing metric-var writes across test boundaries. - `Session.closeErr atomic.Pointer[error]` + `setCloseErr` / `closeError` — preserves the raw `Recv` error handed to `handleClose`. Pool surfaces this on consecutive-failure breaker trips so operators see the underlying server rejection (e.g. `FailedPrecondition` when the resource is still being created) instead of only the sentinel. **Supporting additions to existing files:** - `afe_picker.go` — const `defaultAfeRandomSubsetSize = 2` (power-of-two-choices K-choice default; matches Java). - `debug_tracer.go` — three new tag constants: `tagSessionPoolCreatePanic`, `tagSessionPoolConsecutiveFailuresTripped`, `tagSessionPoolCheckoutFailedCINil`. - `connpool.go` — `ChannelPickHintInto(ctx, *atomic.Int32)` context helper. No-op when the channel pool doesn't consume the hint. ## What does NOT land yet - `SessionPool` / `Invoker` interfaces (follow-up PR alongside sessionClient / sessionTable). - `bigtable.Client` integration (PR-3+). - Debug pages under `bigtable/debugview/` (later PR). ## Test plan - [x] `go build ./...` passes. - [x] `go vet ./internal/transport/` clean. - [x] `go test ./internal/transport/ -race -count=1 -short -skip 'AfeLbSim' -timeout=180s` — passes (32s wall). ~2200 LOC of new tests across pool lifecycle, scaling, consecutive-failure breaker, AFE integration, debug surface, snapshot rendering, plus a K-choice bench. --- # Reviewer guide ## Guide 1 — mutianf (human) ### What this PR does Adds `SessionPoolImpl`, the layer that sits above the per-AFE `sessionList` shipped in #20224 and consumes it via a two-tier picker (AFE first, then a ready session in that AFE). It owns the session lifecycle (open / active / closing / close hooks), server-driven scaling via `PoolSizer`, a consecutive-failure circuit breaker, and the debug/observability surface (histograms + ring buffers) that feeds sessionz/loadz. New files: 5 source, 6 test, ~4.9k LOC. Nothing outside `session_pool*.go` / `session_snapshot*.go` is new logic — the small edits elsewhere are hook-plumbing scaffolding already vetted by the session/AFE subagent reviewers. ### Recommended read order 1. **`session_pool.go`** — start here. Struct field layout with per-field ownership comments (`:104-179`), the `waiter` FIFO shape (`:94-101`), `CheckoutSession` two-tier pick + parking (`:235-310`), `Invoke` (`:465-559`), `Stats` (`:361-397`), `UpdateConfig` (`:402-431`), `pickerFromLoadBalancing` (`:439-461`). Skim `session_pool_test.go` (28 tests) — the FIFO waiter, Stats, and UpdateConfig behaviors are all covered there. 2. **`session_pool_lifecycle.go`** — hooks (`onActive:255`, `onClosing:308`, `onClose:336`), `recordSessionClose` once-CAS on `Session.poolCloseRecorded` (`:117-130`), `Close`'s 6-phase teardown (`:154-247`), `noteAbnormalCloseIfAny` breaker (`:363-392`), the three ticker loops (`:426-538`). Skim `session_pool_lifecycle_test.go` — every hook + `Close`. 3. **`session_pool_scaling.go`** — `Tick` (`:81-162`), `createSession` worker (`:164-274`), `scalingReason` (`:278-299`), `noDeadlineButCancellableContext` (`:301-311`). Skim `session_pool_scaling_test.go` — the `scalingInProgress` gate and panic-safety are the only non-obvious contracts. 4. **`session_pool_debug.go`** — `poolMetrics` (`:36-72`), `latencyHist` log2 histogram (`:160-228`), the four ring buffers (slow-vRPC, time-series, lifetimes, pick-history), `recordPickDecision` (`:366-387`). Skim `session_pool_debug_test.go` — mostly ring-cap and rate-computation coverage. 5. **`session_snapshot.go`** — mostly type defs. Focus on `PoolSnapshot` (`:452-594`) and `LoadBalancingSnapshot` (`:414-436`) as the debug-view contract. 6. **`session_pool_consecutive_failures_test.go`** and **`session_pool_afe_test.go`** — end-to-end behavior verification; useful for confirming intent. ### Flow of events - **CheckoutSession → Invoke → release.** `CheckoutSession` (`session_pool.go:235`) opportunistically kicks Tick if `sl.ReadyCount()==0`, snapshots the picker under `p.mu`, then two-tier picks outside the lock: `ReadyAfes()` → `PickAfe` → `Checkout(afeID)` (`:259-268`). Miss → park in the FIFO waiter queue (`:286-289`), bracket `waitersCount` for the sizer (`:291,300`). `Invoke` (`:465`) checks out, runs `sh.session.Invoke`, records latencies (`:508-523`), logs a slow-vRPC row if over threshold (`:524-557`); the deferred `sh.DecOutstanding()` + `noteVRpcOutcome` (`:493-496`) hands the OK-gated latency to the per-AFE PeakEwma tracker. Session release itself is driven by `OnSlotDrained` (installed at `session_pool_scaling.go:228-231`), which returns the handle to `sessionList` and calls `signalFree` — separate from the `defer` in `Invoke`. - **Background Tick.** `startTickLoop` (`session_pool_lifecycle.go:426`) fires every 1 s → `tickOnce` debounces via `tickPending` CAS (`:447-458`) → `Tick` (`session_pool_scaling.go:81`) samples uptimes, gates on `scalingInProgress`, calls `sizer.Decide()`, and on a positive delta reserves `pendingStarts += delta` + `spawns.Add(delta)` under `p.mu` (`:131-138`) then fans out one goroutine per session. Each `createSession` acquires the budget outside `p.mu`, dials via `streamFactory`, transfers `pendingStarts → startingSessions` in one lock (`:246-249`), starts the session, and blocks on `WaitGoroutines` so it stays on `p.spawns` until the session dies. - **Abnormal close → breaker trip.** `onClose` (`session_pool_lifecycle.go:336`) CAS's `closeRecorded`, calls `noteAbnormalCloseIfAny` (`:363`), which bumps `consecutiveFailures` and stores the raw error into `lastAbnormalCloseErr`. Crossing the threshold snapshots the poison, CAS-resets the counter, and calls `drainWaitersWithErr` — waiters get `*consecutiveFailureError` wrapping the last cause (so `errors.Is(err, ErrConsecutiveFailures)` and `status.Code(err)` both still work, `:60-82`). Counter only resets in `onActive` (`:292-293`) — a successful open, not a healthy vRPC. ### Key invariants 1. **Two-tier pick, no re-entrant `p.mu`.** `CheckoutSession` reads `p.picker` under `p.mu` (`session_pool.go:249-255`) then unlocks before calling picker/sessionList. `recordPickDecision` takes `pickerName` as a **parameter** (`session_pool_debug.go:366`, `session_pool.go:260-262`) precisely because the caller already holds no lock — but any new pool method that reads `p.picker.Name()` from a hot path must not re-take `p.mu`. 2. **Waiter FIFO with `waitersCount` bracketed.** Every `PushBack` bumps `waitersCount` (`session_pool.go:291`); every wake path (`ctx.Done`, `w.ready`) decrements it (`:294,300`). `removeWaiter` (`:316`) is idempotent via `w.elem != nil`; `signalFree` and `drainWaitersWithErr` nil out `elem` under `waitersMu` (`:329-358`). `Stats().PendingCount` reads `waitersCount.Load()` — this is the sizer's queue-depth input. 3. **Close-exactly-once accounting.** `sessionsClosed` and `closesByReason` bumps are gated by `Session.poolCloseRecorded.CompareAndSwap(false, true)` inside `recordSessionClose` (`session_pool_lifecycle.go:117-130`). `sh.closingRecorded` and `sh.closeRecorded` are per-handle CAS's protecting the lifetime histogram + the `OnClose` branch. `Close`'s Phase 1 pre-flips both CAS's on every handle (`:187-193`) so a concurrent mid-flight onClosing can't double-count. 4. **Breaker resets only on `onActive`.** `consecutiveFailures.Store(0)` and `lastAbnormalCloseErr.Store(nil)` live at `session_pool_lifecycle.go:292-293`. Not on per-vRPC OK — otherwise one long-lived healthy session would mask a run of failed opens. 5. **Hot path is atomics/RLocks; debug views take snapshots.** `Stats` is the only per-request path that briefly takes `p.mu` (`session_pool.go:362`); everything else on the vRPC path is atomic. Debug snapshotters copy under lock and format after release (`session_snapshot.go:452-594`). ### What NOT to worry about - **Session / vRPC layer itself** — shipped in #20213 / #20215 (state machine, one-in-flight, PeerInfo timing, retry oracle, heartbeat). - **Per-AFE `sessionList` I1-I6** — shipped in #20224, has its own tests. - **`PoolSizer` scaling formula** — already upstream (`pool_sizer.go`); this PR only wires it and consumes `ScaleDecision`. - **AFE pickers (`SimpleAfePicker` / `LeastInFlight` / `LeastLatency`)** — already upstream (`afe_picker.go`); this PR only builds them via `pickerFromLoadBalancing`. - **`SessionThrottler` / `AdaptiveSessionThrottler`** — already upstream; this PR consumes `Acquire` / `Release` / `UpdateConfig`. - **`ClientConfigurationManager` polling** — this pool receives `UpdateConfig` calls; the polling itself is elsewhere. ### Danger zones - **Re-entrant `p.mu` on picker access.** `recordPickDecision` intentionally takes `pickerName` as a param (`session_pool_debug.go:366`). Adding a new pool method that reads `p.picker.Name()` from within a `CheckoutSession` code path is a re-entrant deadlock; pass the name in or snapshot up-front. - **`startingSessions` / `pendingStarts` accounting.** Tick reserves `pendingStarts` under `p.mu` (`session_pool_scaling.go:131-138`), `createSession`'s `reserved` defer releases it on any early return (`:172-179`), and the transfer at `:246-249` is atomic under `p.mu`. `onActive` deletes from `startingSessions` (`session_pool_lifecycle.go:265`). Any new failure branch in `createSession` must preserve the invariant `pendingStarts + len(startingSessions) + Ready = "in-flight scale-up capacity"`. - **`budget.Acquire` blocks; must run OUTSIDE `p.mu`.** Currently at `session_pool_scaling.go:181`, deliberately after the `defer reserved` block and before any `p.mu.Lock()`. Moving it under the lock deadlocks scale-up under budget exhaustion. - **Slow-vRPC is fire-and-forget.** `recordSlowVRpc` (`session_pool_debug.go:301`) appends into a mutex-guarded ring; it's on the vRPC return path but bounded by threshold gating and one small mutex. Do not add I/O, logging fanout, or channel sends here. - **`newTestPool` / bootstrap defaults.** Test helpers construct the pool without a `ClientConfigurationManager`, so the bootstrap defaults from `defaultPoolConfig()` (`session_pool.go:218-226`) are the only config those tests see. Real callers always get `UpdateConfig` synchronously on registration — verify by reading `NewSessionPoolImpl` end-to-end, not by trusting the bootstrap values. - **`Close` phase ordering.** Phase 4 (`poolCancel`) runs AFTER Phase 3 (`wg.Wait` on graceful closes) because Phase 2's `closeCtx` derives from `p.poolCtx`; swapping order strands in-flight graceful closes on a cancelled ctx. Phase 5 (`p.spawns.Wait`) blocks on every createSession goroutine's `WaitGoroutines` — that's why createSession blocks on `s.WaitGoroutines()` at `session_pool_scaling.go:272`. --- ## Guide 2 — mutianf-bot (automated reviewer) ### REAL HAZARDS to flag - **Re-entrant `p.mu` in pool methods called from `CheckoutSession`.** Anchor: `session_pool.go:235-310`. `p.mu` is dropped at `:255` before `PickAfe` / `Checkout` / `recordPickDecision` fire. Flag any newly-added helper called from that block that re-acquires `p.mu`, or any new method that reads `p.picker.Name()` without taking the name as a parameter (see the intentional parameter pattern at `session_pool_debug.go:366`). - **`budget.Acquire` under `p.mu`.** Currently correctly outside the lock at `session_pool_scaling.go:181`. `SessionThrottler.Acquire` blocks on the budget semaphore; calling it while holding `p.mu` would deadlock scale-up. Flag any code path that acquires `p.mu` before line `:181` or moves `Acquire` inside a `Lock`/`Unlock` bracket. - **`sync.Map` allocations on hit paths.** `bumpCloseReason` uses `Load` first, `LoadOrStore(k, new(atomic.Int64))` only on miss (`session_pool_lifecycle.go:102-111`) — this is the correct pattern. Flag any new `sync.Map.LoadOrStore(key, new(...))` call on a hot path that isn't gated by a preceding `Load` — that allocates on every hit. - **Waiter counter drift.** `waitersCount.Add(+1)` at `session_pool.go:291`, `Add(-1)` on both the `ctx.Done` branch (`:294`) and the `w.ready` branch (`:300`). Flag any new wake path, timeout branch, or early-return between `:291` and `:308` that doesn't decrement, and any new enqueue site that doesn't increment. Drift here corrupts the sizer's `PendingCount` input. - **Unbalanced `pendingStarts` / `startingSessions`.** Tick increments `pendingStarts` under `p.mu` at `session_pool_scaling.go:131-138`; `createSession`'s `reserved` defer at `:172-179` releases on early return; the transfer to `startingSessions` at `:246-249` is atomic; `onActive` deletes at `session_pool_lifecycle.go:265`; failed-start deletes at `session_pool_scaling.go:253-255`. Flag any new failure branch in `createSession` that returns without either the `reserved` defer or an explicit transfer/cleanup. - **Missing CAS on close-once flags.** `sessionsClosed.Add(1)` sits downstream of `s.poolCloseRecorded.CompareAndSwap(false, true)` at `session_pool_lifecycle.go:121`. Lifetime histogram sits downstream of `sh.closingRecorded.CompareAndSwap(false, true)` at `:187` and `:317`. `recordSessionClose` re-entry is dedup'd at `:190` and `:347`. Flag any new counter bump / histogram record that isn't gated by the corresponding CAS. - **Uncontrolled goroutine spawns.** Every pool-spawned goroutine must be tracked so `Close` phases 3/5/6 can drain: createSession workers use `p.spawns.Add`/`Done` under `p.mu` (`session_pool_scaling.go:137-138,149`); `spawnTickOnce` uses the same pattern (`session_pool_lifecycle.go:464-476`); the mid-Close onActive dispatch adds at `:274`. Session-owned goroutines (`readLoop`/`heartbeatLoop`) are tracked on `Session.loops` and drained via `WaitGoroutines`. Flag any bare `go func(){…}()` in these files with no WaitGroup entry. - **Hot-path lock churn.** `p.mu` is briefly held on the fast path only at `session_pool.go:239-241` and `:249-255`. `Stats` takes `p.mu` (`:362`). Flag any new `p.mu.Lock()` inserted into `Invoke` (`:465-559`) or the pre-park stretch of `CheckoutSession` outside the two existing points. ### FALSE-POSITIVE SUPPRESSION - **"Package name should be `transport`, not `internal`."** The directory is `bigtable/internal/transport/` but the `package` declaration is `package internal` (confirmed at `session_pool.go:20`). Package name is correct as-is; do NOT flag. - **Local variable shadowing an exported type** where the local name is idiomatic (e.g. `afeID` local vs `AfeID` type in `CheckoutSession`). Already resolved intentionally; do NOT re-raise variants. - **`goimports` / `gofmt` / column-alignment / trailing-newline nits.** CI (`goimports -l`, `gofmt -l`, `go vet`) already gates these. Bot echo is noise. - **Comments referencing PR #20213 / #20215 / #20224.** Stacked-PR context, not stale references; do NOT suggest removal. - **Test coverage complaints for `pool_sizer.go`, `afe_picker.go`, `session_list.go`, `session.go`, `session_vrpc.go`, `session_throttler.go`, `client_configuration_manager.go`, `default_client_config.go`.** All shipped in earlier PRs (#20213, #20215, #20224) with their own tests; out of scope here. - **"Missing error wrapping"** on internal-only calls where the caller already annotates via `fmt.Errorf("POOL %s ...: %w", ...)` or via `btopt.Debugf`. Do NOT suggest adding a second wrap. - **Retry loop / context propagation questions on `Session.Invoke`.** That's the Session layer (`session_vrpc.go`), out of scope for this PR. - **"Consider using `sync.RWMutex` instead of `sync.Mutex` on `p.mu`."** The pool holds `p.mu` for tens of nanoseconds at a time and never for read-heavy loops; the added atomic on `RLock`/`RUnlock` would cost more than it saves. Do NOT suggest. - **"Consider extracting anonymous goroutine into named function."** Style-only; do NOT suggest for the three ticker loops or the createSession worker. ### SCOPE BOUNDARY Comment ONLY on: - `bigtable/internal/transport/session_pool.go` - `bigtable/internal/transport/session_pool_lifecycle.go` - `bigtable/internal/transport/session_pool_scaling.go` - `bigtable/internal/transport/session_pool_debug.go` - `bigtable/internal/transport/session_snapshot.go` - `bigtable/internal/transport/session_pool_*_test.go` - `bigtable/internal/transport/session_snapshot_test.go` Do NOT comment on additions to: - `session.go` / `session_vrpc.go` (WaitGoroutines / closeError additions — vetted) - `connpool.go` (`ChannelPickHintInto` helper — vetted) - `afe_picker.go` (`defaultAfeRandomSubsetSize` constant — vetted) - `debug_tracer.go` (3 new tags — vetted) These are supporting scaffolding, already reviewed by the 3 subagent reviewers in this stack. Only re-raise if something looks actively unsafe. ### EFFORT SCALING - ~4.9k LOC across 12 files. Do NOT paginate uniformly. - **First pass — the 4 hot source files, in this order:** 1. `session_pool.go` (559 LOC) 2. `session_pool_lifecycle.go` (538 LOC) 3. `session_pool_scaling.go` (311 LOC) 4. `session_pool_debug.go` (416 LOC) - **Second pass ONLY if a first-pass finding needs corroboration:** `session_snapshot.go` (594 LOC, mostly type defs), and the tests. Tests use `newTestPool`, which skips config wiring — do NOT flag bootstrap defaults on tests as if they were production paths. - If a first-pass finding is a real hazard from the list above, cite the file:line and the exact anchor pattern it violates. Do not file speculative "consider" comments.
## Summary
Third of five PRs porting the session-pool infrastructure from
`feat/bigtable-sessionz-debug` into upstream. Introduces
`internal/session/` — a proto-native SessionClient + SessionTable
API sitting on top of PR-2's SessionPoolImpl.
- `internal/session/api.go` — public interfaces (`ChannelPool`,
`Config`, `SessionClient`, `SessionTableAPI`, `DebugAccess`).
- `internal/session/client.go` — `SessionClient` impl: dedicated
channel pool (no primer), `ClientConfigurationManager` wiring,
`OpenSessionTable` / `OpenAuthorizedView` / `OpenMaterializedView`
factories that mint lazily-opened per-resource pools keyed by
`{resource, permission}`.
- `internal/session/table.go` — `SessionTable` impl. Two `*lazyPool`
(read + write); MV is read-only (write pool nil, `MutateRow`
returns `ErrWriteNotSupported`). `stampAttempt` sources per-attempt
`cluster_id` / `zone_id` / peer fields from typed
`InvokeResult.ClusterInfo` and `InvokeResult.PeerInfo` per
CLIENT_SIDE_METRICS_SPEC #1.
- `internal/session/lazy_pool.go` — `Invoker` + `SessionPool`
interfaces + open-on-first-use lazy wrapper. Failed opens are NOT
cached; the next call retries.
- `internal/session/debug.go` — `DebugAccess` impl surfacing pool
snapshots for sessionz/loadz/channelz/configz.
### Transport additions to support the above
- `transport/debug_api.go` (new) — `SessionDebugProvider` /
`ChannelDebugProvider` / `ConfigDebugProvider` interfaces +
`ChannelPoolDebug` / `SessionRef` DTOs. Lives in transport (not
bigtable) so `bigtable.Client` and `internal/session.SessionClient`
can implement without an import cycle.
- `transport/diverter.go` — `sessionPicks` / `classicPicks` counters,
`DiverterSnapshot`, `Snapshot()`.
- `transport/connpool.go` — `ChannelSnapshot` +
`ChannelPoolSnapshot` type + method, `WithInstanceName` /
`WithAppProfile` options for channelz labelling.
- `transport/debug_tracer.go` — exported `DebugTag` +
`RecordDebugTag` + `TagSessionAttemptNilClusterInfo` /
`TagSessionAttemptEmptyClusterID` catalog constants.
- `transport/session_descriptors.go` — `SessionType.ProtoName()` for
human-readable pool identifiers.
- `transport/direct_access_checker.go` — renames
`newPingAndWarmDirectAccessChecker` →
`NewPingAndWarmDirectAccessChecker` (constructor exported) and
nil-guards `primer.Prime` so session-based clients can pass a nil
primer. Session clients warm channels on-demand via `OpenSession`,
not eagerly at pool-init.
### Lifecycle correctness
`sessionClient.Close()` snapshots owned resources under `poolsMu` and
releases the lock before running `Close` / `Shutdown` / `Cancel`
calls, so a snapshot method holding `poolsMu` never deadlocks
teardown. Post-Close Opens surface a distinct
`ErrSessionClientClosed` sentinel rather than misleading
`errReadPoolNil` / `ErrWriteNotSupported`.
## Stack
- **PR-1** #20224 (sessionList) — merged
- **PR-2** #20225 (SessionPoolImpl) — open; this PR stacks on it
- **PR-4** (this PR)
- PR-5 will follow with the bigtable-package integration + debugview.
The diff on this PR includes PR-2's commits until #20225 merges into
main.
## Test plan
- [x] `go build ./internal/session/ ./internal/transport/ ./...`
- [x] `go test ./internal/session/ -race -count=1 -short -timeout=180s`
- [x] `go test ./internal/transport/ -race -count=1 -short -skip
'AfeLbSim|TestHighQpsSession' -timeout=240s`
- [x] `gofmt -l ./internal/session/ ./internal/transport/` — clean
- [x] `go vet ./internal/session/ ./internal/transport/` — clean
- [x] Reviewed against the 4 behavioral specs (SESSION_SPEC,
SESSION_CLIENT_SPEC, SESSION_POOL_SPEC, CLIENT_SIDE_METRICS_SPEC)
and SESSION_COMPONENT_SPEC (boundary rules) — PASS.
🤖 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>
Summary
First of five PRs porting the session-pool infrastructure from
feat/bigtable-sessionz-debug(which powers the recycle-repro fleet) upstream. Stacks on #20215 (Session lifecycle, now merged).What lands
New file:
session_list.go(~600 LOC) — the per-pool sessionList data structure that groups sessions by the AFE (Application Front End) their handshake landed on. Consumed by the two-tier picker (K-choice-over-AFEs → dequeue-idle-session) in a follow-up PR.Key types:
AfeID(int64) — AFE identifier from the server's PeerInfo header at session-open. 0 is the sentinel "unknown" bucket for handshakes that did not carry a peer-info header.AfeSnapshot— value-typed view of an afeHandle for pickers to score without holding sl.mu (Checkout re-resolves by ID; no *afeHandle escapes).AfeSnapshotRow— debug-UI row emitted bysessionList.Snapshot()(consumed by afez/sessionz in a later debug PR).afeHandle(unexported) — per-AFE bucket: FIFO idle queue, refCount (idle + inFlight + closing), two PeakEwma trackers.SessionHandle— pool bookkeeping wrapper around Session. CarriesinExpectedCount(I5 guard against WaitServerClose retry storm) andactivated / closingRecorded / closeRecordeddedup flags for the pool's per-session hook chain.sessionList(unexported) — the state machine, guarded bysl.mu.The state model documents six invariants (I1-I6) that every method preserves:
Lock order:
sl.muONLY.RecordVRpcOutcomedeliberately dropssl.mubetween the map lookup and thePeakEwma.Updateso the hot vRPC-outcome path doesn't serialize on it.Consolidates AFE types (
AfeID+AfeSnapshot) that previously lived inafe_snapshot.go— sessionList now owns all AFE-bucket concepts. Deletesafe_snapshot.go.New file:
session_list_test.go(~770 LOC) — I1-I6 coverage plus per-method tests (OnSessionStarted / Checkout / ReleaseToPool / OnSessionClosing / OnSessionClosed / RecordVRpcOutcome / ReadyAfes / Snapshot / AllHandles / Prune) and a concurrency stress test covering the documented lock-drop path in RecordVRpcOutcome.Edits to
debug_tracer.go— three new tag constants for sessionList bookkeeping violations (all unreachable-under-invariants, kept as belt-and-suspenders):tagSessionListStartedNilSessiontagSessionListRefcountUnderflowtagSessionListReadyCountUnderflowEdit to
session.go— one-line comment retarget onAfeID()(type now lives insession_list.go, not the deletedafe_snapshot.go).Stack
Test plan
go build ./internal/transport/passesgo vet ./internal/transport/cleango test ./internal/transport/ -race -count=1 -short -timeout=120spasses — 20+ new sessionList tests plus all pre-existing.