Skip to content

fix(bigtable): eliminate stats-handler MD race in internal/metrics tracer#20158

Merged
sushanb merged 6 commits into
googleapis:mainfrom
sushanb:fix/bigtable-metrics-tracer-md-race
Jul 17, 2026
Merged

fix(bigtable): eliminate stats-handler MD race in internal/metrics tracer#20158
sushanb merged 6 commits into
googleapis:mainfrom
sushanb:fix/bigtable-metrics-tracer-md-race

Conversation

@sushanb

@sushanb sushanb commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Hoists per-attempt cluster/zone/transport/server-latency extraction out of HandleRPC's stats.End branch and into a new ingestMetadata helper that runs on the InHeader/InTrailer dispatches. End now reads only already-parsed primitives — never the raw metadata.MD — so the cross-goroutine race on the map is gone.
  • Serializes the primitive-field writes vs reads with a per-Tracer sync.Mutex. Only held around the field access, never across metric.Record calls. CreateTracer now returns *Tracer so the embedded mutex isn't copied out of the factory.
  • Adds a deterministic -race regression test (tracer_race_test.go) that reproduced the failing interleave on the pre-fix code and passes cleanly on the fix.

Race being fixed

Reported in the wild by the nightly integration suite; race dump captured from TestIntegration_Presidents:

WARNING: DATA RACE
Read  at 0x…5ac8 by goroutine 5793 (csAttempt.finish → HandleRPC[*stats.End])
  bigtable/internal/metrics/tracer.go:867
Previous write at 0x…5ac8 by goroutine 1478 (http2Client.operateHeaders → HandleRPC[*stats.InTrailer])
  bigtable/internal/metrics/tracer.go:859

Two follow-on races on the same run against the underlying metadata.MD map (MD.Copy in operateHeaders vs MD.Get in extractLocation). All three came from the same design flaw: HandleRPC stored ev.Header / ev.Trailer on the current attempt from InHeader / InTrailer, then re-read them under stats.End. gRPC's stats.Handler contract does not promise InHeader → InTrailer → End dispatch on a single goroutine — under cancel / deadline / GOAWAY, csAttempt.finish (google.golang.org/grpc@1.82.0/stream.go:1251) fires End from the caller goroutine while the transport reader (http2_client.go:1650) is still processing the trailer frame.

