Repository navigation
driver/redis: SentinelClient and ClusterClient no longer leak or panic when closed during a failover or a refresh - #1141
Merged
Conversation
…ster (Refs #1104) Two defects, both fail here and are fixed by the next commit. A Close that lands while dialMaster is in flight (its own timeout, not closeCh) leaves the new client installed on the closed SentinelClient, and open: 20 of 20 runs. A failed dial followed by a Close, with the retry pause over too, is a tie between closeCh and the timer, and the loop dialled the master again about half of the time: 52 of 100 runs. The first pause of handleSwitchMaster is a variable (sentinelSwitchBackoffBase) so the test can make it end at once; its default is the 100 ms it was.
…al for, a closed SentinelClient (Refs #1104) Under s.mu, look at the closed flag before storing the new client and close the client when it is set: Close sets the flag first, so either handleSwitchMaster sees it or Close finds the stored client and closes it. After the retry pause, a closed client wins a tie with the timer, so no further dial is made.
…se (Refs #1104) closeNodes leaves c.nodes nil. A refresh whose CLUSTER SLOTS reply came before Close and takes c.mu after it, and getOrCreateNode for a redirect, write into that nil map and end the process with a panic, after dialling a node nothing closes. Both fail here, 30 of 30 runs each; the next commit fixes them. refreshTopology is split so the part under c.mu (applyTopology) can run at the moment Close has just returned; no behaviour change.
… ErrClosed (Refs #1104) applyTopology and getOrCreateNode look at c.nodes under c.mu before they dial or write: closeNodes leaves it nil, and a write into it is a panic. getOrCreateNode also looks at the closed flag before it dials, and closes a client it dialled while Close ran.
|
Caution Review failedThe pull request is closed.
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
…h-master that runs after Close (Refs #1104) The cluster tests asserted that no node was dialled, which cannot fail: a Client dials lazily. They assert ErrClosed and that nothing was stored in c.nodes now. The part of getOrCreateNode under c.mu is split out as installNode (no behaviour change) and tested on its own, so removing its check turns a test red; the early closed check cannot be reached alone (without it installNode answers the same), and the test says so. handleSwitchMaster, called after Close with a switch message already queued (the pick watchFailover's select can make), dialled the new master once: the new test fails, 50 of 50 runs. The next commit fixes it.
…lient (Refs #1104) A switch message already queued when Close ran can still be picked by watchFailover's select, and the first dial of handleSwitchMaster had no look at the closed flag (the retry dial and the install did). Return at entry.
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
driver/redis: two Close races in
SentinelClientandClusterClient, both real (not flakes), found by the review of #1140. Refs #887 #1104Method: one commit pair per defect so each can be dropped. Commit 1 of each pair adds the test (and a behaviour-neutral seam) and FAILS; commit 2 is the fix. Tests use the package's fake Redis servers, no tag, so they also run in the "Portable fallback" jobs.
SentinelClient:handleSwitchMasterafterCloseac8f75atests + seam,ff1f376fix (the retry dial and the install)ClusterClient: refresh / redirect afterClosebfc657btests + seam,6a98787fixb784352tests (the entry test is red, the cluster tests are green and are mutant-checked) + theinstallNodesplit,784d60afix (the entry check)A.
handleSwitchMasterandClose(sentinel.go)Two defects in one function:
select { case <-time.After(delay): case <-s.closeCh: return }. WhenCloseand the timer are both ready, Go picks at random, so the loop dials the master again afterClose(at most twice more,maxRetriesis 3): a live connection, ROLE round trip included. The same shape as driver/internal/eventloop: RegisterConnWait stops at a done ctx even when its pause has ended too #1140.s.client = newClienthad no look ats.closed.dialMasterruns under its ownDialTimeout, notcloseCh, so aClosethat lands while it is in flight (it clearss.client, closes the old one, and returns without waiting for the goroutine) is followed by the install of a live*Clienton a closedSentinelClient. Nothing closes it:getClientanswersErrClosed, so it is never used either. A leaked client, its socket and its engine registration, until the process exits (a +switch-master during shutdown).Fix (commit
ff1f376, which covers the dial after a retry pause and the install, not the first dial; that is784d60abelow): after the pause,if s.closed.Load() { return }, so a closed client wins a tie; and unders.mu, before the store,if s.closed.Load()unlock, closenewClientand return.Closesets the flag before it takess.mu, so eitherhandleSwitchMastersees the flag, orClosetakess.muafter the store and closes the stored client.Third defect, found by the review of this PR:
handleSwitchMasterhad no closed check before its FIRST dial. A switch message already queued whenCloseruns can still be picked bywatchFailover's select (message vscloseCh, random when both are ready), so it ran afterCloseand made one dial (aROLErequest reaches the new master; the client was closed again, so no leak).784d60areturns at entry when the client is closed. Soff1f376makes no further dial after a retry pause and installs nothing; the entry check completes "no dial for a closed client".Seam:
sentinelSwitchBackoffBase(var, default the 100 ms that was a literal), the same pattern assentinelDownGrace, so the test can make the pause end at once.Tests (
sentinel_close_switch_1104_test.go): a master whose reply toROLEis held lets the test callCloseat the exact point, then releases it.TestSentinelCloseDuringSwitchMasterDialInstallsNothing1104: Close during the dial, 20 runs: no client installed on the closedSentinelClientand the dialled connection closed.TestSentinelSwitchMasterAfterCloseMakesNoDial1104(b784352):handleSwitchMastercalled afterClose, the state a lost pick leaves, 50 runs: noROLE, nothing installed.TestSentinelSwitchMasterDoesNotDialAfterClose1104: the master answersslaveso the dial fails, Close while it is held, pause 1 ns (a tie), 100 runs: exactly oneROLE.B. refresh / redirect after
Close(cluster.go)ClosecallscloseNodes, which leavesc.nodesnil. Two writers tookc.muafterwards and wrote into the nil map, which is a panic that ends the process (in the refresh goroutine):refreshTopology: when itsCLUSTER SLOTSDofinished beforeCloseand it tookc.muaftercloseNodes,c.nodes[addr] = n(and the replica one). It dials the node first, and nothing would close it. The window is microseconds, once per 60 s tick or per MOVED-triggered refresh.getOrCreateNodeAs(a MOVED/ASK redirect to an address not known yet): dials, thenc.nodes[addr] = n. The review named only the refresh; this is the same defect on the same write.Fix (commit 2):
applyTopologyreturnsErrClosedwhenc.nodes == nil, before it dials or writes.getOrCreateNodeAsreturnsErrClosedbefore dialling when the client is closed, and, underc.mu, closes the client it dialled and returnsErrClosedifc.nodesis nil (aClosethat ran during the dial). The refresh dials underc.mu, so aClosewaits for it and closes what it stored.Seam:
refreshTopologyis split in two;applyTopology(ctx, v)is its part underc.mu, no behaviour change, so a test runs it right after aClose.Seam: the part of
getOrCreateNodeAsunderc.muisinstallNode(no behaviour change).Tests (
cluster_close_refresh_1104_test.go): take a realCLUSTER SLOTSreply,Close, then callapplyTopology/getOrCreateNode/installNode; 30 runs each: no panic,ErrClosed, andc.nodesstill nil (nothing stored);installNodealso closes the client it was given, and its two other outcomes (stores a new address, returns the known node and closes the duplicate) are pinned on a live cluster. An earlier version asserted that no connection to the node was made; that could never fail (aClientdials lazily), so it is gone.Evidence
golang:1.27.3,--cpus 2, 8 MiB memlock,-race.ac8f75a),-count=5bfc657b),-count=20b784352),-count=20-count=20go test -race ./driver/redis, onceokMutants (each applied to the head, the named test then fails, restored): A without the check before the store: installed in 20 of 20; A without the check after the pause: dialled again in 55 of 100; A without the entry check: dialled in 50 of 50; B without the nil check in
applyTopology: 30 of 30 panics; B without the nil check ininstallNode: 30 of 30 panics (TestClusterInstallNode1104); B withinstallNodenot closing its client onErrClosed: 30 of 30 left open. Not covered, and not coverable on its own: the early closed check at the top ofgetOrCreateNodeAs. Removing it leaves every test green, because the node is then dialled (lazily, nothing observable) andinstallNodeanswersErrClosedwith the same result; it only saves building a client for a closed cluster. The test comment says so.Lint:
golangci-lint(CI pin v2.14 image) 0 issues;go veton linux, darwin and windows. The new tests also pass natively on macOS (-race -count=3).The same shapes elsewhere in the drivers
driver/redis/pubsub.goreconnect backoff vscloseCh, andsentinel.gosubscribeLoop: checkclosedafter the select / at the top of the loop, andsubscribeLoopre-checks undersentinelMubefore it lists a sentinel; nothing to change.driver/redis/cluster.goinitNodes/NewClusterClient: writesc.nodesbefore the client is shared; the failure path callscloseNodessingle-threaded.driver/redis/cluster.goother readers ofc.nodes(anyNode,ForEachNode,measureLatencyLocked): read a nil map, which is safe.driver/memcached/cluster.go:nodesis built once and never reassigned;Closestops and waits for the probe loop before it closes the clients, and a probe after the ctx is done fails at once. No instance.driver/internal/async/pool.go:Releasechecksclosedand closes the conn, andAcquirechecks it on entry; a dial in flight duringCloseis handed back throughRelease. No instance.driver/postgres: no map or client installed by a background goroutine afterClosethat I found; its select-on-timer sites are thefailAlltimeouts listed in driver/internal/eventloop: RegisterConnWait stops at a done ctx even when its pause has ended too #1140. I did not audit every postgres path.Not covered: only these files were read for the shapes; a path that installs state from a goroutine with no
closedcheck under its lock may still exist.Release note
Fixed:
SentinelClientno longer leaves a live connection behind when it is closed during a failover, andClusterClientno longer panics when it is closed while a topology refresh or a redirect is in flight.