fix(pubsub/eventhub): cap in-flight health probes at one to prevent goroutine leak - #4156
Umang01-hash wants to merge 4 commits into
Conversation
…oroutine leak Health() runs the connectivity probe on a goroutine so probeWithin can return on its own deadline; the parked goroutine stays blocked in the SDK call until it returns (GetEventHubProperties passes context.Background() to the AMQP RPC, so the caller deadline cannot bound it). Against a broker that accepts a connection but never answers, every health poll stranded another goroutine -- an unbounded leak on the hot health path. Cap concurrent probes at one with a TryLock: a poll that finds a probe already parked reports it (the broker is already unresponsive) instead of piling on another abandoned goroutine. The lock is released by the goroutine that ran the probe once the SDK call finally returns. Test parks a probe against a fake that never answers and asserts the SDK probe runs at most once across subsequent polls; reverting the cap makes it climb.
…efore publishing Add Test_Health_ProbeRecoversAfterParkedCallReturns: the cap test alone never lets its parked probe return, so a dropped Unlock -- which pins the client to a permanent false DOWN once it has probed once -- passed the whole suite. The new test parks a probe, releases it, and asserts the next poll runs a fresh probe and reports up; it goes red if the lock is not released. Release the lock before the goroutine publishes its result, so a caller that reads the result and immediately re-probes cannot find the lock still held by an already-finished probe. Closes the only window in which a healthy broker could report a spurious in-flight probe.
aryanmehrotra
left a comment
There was a problem hiding this comment.
The goroutine-leak fix itself is correct and the tests behind it are real. My one blocking concern is that the cap changes behaviour in a case the description says it doesn't.
What I verified
go build,go vet(incl. copylocks, sinceClientnow holds a mutex) — cleango test -race -count=1 ./...— passesgolangci-lint run --new-from-rev=origin/development— 0 issues, as claimed- Unlocking from a different goroutine than the one that locked is explicitly legal (
sync.Mutex: "A locked Mutex is not associated with a particular goroutine"), so that part is sound - Both new tests are load-bearing — I mutation-tested each in isolation:
- revert the cap (drop
TryLock+Unlock) →Test_Health_ParkedProbeIsCappedAtOnegoes red - drop only the
Unlock→Test_Health_ProbeRecoversAfterParkedCallReturnsgoes red
- revert the cap (drop
The central claim holds: at most one goroutine parks, regardless of poll rate.
Blocking: two concurrent polls against a healthy broker now report DOWN
Behaviour is unchanged in every healthy/normal-failure case; only the pathological "accepts-but-hangs" broker is now bounded.
That isn't quite right. The cap is per-Client, not per-broker-state — a second caller that arrives while a perfectly healthy probe is in flight gets errProbeInFlight and reports StatusDown.
Two simultaneous Health() calls, healthy broker, 100ms probe (well inside the 2s deadline):
| branch | result |
|---|---|
development |
up=2 down=0 |
| this PR | up=1 down=1 |
Reproducer:
func Test_ConcurrentPollsOnHealthyBroker(t *testing.T) {
client := newHealthTestClient(t, &mockConsumerClient{
getPropsFunc: func(context.Context,
*azeventhubs.GetEventHubPropertiesOptions) (azeventhubs.EventHubProperties, error) {
time.Sleep(100 * time.Millisecond) // healthy, but not instant
return azeventhubs.EventHubProperties{PartitionIDs: []string{"0"}}, nil
},
})
var wg sync.WaitGroup
var up int32
start := make(chan struct{})
for range 2 {
wg.Add(1)
go func() {
defer wg.Done()
<-start
if client.Health().Status == datasource.StatusUp {
atomic.AddInt32(&up, 1)
}
}()
}
close(start)
wg.Wait()
require.Equal(t, int32(2), atomic.LoadInt32(&up),
"a healthy broker must report UP to every concurrent poller")
}This is reachable in normal operation. Container.Health calls c.PubSub.Health() sequentially (pkg/gofr/container/health.go:35), so there's no intra-request concurrency — but /.well-known/health is an ordinary HTTP handler and Go serves each request on its own goroutine. Overlapping pollers (k8s liveness + readiness, a load balancer plus a monitor) hit this. The window is narrow, since a healthy probe is milliseconds, but the consequence on a liveness probe is a false DOWN and a pod restart — a false negative on the exact path whose job is to avoid them.
Suggested fix: share the in-flight probe rather than rejecting the caller
Let concurrent callers wait on the same probe's result, each still bounded by its own 2s deadline, instead of returning errProbeInFlight immediately. That keeps the goroutine cap exactly as it is — still one SDK call parked, still one goroutine — and removes the false DOWN outright, because a probe that finishes inside the deadline satisfies every waiter. golang.org/x/sync/singleflight is the idiomatic shape; a shared result channel swapped under a small mutex works too and avoids the dependency.
Worth keeping errProbeInFlight for the genuinely pathological case: a caller whose own deadline expires while the shared probe is still parked should still report down.
Minor: worth stating the recovery trade-off
If a parked probe never returns, health now reports DOWN permanently — even after the broker recovers, where pre-fix a fresh probe would have succeeded. Bounded-leak-then-restart is probably the better trade, but since it's a real change in failure behaviour it belongs in the description rather than being left implicit.
Problem
Health()runs the Event Hub connectivity probe on a goroutine soprobeWithincan return on its own deadline. That design is correct — the Azure SDK'sGetEventHubPropertiespassescontext.Background()to the AMQP RPC (mgmt.go) and the reply-wait selects on that context (internal/rpc.go), so the caller's deadline can't bound it; only selecting on our own deadline does.The cost, as the existing comment notes, is that an abandoned probe stays parked in the SDK call until it returns. Against a broker that accepts a connection but never answers, that call doesn't return promptly — so every health poll stranded another parked goroutine. Health checks are polled continuously (liveness,
/.well-known/health), so this is an unbounded goroutine leak on the hot path, and it compounds exactly when the broker is already degraded.Fix
Cap concurrent probes at one via
TryLock. A poll that finds a probe already parked reportserrProbeInFlight(the broker is already unresponsive, so reporting down is correct) instead of piling on another abandoned goroutine. The lock is released by the goroutine that ran the probe, once the SDK call finally returns — so at most one goroutine is ever parked, regardless of poll rate.Behaviour is unchanged in every healthy/normal-failure case: the probe still returns on its 2s deadline; only the pathological "accepts-but-hangs" broker is now bounded.
Test
Test_Health_ParkedProbeIsCappedAtOneparks a probe against a fake that blocks without readingctx(what the SDK does once a management link exists), then pollsHealth()repeatedly and asserts the SDK probe runs at most once while one is still parked. Reverting the cap makes the count climb — verified red.go test ./... -racepasses;golangci-lint --new-from-revreports 0 new issues.Follow-up to #3649.