Skip to content

discovery: hold connMu when writing tabletHealthCheck health fields - #20319

Open
netliomax25-code wants to merge 6 commits into
vitessio:mainfrom
netliomax25-code:healthcheck-connmu-lock
Open

discovery: hold connMu when writing tabletHealthCheck health fields#20319
netliomax25-code wants to merge 6 commits into
vitessio:mainfrom
netliomax25-code:healthcheck-connmu-lock

Conversation

@netliomax25-code

@netliomax25-code netliomax25-code commented Jun 15, 2026

Copy link
Copy Markdown
Contributor

Description

  1. tabletHealthCheck.SimpleCopy (and connectionLocked) read Target, Serving, Stats, LastError and PrimaryTermStartTime under connMu, but processResponse, the timeout branch of checkConn, closeConnection and finalizeConn write those same fields without holding connMu. HealthCheck.TabletConnection also read thc.Conn outside connMu.
  2. SimpleCopy is reachable from another goroutine through HealthCheck.GetTabletHealthByAlias, and thc.Conn through HealthCheck.TabletConnection, while the per-tablet checkConn goroutine is applying a streaming health update or closing the connection, so go test -race reports read/write races on those fields.
  3. Took connMu around the field writes at all four sites, releasing it before SimpleCopy and before any connection Close so the lock is never held across network IO, and added a currentConnection helper so TabletConnection reads thc.Conn under connMu.

The added regression tests drive processResponse/GetTabletHealthByAlias and TabletConnection/stream-close concurrently and fail under -race without the change.

Related Issue(s)

Checklist

  • "Backport to:" labels have been added if this change should be back-ported to release branches
  • If this change is to be back-ported to previous releases, a justification is included in the PR description
  • Tests were added or are not required
  • Did the new or modified tests pass consistently locally and on CI?
  • Documentation was added or is not required

Deployment Notes

None. Internal locking fix with no user-visible behavior change.

Signed-off-by: netliomax25-code <netliomax25@gmail.com>
Copilot AI balanced review requested due to automatic review settings June 15, 2026 09:32

Copilot AI 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.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@github-actions github-actions Bot added this to the v25.0.0 milestone Jun 15, 2026
@vitess-bot vitess-bot Bot added the NeedsWebsiteDocsUpdate What it says label Jun 15, 2026
@vitess-bot

vitess-bot Bot commented Jun 15, 2026

Copy link
Copy Markdown
Contributor

Review Checklist

Hello reviewers! 👋 Please follow this checklist when reviewing this Pull Request.

General

  • Ensure that the Pull Request has a descriptive title.
  • Ensure there is a link to an issue (except for internal cleanup and flaky test fixes), new features should have an RFC that documents use cases and test cases.

Tests

  • Bug fixes should have at least one unit or end-to-end test, enhancement and new features should have a sufficient number of tests.

Documentation

  • Apply the release notes (needs details) label if users need to know about this change.
  • New features should be documented.
  • There should be some code comments as to why things are implemented the way they are.
  • There should be a comment at the top of each new or modified test to explain what the test does.

New flags

  • Is this flag really necessary?
  • Flag names must be clear and intuitive, use dashes (-), and have a clear help text.

If a workflow is added or modified:

  • Each item in Jobs should be named in order to mark it as required.
  • If the workflow needs to be marked as required, the maintainer team must be notified.

Backward compatibility

  • Protobuf changes should be wire-compatible.
  • Changes to _vt tables and RPCs need to be backward compatible.
  • RPC changes should be compatible with vitess-operator
  • If a flag is removed, then it should also be removed from vitess-operator and arewefastyet, if used there.
  • vtctl command output order should be stable and awk-able.

@vitess-bot vitess-bot Bot added NeedsDescriptionUpdate The description is not clear or comprehensive enough, and needs work NeedsIssue A linked issue is missing for this Pull Request NeedsBackportReason If backport labels have been applied to a PR, a justification is required labels Jun 15, 2026
@netliomax25-code

Copy link
Copy Markdown
Contributor Author

any update?

@mattlord
mattlord requested a review from arthurschreiber June 29, 2026 13:01

@mattlord mattlord left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

TabletConnection still seems to read thc.Conn outside connMu. The PR moves Conn writes in closeConnection/finalizeConn under connMu, but HealthCheckImpl.TabletConnection still does the nil check with thc.Conn after releasing hc.mu and before taking connMu (go/vt/discovery/healthcheck.go:907). That leaves a remaining read/write race with the newly locked thc.Conn = nil writes in tablet_health_check.go, so the race fix is incomplete. It seems like we should also move the Conn nil check under connMu too, ideally via a small helper on tabletHealthCheck, and add a race regression that calls TabletConnection concurrently with stream close/finalize.