Also plausibly implicated in the FlakyBot P1 burst against 30b1dfa0db on the same nightly (#20147, #20148, #20150, #20151, #20152), all of which are TestIntegration_* failures with no attached stack — the Sponge log is Google-internal, but the timing (all P1s stem from the same invocation, and the metrics refactor #20099 that introduced this pattern shipped six days earlier) strongly suggests the same underlying race.

Design (why this exact fix)

Option A of the several considered — the alternatives were:

  • B: add a mutex around metadata.MD.Get inside RecordAttemptCompletion. Fixes the primitive-field race but not the map-content race (gRPC's transport owns the map and mutates it inside operateHeaders). Rejected.
  • C: plumb server headers/trailers through stats.End. Requires a gRPC-side change. Not viable short-term.

Option A eliminates cross-goroutine access to the MD entirely — the raw map is read exactly once on the goroutine that received it, then dropped. The mutex only guards the small set of already-parsed primitive fields that End needs to read.

extractLocation / extractPeerInfo / extractServerLatency all check header first then trailer, so passing one MD at a time (with the locationExtracted / peerInfoExtracted booleans + the serverLatency == 0 gate) preserves the exact header-preferred-trailer-fallback behaviour.

Test plan

  • go test -race -count=5 -run TestHandleRPC_ConcurrentInTrailerAndEnd_NoRace -timeout=120s ./bigtable/internal/metrics/ — passes; same test tripped the race on the pre-fix code.
  • go test -race -count=1 -timeout=120s ./bigtable/internal/metrics/... — full package suite passes under race.
  • go vet ./... clean.
  • go build ./... clean.
  • Follow-up: rerun the nightly integration suite to close out the sibling FlakyBot issues if the fix eliminates them.

…te race

The metrics StatsHandler stored ev.Header / ev.Trailer on the current
attempt from HandleRPC's InHeader/InTrailer branches and re-read them
from the End branch. gRPC's stats.Handler contract does not guarantee
all three events dispatch from the same goroutine — csAttempt.finish
(stream.go:1251) fires End on the caller goroutine under cancel /
deadline / GOAWAY, while the transport reader is still processing the
trailer frame. TestIntegration_Presidents caught the interleave and
produced:

  WARNING: DATA RACE
    Read  at ... bigtable/internal/metrics/tracer.go:867
    Write at ... bigtable/internal/metrics/tracer.go:859
    Write at ... grpc/metadata.MD.Copy (operateHeaders)
    Read  at ... extractLocation -> metadata.MD.Get

Also implicated in the same nightly's FlakyBot P1 burst against
30b1dfa (see googleapis#20147, googleapis#20148, googleapis#20150, googleapis#20151, googleapis#20152).

Fix hoists the extraction into a new ingestMetadata helper that runs
on the InHeader/InTrailer dispatch itself, storing already-parsed
primitives (clusterID, zoneID, transportType/Region/Zone/SubZone,
serverLatency) on the current attempt. End then does no MD access —
it reads only the primitives ingestMetadata already wrote, so the raw
metadata.MD map is never touched cross-goroutine.

Serializes the primitive-field writes/reads with a per-Tracer
sync.Mutex — the mutex is only held around field reads/writes, never
across metric.Record calls, and per-attempt contention is limited to
the rare cancel/deadline race window. CreateTracer now returns *Tracer
(instead of Tracer) so the embedded mutex isn't copied out of the
factory.

RecordAttemptCompletionWithMetadata stays as a thin adapter for the
downstream session/vRPC path. RecordAttemptCompletion's signature
drops the two MD params — they're never needed once ingestMetadata
has run — and its former extractLocation / extractPeerInfo /
extractServerLatency blocks are gone.

Adds tracer_race_test.go with a deterministic regression test:
concurrently fires InTrailer and End on the same tracer across
different goroutines for 200 iterations, must pass under -race.
Verified to trip the race on the pre-fix code and pass on the fix.
@sushanb
sushanb requested review from a team as code owners July 14, 2026 22:21
@product-auto-label product-auto-label Bot added the api: bigtable Issues related to the Bigtable API. label Jul 14, 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 addresses data races in the Bigtable metrics tracer by introducing a mutex to serialize access to the tracer and refactoring metadata extraction. Instead of passing raw metadata to RecordAttemptCompletion, a new ingestMetadata method parses and stashes metadata as primitive fields on the transport reader goroutine, making it safe to race with stats.End. A concurrent race test is also added. Feedback points out a bug in ingestMetadata where locationExtracted is set to true even if no location was found in the headers, which would incorrectly prevent fallback extraction from trailers.

Comment on lines +557 to 583
if !a.locationExtracted {
if clusterID, zoneID, err := extractLocation(headerMD, trailerMD); err == nil {
// Don't overwrite a cluster/zone that the vRPC path has
// already populated (SessionTable sets these directly from
// the ClusterInformation payload); only fill in if the
// attempt's value is missing or the sentinel default.
// lastClusterID / lastZoneID always track the freshest real
// value so operation-level metrics get a sensible fallback.
if clusterID != "" {
if existing := a.clusterID; existing == "" || existing == defaultCluster {
a.SetClusterID(clusterID)
}
if clusterID != defaultCluster {
mt.currOp.lastClusterID = clusterID
}
}
if zoneID != "" {
if existing := a.zoneID; existing == "" || existing == defaultZone {
a.SetZoneID(zoneID)
}
if zoneID != defaultZone {
mt.currOp.lastZoneID = zoneID
}
}
a.locationExtracted = true
}
}

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.

high

If extractLocation is called on InHeader and the header does not contain the location metadata, it will likely return "", "", nil (since missing optional metadata is not a parsing error). In this case, a.locationExtracted is set to true even though no location was actually extracted. Consequently, when InTrailer is processed, the check !a.locationExtracted will be false, and the code will skip extracting the location from the trailer, causing the location to be missed entirely.

To prevent this, a.locationExtracted should only be set to true if a non-empty clusterID or zoneID was successfully extracted.

	if !a.locationExtracted {
		if clusterID, zoneID, err := extractLocation(headerMD, trailerMD); err == nil {
			// Don't overwrite a cluster/zone that the vRPC path has
			// already populated (SessionTable sets these directly from
			// the ClusterInformation payload); only fill in if the
			// attempt's value is missing or the sentinel default.
			// lastClusterID / lastZoneID always track the freshest real
			// value so operation-level metrics get a sensible fallback.
			if clusterID != "" {
				if existing := a.clusterID; existing == "" || existing == defaultCluster {
					a.SetClusterID(clusterID)
				}
				if clusterID != defaultCluster {
					mt.currOp.lastClusterID = clusterID
				}
			}
			if zoneID != "" {
				if existing := a.zoneID; existing == "" || existing == defaultZone {
					a.SetZoneID(zoneID)
				}
				if zoneID != defaultZone {
					mt.currOp.lastZoneID = zoneID
				}
			}
			if clusterID != "" || zoneID != "" {
				a.locationExtracted = true
			}
		}
	}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

False alarm, I think — the premise "missing optional metadata is not a parsing error" doesn't match extractLocation's actual contract.

At bigtable/internal/metrics/util.go:168-170:

if len(locationMetadata) < 1 {
    return defaultCluster, defaultZone, errors.New("failed to get location metadata")
}

When LocationMDKey is absent from the (non-nil) MD bag passed in, extractLocation returns a non-nil error. ingestMetadata gates on if err == nil, so on the (headerMD, nil) call where the header lacks the key, we get the error, skip the locationExtracted = true assignment, and the subsequent (nil, trailerMD) call from InTrailer still has a full chance to populate. That's exactly the header-preferred-trailer-fallback behaviour the old single-call extractLocation(headerMD, trailerMD) had.

The other two extractors follow the same shape:

  • extractPeerInfo returns (nil, nil) when the key is absent → we gate peerInfoExtracted = true on peerInfo != nil, so trailer still gets its shot.
  • extractServerLatency returns (0, nil) when absent → the outer if a.serverLatency == 0 gate keeps re-trying until a real value lands.

The scenario the suggested change would guard against (header has the key but the marshalled ResponseParams decodes to empty cluster+zone) is a server-side pathology that wouldn't reach the trailer either, so the extra gate wouldn't recover anything real. Leaving as-is.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correction — my earlier "False alarm" was itself wrong, though not in the direction the bot flagged.

The bot's scenario (locationExtracted = true incorrectly latched on "") doesn't reproduce, for the reason I gave: extractLocation returns a non-nil error when LocationMDKey is absent, so the err == nil gate blocks the latch. But the same gate ALSO blocked the stamping of the (defaultCluster, defaultZone) sentinel that extractLocation returns on error — and RecordAttemptCompletion's connectivity_error_count check reads currAttempt.clusterID == defaultCluster as the "no server-side signals" marker. With the gate, no-header attempts left clusterID as ""; the check evaluated "" == "<unspecified>" → false; the counter never incremented. TestConnectivityErrorCount in bigtable/metrics_test.go caught it:

metrics_test.go:592: Metric connectivity_error_count for status Unavailable:
  got cumulative value 0, want 1

Fixed in 05d910a: drop the err == nil gate around the stamping block (so the sentinel is applied on missing-header attempts, matching pre-race-fix behaviour), and set a.locationExtracted = true only when err == nil (so a later InTrailer carrying real data can still overwrite the sentinel on the same attempt). This also happens to be closer in spirit to the bot's suggestion — the latch is now guarded — just guarded on err == nil rather than clusterID != "", because the sentinel non-empty clusterID is load-bearing downstream.

…missing headers

ingestMetadata gated the entire extractLocation stamping block on
err == nil. extractLocation returns (defaultCluster, defaultZone, err)
when the LocationMDKey header is absent, and RecordAttemptCompletion's
connectivity_error_count check reads

    isLocationEmpty := currAttempt.clusterID == defaultCluster

as the "no server-side signals" marker. With the gate, an attempt
without location headers left clusterID as "" (fresh AttemptTracer zero
value); the check evaluated "" == "<unspecified>" → false, and the
counter never incremented. TestConnectivityErrorCount caught this:

    metrics_test.go:592: Metric connectivity_error_count for status
    Unavailable: got cumulative value 0, want 1

Fix drops the err == nil gate around the stamping block so the
sentinel is applied on missing-header attempts (matching the pre-race-
fix behaviour), and only sets locationExtracted = true when err == nil
so a later InTrailer carrying real data can still overwrite the
sentinel on the same attempt.

Verified:
  go test -run TestConnectivityErrorCount -count=1 ./bigtable/       → pass
  go test -race -count=3 -run TestHandleRPC_ConcurrentInTrailerAndEnd_NoRace
    ./bigtable/internal/metrics/                                     → pass
  go test -race -count=1 ./bigtable/internal/metrics/...             → pass
Comment thread bigtable/internal/metrics/tracer.go Outdated
// those primitives, so stats.End (which may dispatch on a different
// goroutine under cancel/deadline/GOAWAY) never touches the raw MD.
//
// The old code stored ev.Header / ev.Trailer on the attempt and re-read

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.

nit: remove the document about the old code

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 4a68e10 — removed the paragraph so the doc reads forward-only.

Comment thread bigtable/internal/metrics/tracer.go
Comment thread bigtable/internal/metrics/tracer.go Outdated
// didn't carry a server-timing value (serverLatency still 0), fall
// back to the client-observed T4-T7 span. Runs here rather than in
// ingestMetadata because the T4-T7 window closes only at End.
if mt.currOp.currAttempt.serverLatency == 0 && mt.currOp.currAttempt.t4t7Tracker != 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.

Sorry I didn't catch this earlier, but I don't think we need / want a t4t7Traacker fallback? It's not capturing the same value as the server t4t7. The gfe server latency is strictly measuring the server side, and doesn't include the latency between application to GFE. On the other hand t4t7Tracker mixes up both server side latencies and the network latencies between the app and GFE.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah, we use t4t7Tracker for directpath (if gfe sends no header).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Youre right that the two arent measuring the same slice (T4-T7 includes the app↔GFE round trip, GFEs gfet4t7 doesnt), but Id like to keep the fallback for now — this is a resurrection of behaviour that was in the tracer before the race fix, and TestFallbackT4T7Latency (bigtable/metrics_test.go:1163) explicitly pins the "server-timing header omitted → server_latencies still gets a value from T4-T7" contract with a 50 ms epsilon comparison.

The intent (as I read the original) is: when the server doesnt emit server-timing (fake servers in tests, non-GFE proxies, older backends), fall back to something rather than dropping the whole server_latencies metric for that attempt. The value is coarser but its still bounded above by the actual server-side latency plus network round-trip, which is more useful than a zero-count series.

If youd rather delete the fallback + the test as a follow-up (semantic-purity win, lose the fake-server smoke-signal), Im happy to do that in a separate PR so this one stays scoped to the race fix. Let me know which you prefer.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reversing course — removed the whole t4t7Tracker in ef145b2 (not just the fallback branch). Deleted the context key, the AttemptTracer field, the struct+methods, the TagRPC/HandleRPC hooks, and TestFallbackT4T7Latency. server_latencies now stays unrecorded on attempts where the server didn't emit server-timing — cleaner semantic than the mixed server+network value.

3 files changed, 9 insertions(+), 187 deletions(-). Passes race + short suites.

sushanb added 2 commits July 17, 2026 16:18
- Drop the paragraph in ingestMetadata's doc that recounted the pre-
  race-fix code (stashed MD on the attempt, csAttempt.finish vs
  transport reader collision, issue googleapis#20152 link). The comment now
  reads forward-only.
- Add a nested comment on the peerInfoExtracted latch explaining why
  the gate is peerInfo != nil rather than the err == nil pattern used
  for locationExtracted: extractPeerInfo returns (nil, nil) on missing
  header (not an error), so gating on err == nil alone would latch on
  the header call and kill the trailer fallback.

No behaviour change.

Verified:
  go build ./...                                   → clean
  go vet ./bigtable/internal/metrics/...           → clean
  go test -race -count=1 ./bigtable/internal/metrics/... → pass
Per mutianf on PR review: the t4t7Tracker fallback wasn't measuring the
same slice as GFE server_latencies. gfet4t7 is strictly server-side; the
tracker's window (OutHeader → InHeader) also includes the app↔GFE round
trip, so the fallback value was silently mixing server + network into
what's supposed to be a server-only distribution.

Delete the whole tracker plumbing rather than just the fallback so
there's no dead code left behind:

- tracer.go: drop t4t7ContextKey, AttemptTracer.t4t7Tracker field,
  t4t7Tracker struct + recordOutHeaderSent / recordInHeaderRecv /
  getLatencyMs methods, the fallback branch in RecordAttemptCompletion,
  StatsHandler.TagRPC installation, and the HandleRPC OutHeader/InHeader
  switch. Fix up the surrounding doc comments (StatsHandler,
  TagRPC) to stop referencing the tracker.
- bigtable.go: update the "per-attempt metric setup" comment on the
  invoke wrapper to drop the t4t7Tracker reference.
- metrics_test.go: remove TestFallbackT4T7Latency, which pinned the
  removed fallback via a fake server that omitted server-timing.

server_latencies now stays unrecorded on attempts where the server
didn't emit server-timing (the existing serverLatencyErr gate in
RecordAttemptCompletion). That's the intended semantic — a
zero-server-latency signal is better than a coarse mixed one.

Verified:
  go build ./...                                          → clean
  go vet ./...                                            → clean
  go test -race -count=1 ./bigtable/internal/metrics/...  → pass
  go test -short -run 'TestReadRowsMetrics|TestMetric|TestConnectivityErrorCount|TestClientBlockingLatency|TestRetryMetrics' ./bigtable/... → pass
@sushanb

sushanb commented Jul 17, 2026

Copy link
Copy Markdown
Contributor Author

@mutianf Addressed the review nits in two commits on top of 05d910a:

  • 4a68e10 — dropped the old-code paragraph from the ingestMetadata doc; added an inline explainer on the peerInfoExtracted latch (kept as peerInfo != nil — see the reply on that thread for why gating on err == nil alone would break trailer fallback given extractPeerInfo's (nil, nil)-on-missing-key contract).
  • ef145b2 — removed the whole t4t7Tracker: context key, AttemptTracer field, struct + methods, TagRPC installation, HandleRPC OutHeader/InHeader hooks, and TestFallbackT4T7Latency. server_latencies now stays unrecorded on attempts where the server didn't emit server-timing (existing serverLatencyErr gate), rather than substituting the coarser T4-T7 span. 9 insertions, 187 deletions.

Verified:

go build ./...                                          → clean
go vet ./...                                            → clean
go test -race -count=1 ./bigtable/internal/metrics/...  → pass
go test -short -run 'TestReadRowsMetrics|TestMetric|TestConnectivityErrorCount|TestClientBlockingLatency|TestRetryMetrics' ./bigtable/... → pass

PTAL.

The race-fix refactor moved extractServerLatency from End-time (in
RecordAttemptCompletionWithMetadata) to the InHeader/InTrailer
dispatches (in ingestMetadata). That closed the cross-goroutine
race, but opened a new gap: if the stream aborts before any
InHeader/InTrailer fires (pre-connect failure, DEADLINE_EXCEEDED
before response, transport hangup), ingestMetadata never runs, so
serverLatencyErr stays at its zero-value nil. RecordAttemptCompletion
then sees serverLatencyErr == nil and records serverLatency=0.

The pre-refactor code didn't hit this: RecordAttemptCompletionWith-
Metadata unconditionally called extractServerLatency at End, which
always stamped serverLatencyErr = err on a missing server-timing
header.

Add a serverLatency > 0 guard to the Record call so the aborted-
stream case stays out of the distribution. This also silences the
"server explicitly reported gfet4t7; dur=0" case, but a legit 0ms
server latency is unrealistic — the truthful zero was a small
noise source not worth preserving.

Verified:
  go build ./...                                          → clean
  go vet ./...                                            → clean
  go test -race -count=1 ./bigtable/internal/metrics/...  → pass
  go test -short -run 'TestReadRowsMetrics|TestMetric|TestConnectivityErrorCount|TestClientBlockingLatency|TestRetryMetrics' ./bigtable/... → pass
@sushanb

sushanb commented Jul 17, 2026

Copy link
Copy Markdown
Contributor Author

Follow-up in b4cc226: caught an edge case while auditing the t4t7 removal.

The refactor moved extractServerLatency from End-time into the InHeader/InTrailer dispatches (in ingestMetadata), which closed the race but opened a gap: if the stream aborts before *any* InHeader/InTrailer fires (pre-connect failure, DEADLINE_EXCEEDED before response, transport hangup), ingestMetadata never runs, serverLatencyErr stays at its zero-value nil, and RecordAttemptCompletion would happily record serverLatency=0. The pre-refactor code didn't hit this — it called extractServerLatency unconditionally at End, always stamping the error.

Fix: gate the Record call on serverLatency > 0 in addition to serverLatencyErr == nil. Also silences the (unrealistic) server-reported 0ms case. tracer.go:673-687.

…cy > 0"

This reverts b4cc226. Keeping the single serverLatencyErr == nil gate
per review discussion — the aborted-stream edge case (ingestMetadata
never fires, serverLatencyErr stays at zero-value nil) is being deferred
to a separate follow-up rather than resolved with the value-based guard.
@sushanb

sushanb commented Jul 17, 2026

Copy link
Copy Markdown
Contributor Author

Reverted b4cc226 in 2cfa359 — keeping the single serverLatencyErr == nil gate. The aborted-stream edge case I flagged (ingestMetadata never fires → serverLatencyErr stays nil → server_latencies records 0) will be handled separately if we decide it's worth handling.

@sushanb
sushanb merged commit c387066 into googleapis:main Jul 17, 2026
19 checks passed
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.

2 participants