Do not open the circuit breaker when only one host failed - #267
Do not open the circuit breaker when only one host failed#267justinpersaud wants to merge 13 commits into
Conversation
A call counted toward the breaker whenever it exhausted its own candidate host list, and the blacklist routinely narrows that list to a single host, so one dead node was indistinguishable from a dead cluster. On 2026-08-04 a single dead auth node (db1.sjc) opened the breaker on 8 web boxes while the other 5 nodes were healthy and db2.sjc had been leading for 5 minutes. Because call() checks the breaker before host selection, the ~880 requests that failed fast never tried a healthy node. Of the 1,432 requests that threw, 1,321 had failed against exactly one host. Blacklisting already routes around a single bad host, so only count a failure once the call failed against more than one distinct host. Capped at the number of configured hosts so a single-host cluster still trips.
4ee9a82 to
f4fc3e0
Compare
A cluster keeps its leader while a majority of full peers are reachable (numLoggedInFullPeers * 2 >= numFullPeers), so auth's 5 full nodes plus one permafollower still serve with 3 nodes up. Failing against 2 hosts therefore proves nothing: it happens when the blacklist has narrowed a call's candidate list to two dead hosts while the cluster is fine, which 75 requests hit on 2026-08-04.
iwiznia
left a comment
There was a problem hiding this comment.
Have to be honest, I am not sure what the circuit breaker is doing or is supposed to be doing...
Like if one command is bad and gets attempted in all hosts and fails, it will stop processing all requests?
| $rawRequest .= $body; | ||
|
|
||
| $response = null; | ||
| $this->failedHosts = []; |
There was a problem hiding this comment.
Probably better in getPossibleHosts?
There was a problem hiding this comment.
Why is that? Happy to move but not sure why
| return; | ||
| } | ||
| // A cluster only loses its leader once 3 nodes are unreachable; below that, blacklisting routes around them. | ||
| if (count($this->failedHosts) < min(3, count($this->mainHostConfigs + $this->failoverHostConfigs))) { |
There was a problem hiding this comment.
Why are you adding main + failovers? That count will be 8 in production.. I think you only want failoverHostConfigs here.
There was a problem hiding this comment.
This is an array union, so it doesn't add both together. It should still be 6.
There was a problem hiding this comment.
Oh, TIL that's how it works
| if ($this->circuitBreakerThreshold <= 0 || !$this->isApcuAvailable()) { | ||
| return; | ||
| } | ||
| // A cluster only loses its leader once 3 nodes are unreachable; below that, blacklisting routes around them. |
There was a problem hiding this comment.
Not sure I get this, we can have a cluster with 3 nodes and have 6 in total, so with 3 nodes down (and 3 up) we have a working cluster.
Shouldn't we be circuit breaking when all hosts are down?
There was a problem hiding this comment.
fair, i will move it to all hosts
The goal of the circuit breaker is to stop creating new requests up to the readTimeout when the auth cluster cannot handle any additional requests due to whatever reason. It prevents overwhelming an overloaded cluster during fires. It is not one bad command, but rather, 10 failures. Any success within that clears it. |
…ing it Failing against 3 hosts assumed auth's node count and still left hosts that could serve the call. Require every configured host to have failed instead, which is the condition the breaker exists for and needs no per-cluster tuning. Timeouts are never retried on another host, so they can never fail against all of them. They bypass the check and count on their own, keeping the behaviour from Bedrock-PHP #266.
|
We have a tag conflict here now from another PR by @Valforte. I will have to move this over to 2.3.7.. and then we skip 2.3.6 I guess? |
|
actually since this one was already here first @Valforte I am going to use the 2.3.6 tag here and you can use 2.3.7 on yours when it is ready |
|
ok, make sure that the tag points to the correct commit when you create it. |
|
Oh you already created it and it's pointing to the correct commit... |
|
Yeah we should really just add the tag automation to this repo |
flodnv
left a comment
There was a problem hiding this comment.
The comments are more confusing than the code 😅
| try { | ||
| $response = $this->doCall($method, $headers, $body); | ||
| } catch (TimeoutError $e) { | ||
| // A timeout doesn't mark the host as failed, so it records none and could never pass the check below. |
There was a problem hiding this comment.
What? This seems like an AI comment that taken out of its context, is meaningless (and very confusing)
| if ($this->circuitBreakerThreshold <= 0 || !$this->isApcuAvailable()) { | ||
| return; | ||
| } | ||
| // While any host can still serve the call, blacklisting the bad ones is enough. |
There was a problem hiding this comment.
I also don't understand this comment. Why is it talking about blacklisting, when what we're doing is an early return?
There was a problem hiding this comment.
Blacklisting prevents requests to a host that have failed for some reason already, but the early return here is because there are still candidate hosts that can serve the request and we don't want to prevent a request from going to them
There was a problem hiding this comment.
To be honest, I don't think I fully understand this PR. Does it mean that if we get 10 different command timeouts in 1 minute, the circuit breaker will kick in? So if a user causes 10 timeouts in 1 minute, the circuit breaker will kick in and prevent all other requests? If so, that feels off 🤔
Co-authored-by: Florent De'Neve <florent@expensify.com>
Co-authored-by: Florent De'Neve <florent@expensify.com>
Yes, but this behaviour was introduced in the other PR Ionatan made regarding timeouts. Also, this assumes zero requests succeed in that time period. Remember, any successful request resets the counter. |
|
We may not need this PR after all. I misdiagnosed the original issue I was looking at earlier in the week. Details here https://expensify.slack.com/archives/C05CBC62HGW/p1786125723436259?thread_ts=1783698356.886189&cid=C05CBC62HGW If there are no issues over the weekend with the LAX maintenance, then I think we just close this. |
|
No issues, so closing this as not needed |
|
(noting I will delete the tags now too) |
Explanation of Change
The circuit breaker opened when a single node was down. We don't need the breaker to jump in here, only when many hosts are down or the cluster isn't responding.
Related Issues
For #258
For https://github.com/Expensify/Expensify/issues/657702
Tests
AI tests below
No automated suite in this repo, so I ran a manual script in the Expensidev VM (dead loopback ports for unreachable nodes, local Bedrock on 8888 for healthy ones). 6/6:
Phase 1 is the 2026-08-04 shape and fails on
main. Happy to attach the script.Deployment
Version bump and tag still to come.