Thanks, @netliomax25-code !

@mattlord

Copy link
Copy Markdown
Member

@netliomax25-code We will also need a corresponding issue which lays out the problem we are fixing in this PR. Thanks!

Signed-off-by: Kartik Kenchi <netliomax25@gmail.com>
@netliomax25-code

Copy link
Copy Markdown
Contributor Author

Good catch, you're right that TabletConnection left a gap. Pushed a follow-up:

  1. Issue: HealthCheckImpl.TabletConnection did the thc.Conn nil check after releasing hc.mu and before taking connMu, so it raced the newly locked thc.Conn = nil writes in closeConnection/finalizeConn.
  2. Impact: the race fix was incomplete, go test -race still flagged a read/write on thc.Conn.
  3. Fix: added a small currentConnection() helper on tabletHealthCheck that reads thc.Conn under connMu without re-dialing, and TabletConnection now uses it for both the nil check and the returned conn.

Added TestTabletConnectionConcurrentWithStreamClose, which calls TabletConnection concurrently while the checkConn goroutine repeatedly closes and re-dials the connection on stream errors. It fails with WARNING: DATA RACE on the unpatched TabletConnection and passes with the helper; the full ./go/vt/discovery/ suite is green under -race.

Also opened #20419 to lay out the problem this PR fixes, and linked it in the description.

@mattlord mattlord added Type: Bug Component: Query Serving and removed NeedsDescriptionUpdate The description is not clear or comprehensive enough, and needs work NeedsWebsiteDocsUpdate What it says NeedsIssue A linked issue is missing for this Pull Request NeedsBackportReason If backport labels have been applied to a PR, a justification is required labels Jun 30, 2026
@netliomax25-code

Copy link
Copy Markdown
Contributor Author

gentle ping

@mhamza15

mhamza15 commented Jul 8, 2026

Copy link
Copy Markdown
Collaborator

An issue was created for this previously: #20325. I will close out mine in favor of yours, but for future reference, please look to see if there are any existing issues before creating new ones. Thanks!

Additionally, your issue does not follow the bug report template. Please update it to do so as well.

@mhamza15

mhamza15 commented Jul 8, 2026

Copy link
Copy Markdown
Collaborator

@netliomax25-code There are conflicts you'll need to resolve first.

Signed-off-by: Kartik Kenchi <netliomax25@gmail.com>

# Conflicts:
#	go/vt/discovery/healthcheck.go
#	go/vt/discovery/tablet_health_check.go
Copilot AI review requested due to automatic review settings July 9, 2026 09:01
@netliomax25-code

Copy link
Copy Markdown
Contributor Author

Sorry about the duplicate issue, I should have searched first. Both points addressed:

  1. Updated Bug Report: data race on tabletHealthCheck health fields in go/vt/discovery #20419 to follow the bug report template, with repro steps, binary version, environment details, and the actual -race output captured against main at f9463a3.
  2. Merged main to resolve the conflicts. Two hunks needed a bit of care: updateHealth now takes the tabletHealthCheck itself and calls SimpleCopy internally, so processResponse and the checkConn timeout branch pass thc but still pass the serving/target values captured while connMu was held. TabletConnection keeps the new registeredHealthCheck helper from main, with the connMu-guarded currentConnection read on top.

Re-ran go test -race -count=1 ./go/vt/discovery/ after the merge and everything passes, including the two regression tests.

Copilot AI 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.

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated no new comments.

Comments suppressed due to low confidence (1)

go/vt/discovery/tablet_health_check.go:120

  • setServingState's comment says logging happens from a separate goroutine to avoid holding the lock, but the code currently logs synchronously. With this PR making callers hold connMu around setServingState, the connMu mutex can now be held while logging, which can block concurrent health reads/updates if the logger output stalls. Consider capturing the values under the lock and logging asynchronously (or otherwise logging outside the critical section).
