feat(bigtable): add PeakEwma continuous time-decay latency tracker - #20187
Conversation
Adds the PeakEwma type used by the upcoming AFE picker to score sessionList buckets. Standalone (no callers on upstream/main yet) so the type + tests can land ahead of the consumers. - PeakEwma — thread-safe EWMA with e^(-dt/tau) continuous time-decay weighting. First Update snaps value to the sample; subsequent updates decay the prior and blend the new sample proportionally. - NewPeakEwmaSeeded — Java-parity seed for cold AFEs so a brand-new bucket doesn't win the least-latency picker by looking free-cost (SessionList.java: transport 500us, e2e 1ms). Tests cover the unseeded / seeded initial value, first-update-overrides- seed, the constant-sample invariant (w*L + (1-w)*L == L for any weight), the convex-combination bound (Value stays within [min, max] of samples seen), Value() purity, wide-gap dominance (dt >> tau => new sample wins), and a mutex race check with mixed reader/writer goroutines.
There was a problem hiding this comment.
Code Review
This pull request introduces PeakEwma, a thread-safe continuous-time exponentially-weighted moving average of latency samples, along with comprehensive unit tests. The review feedback points out potential robustness issues in the Update method, specifically that a zero decay constant (tau) combined with a zero time difference (dt) can result in NaN, and a backward clock jump (dt < 0) can cause the weight to exceed 1.0. A defensive code suggestion is provided to clamp dt and handle tau <= 0.
| func (e *PeakEwma) Update(latency time.Duration) { | ||
| e.mu.Lock() | ||
| defer e.mu.Unlock() | ||
| now := time.Now() | ||
| if e.lastUpdate.IsZero() { | ||
| e.value = float64(latency) | ||
| e.lastUpdate = now | ||
| return | ||
| } | ||
| dt := now.Sub(e.lastUpdate) | ||
| e.lastUpdate = now | ||
| // Continuous time-decay weight: e^(-dt/tau). | ||
| weight := math.Exp(-float64(dt) / float64(e.tau)) | ||
| // EWMA = EWMA * weight + latency * (1 - weight). | ||
| e.value = e.value*weight + float64(latency)*(1-weight) | ||
| } |
There was a problem hiding this comment.
If e.tau is 0 (such as when using the zero-value of PeakEwma or if initialized with 0), and Update is called twice in rapid succession such that dt is 0, the weight calculation math.Exp(-0 / 0) will produce NaN. This permanently corrupts the EWMA value with NaN for all subsequent updates.
Additionally, if the system clock jumps backwards (resulting in dt < 0), the weight will exceed 1.0, violating the convex combination bounds and causing the EWMA to blow up or go negative.
To make the implementation robust and defensive against these edge cases, we should clamp dt to 0 and handle e.tau <= 0 by instantly decaying to the new sample.
func (e *PeakEwma) Update(latency time.Duration) {
e.mu.Lock()
defer e.mu.Unlock()
now := time.Now()
if e.lastUpdate.IsZero() {
e.value = float64(latency)
e.lastUpdate = now
return
}
dt := now.Sub(e.lastUpdate)
if dt < 0 {
dt = 0
}
e.lastUpdate = now
if e.tau <= 0 {
e.value = float64(latency)
return
}
// Continuous time-decay weight: e^(-dt/tau).
weight := math.Exp(-float64(dt) / float64(e.tau))
// EWMA = EWMA * weight + latency * (1 - weight).
e.value = e.value*weight + float64(latency)*(1-weight)
}…ive guards Address session-reviewer + gemini-code-assist findings on PR googleapis#20187: - Peak-snap: samples strictly higher than the current value snap up immediately with no blend. Without this, the tracker was a plain symmetric EWMA — a new AFE with one fast sample would look instantly cheap and steal traffic from the least-latency picker. - Seed retained on first Update: the earlier lastUpdate.IsZero() branch snapped the value to the first sample, discarding the seed and defeating cold-start weighting. Constructors now stamp lastUpdate = time.Now() so the seed participates in the first blend normally. - rtt <= 0 guard: zero or negative samples are dropped instead of corrupting the tracker. - Backward-clock clamp: dt < 0 is clamped to 0 so a wall-clock rewind can't push the blend weight > 1 and shove Value outside sample range. - tau <= 0 collapses decay to 0: guards against exp(-0/0) = NaN when tau and dt are both zero. Natural τ → 0 limit (new sample fully replaces). Test changes: - Drop TestPeakEwma_FirstUpdateOverridesSeed (assertion was wrong). - Add TestPeakEwma_PeakSnapOnHigherSample. - Add TestPeakEwma_SeedRetainedOnLowerFirstSample. - Add TestPeakEwma_NonPositiveSampleIgnored. - Add TestPeakEwma_ZeroTauSafe. - Add TestPeakEwma_BackwardClockNoOvershoot. - Rename LargeGapDominatesWithNewSample → LargeGapDominatesLowerSample (higher new samples now peak-snap; lower samples still blend). 12 tests pass under -race.
The rationale for the seed values (500us / 1ms) stands on its own without citing SessionList.java — the description now reads without requiring the reader to have the Java tree handy. Peak-snap and the other Java-parity fixes shipped on PR googleapis#20187 have not been ported to this branch's peak_emwa.go yet; that's a separate change.
🤖 I have created a release *beep* *boop* --- ## [1.51.0](bigtable/v1.50.0...bigtable/v1.51.0) (2026-07-23) ### Features * **bigtable:** Add ChainInterceptors and RetryingVRpc for vRPC pipeline ([#20185](#20185)) ([c7a832a](c7a832a)) * **bigtable:** Add ClientConfigurationManager ([#19986](#19986)) ([3a8f927](3a8f927)) * **bigtable:** Add debug tag counter (recordDebugTag / assertDebugTag) ([#20114](#20114)) ([3c97590](3c97590)) * **bigtable:** Add lazyPool helper for on-demand session pool opening ([#20182](#20182)) ([f6ae3fb](f6ae3fb)) * **bigtable:** Add PeakEwma continuous time-decay latency tracker ([#20187](#20187)) ([9d124ef](9d124ef)) * **bigtable:** Add PoolSizer for server-driven session pool capacity ([#20189](#20189)) ([57ebbeb](57ebbeb)) * **bigtable:** Add session package with SessionClient + SessionTableAPI interfaces ([#20180](#20180)) ([4b82fd2](4b82fd2)) * **bigtable:** Add Session primitives (AttemptOutcome, vRPC ctx, msgtype) ([#20116](#20116)) ([e1011e2](e1011e2)) * **bigtable:** Add Session state enum ([#19981](#19981)) ([0748972](0748972)) * **bigtable:** Add SessionThrottler / AdaptiveSessionThrottler for OpenSession pacing ([#20184](#20184)) ([02e3c6d](02e3c6d)) * **bigtable:** Add SessionThrottler / AdaptiveSessionThrottler for OpenSession pacing ([#20184](#20184)) ([29be83e](29be83e)) * **bigtable:** Add sessionTracer for per-Session lifecycle + vRPC metrics ([#20190](#20190)) ([a466345](a466345)) * **bigtable:** Enable new auth library and JWT for instance admin client ([#20013](#20013)) ([21c4a44](21c4a44)) * **bigtable:** Modularize channel priming behind a ChannelPrimer interface ([#20027](#20027)) ([5214ab7](5214ab7)) * **bigtable:** Modularize Direct Access compatibility check ([#19987](#19987)) ([a25e93d](a25e93d)) * **o11y:** Regenerate clients for LRO tracing ([#20107](#20107)) ([779074e](779074e)) ### Bug Fixes * **bigtable:** Default cluster/zone in toOtelMetricAttrs to avoid Monitoring reject ([#20178](#20178)) ([14493f4](14493f4)) * **bigtable:** Eliminate stats-handler MD race in internal/metrics tracer ([#20158](#20158)) ([c387066](c387066)) --- 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
Adds
PeakEwma, a thread-safe continuous-time exponentially-weighted moving average of latency samples. The upcoming AFE picker (Session subsystem) uses one instance per bucket to score candidates; landing the type standalone lets it merge ahead of its consumers.peak_ewma.go—PeakEwmastruct,NewPeakEwma(tau),NewPeakEwmaSeeded(tau, seed),Update(latency),Value(). Decay weight ise^(-dt/tau)computed on every Update. First Update snaps value to the sample; subsequent updates blend prior and new proportionally.NewPeakEwmaSeeded— Java parity:SessionList.javaseeds transport at 500µs and e2e at 1ms so a brand-new AFE doesn't win the least-latency picker by looking free-cost. The seed is authoritative only until the firstUpdate.No callers on
mainyet — follow-up PRs for the AFE picker andsessionListwill consume it.Test plan
go build ./bigtable/...go test ./bigtable/internal/transport/ -run '^TestPeakEwma' -count=10 -race— passes (1.6s)gofmt -l bigtable/internal/transport/peak_ewma*.go— cleango vet ./bigtable/internal/transport/— cleanTest coverage:
Value()Updateoverrides the seed (pins the documented behavior)w*L + (1-w)*L == Lfor any weight, so the EWMA is exact on a flat stream[min, max]of samples seenValue()is a pure read (no state mutation)dt >> tau⇒ weight → 0 ⇒ Value tracks the newest sample (skipped under-short)-race)