func (thc *tabletHealthCheck) setServingState(serving bool, reason string) {
	if !thc.loggedServingState || (serving != thc.Serving) {
		// Emit the log from a separate goroutine to avoid holding
		// the th lock while logging is happening
		thc.logger.Infof("HealthCheckUpdate(Serving State): tablet: %v serving %v => %v for %v/%v (%v) reason: %s",
			topotools.TabletIdent(thc.Tablet),
			thc.Serving,
			serving,
			thc.Tablet.GetKeyspace(),
			thc.Tablet.GetShard(),
			thc.Target.GetTabletType(),
			reason,
		)

Copilot AI 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.

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated no new comments.

@timvaillancourt

Copy link
Copy Markdown
Contributor

Thanks for this fix! I verified the new tests fail under -race without the fix and pass with it, and the full ./go/vt/discovery/ suite is green. Lock ordering looks correct.

A few small things, none blocking:

  1. The "Emit the log from a separate goroutine" comment in setServingState is now misleading, since the log runs synchronously under connMu. Worth fixing while you're updating that function's comments.
  2. serving = thc.Serving after setServingState(serving, reason) is a no-op, you can just pass serving.
  3. The "unblock the writer" drains in both tests are dead code, the writers already select on <-stop.
  4. The checklist says backport labels were added but there are none. Either add them or uncheck that box.

…d test drains

Signed-off-by: Kartik Kenchi <netliomax25@gmail.com>
Copilot AI review requested due to automatic review settings July 23, 2026 09:21
@netliomax25-code

Copy link
Copy Markdown
Contributor Author

All four addressed:

  1. Dropped the "separate goroutine" comment in setServingState. The log has run synchronously for a while and now runs under connMu, so the comment was just wrong.
  2. Removed the no-op serving = thc.Serving and pass serving straight through to updateHealth.
  3. Removed the drains in both tests, you're right that the writers already exit via <-stop.
  4. Unchecked the backport box since no labels are set. I can't add labels myself, so I'll leave whether this should go to release branches up to you.

Re-ran the two regression tests and the full ./go/vt/discovery/ suite under -race after the change, all green.

Copilot AI 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.

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.

Comment thread go/vt/discovery/tablet_health_check.go Outdated
Comment on lines 107 to 108
// thc.connMu must be locked before calling this function.
func (thc *tabletHealthCheck) setServingState(serving bool, reason string) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This seems like a valid point to me. No?

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.

Yes, it's a valid point, fixed in e6f0b52:

  1. Issue: with the log inside the connMu section, a CallbackLogger callback that re-enters the healthcheck (GetTabletHealthByAlias, TabletConnection) would try to take connMu again and deadlock the checkConn goroutine.
  2. Fix: setServingState now formats the message while holding connMu and returns a log function, and all four call sites invoke it right after unlocking. The state update and the log decision stay protected, only the logger call moved outside the lock.
  3. Added TestHealthCheckReentrantLoggerCallback, which installs a CallbackLogger that calls GetTabletHealthByAlias from inside the callback. On the previous revision it deadlocks and times out, with this change it passes, and the full ./go/vt/discovery/ suite is green under -race.

@codecov

codecov Bot commented Jul 23, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 69.64%. Comparing base (70c7a72) to head (b26019a).
⚠️ Report is 487 commits behind head on main.

Additional details and impacted files
@@             Coverage Diff             @@
##             main   #20319       +/-   ##
===========================================
- Coverage   69.67%   69.64%    -0.03%     
===========================================
  Files        1614       12     -1602     
  Lines      216793     1835   -214958     
===========================================
- Hits       151044     1278   -149766     
+ Misses      65749      557    -65192     
Flag Coverage Δ
partial 69.64% <100.00%> (?)

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

A CallbackLogger callback that re-enters the healthcheck (e.g. via
GetTabletHealthByAlias) would deadlock on connMu if setServingState
invoked the logger while holding it. setServingState now formats the
message under the lock and returns a log function that callers invoke
after unlocking.

Signed-off-by: Kartik Kenchi <netliomax25@gmail.com>
Copilot AI review requested due to automatic review settings August 16, 2026 21:06

Copilot AI 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.

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.

Suppressed comments (2)

go/vt/discovery/healthcheck_test.go:333

  • This goroutine spins in a tight loop due to the default case, which can peg a CPU core and make tests noisier/flakier under load. Consider adding a small backoff (e.g. runtime.Gosched() or a short time.Sleep) or driving the reads with a ticker/channel instead of a busy loop.
		for {
			select {
			case <-stop:
				return
			default:
				_, _ = hc.GetTabletHealthByAlias(tablet.Alias)
			}
		}

go/vt/discovery/healthcheck_test.go:398

  • This is also a tight busy loop via default, which can unnecessarily consume CPU during the 200ms test window. Add a small yield/backoff or use a ticker to reduce contention while still exercising the race scenario.
		for {
			select {
			case <-stop:
				return
			default:
				_, _ = hc.TabletConnection(ctx, tablet.Alias, nil)
			}
		}

if logServingChange != nil {
logServingChange()
}
_ = conn.Close(ctx)

@mattlord mattlord left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I don’t see any remaining correctness or performance issues at the latest HEAD. The open nil-connection concern does not seem reachable: closeConnection is only called after Connection returned a non-nil connection and that connection’s StreamHealth failed. finalizeConn runs later on the same goroutine, and no other path clears Conn.

One small non-blocking nit: the connMu comment still says it protects only Conn, but it now protects all mutable health fields. I think we should update that comment to document the synchronization contract. Otherwise, this looks good to me.

Comment thread go/vt/discovery/healthcheck.go Outdated
return thc.Connection(ctx), nil
conn := thc.currentConnection()
if conn == nil {
return nil, vterrors.Errorf(vtrpc.Code_NOT_FOUND, "tablet: %v is either down or nonexistent", alias)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

IMO we should not have identical errors for different cases. Can we at least add something in parens to indicate that there was no connection?

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 b26019a, the no-connection case now says "is either down or nonexistent (no health check connection)" so the two failures are distinguishable. The e2e assertion in vtgate_test.go matches on the shared substring, so it still passes.

Signed-off-by: Kartik Kenchi <netliomax25@gmail.com>
Copilot AI review requested due to automatic review settings August 18, 2026 10:49
@netliomax25-code

Copy link
Copy Markdown
Contributor Author

Both points from the review addressed in b26019a:

  1. The connMu comment now documents the full contract: it protects Conn plus the mutable health fields (Target, Serving, PrimaryTermStartTime, Stats, LastError), and must not be held across network IO or logger calls.
  2. The nil-connection error in TabletConnection now carries a "(no health check connection)" suffix to distinguish it from the unregistered-tablet case.

Agreed on the nil Conn concern being unreachable in closeConnection, so I left that as is. Re-ran the full ./go/vt/discovery/ suite under -race after the change, all green.

Copilot AI 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.

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.

Suppressed comments (3)

go/vt/discovery/tablet_health_check.go:171

  • connectionLocked performs dialing (tabletconn.GetDialer()(...)) in a function that is intended to be called while connMu is held (per naming and current call patterns). That directly conflicts with the new connMu contract comment stating it must not be held across network IO/dialing, and can cause head-of-line blocking (and potentially deadlocks if dialing/logging paths re-enter). Refactor to release connMu before dialing, then re-lock to publish thc.Conn (double-checking whether another goroutine already set it), and close any redundant connection outside the lock.
func (thc *tabletHealthCheck) connectionLocked(ctx context.Context) queryservice.QueryService {
	if thc.Conn == nil {
		conn, err := tabletconn.GetDialer()(ctx, thc.Tablet, grpcclient.FailFast(true))

go/vt/discovery/healthcheck.go:925

  • This changes TabletConnection semantics from 'return (and potentially establish) a connection' (previously thc.Connection(ctx)) to 'only return an already-established connection'. That can break callers by returning NOT_FOUND during transient periods (e.g., right after AddTablet, during redial, or before the first stream is up). If the API is expected to provide a usable connection when possible, consider keeping the dialing behavior and fixing the underlying synchronization (e.g., by making dialing happen outside connMu, per the connMu contract). If the new behavior is desired, consider updating the error code/message to distinguish 'temporarily unavailable' from 'nonexistent'.
func (hc *HealthCheckImpl) TabletConnection(ctx context.Context, alias *topodata.TabletAlias, target *query.Target) (queryservice.QueryService, error) {
	thc := hc.registeredHealthCheck(alias)
	if thc == nil {
		return nil, vterrors.Errorf(vtrpc.Code_NOT_FOUND, "tablet: %v is either down or nonexistent", alias)
	}
	conn := thc.currentConnection()
	if conn == nil {
		return nil, vterrors.Errorf(vtrpc.Code_NOT_FOUND, "tablet: %v is either down or nonexistent (no health check connection)", alias)
	}
	return conn, nil

go/vt/discovery/healthcheck_test.go:337

  • These test goroutines busy-spin on the default case, potentially pegging a CPU core during the sleep window and making CI noisier/slower. Consider adding a small backoff (e.g., runtime.Gosched() or a short time.Sleep) or using a ticker/channel-driven loop to reduce CPU usage (same pattern also appears in the TabletConnection reader loop).
	go func() {
		defer wg.Done()
		for {
			select {
			case <-stop:
				return
			default:
				_, _ = hc.GetTabletHealthByAlias(tablet.Alias)
			}
		}
	}()

Comment thread go/vt/discovery/tablet_health_check.go
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Bug Report: data race on tabletHealthCheck health fields in go/vt/discovery

5 participants