Skip to content

Do not open the circuit breaker when only one host failed - #267

Closed
justinpersaud wants to merge 13 commits into
mainfrom
jpersaud-breaker-cluster-threshold
Closed

Do not open the circuit breaker when only one host failed#267
justinpersaud wants to merge 13 commits into
mainfrom
jpersaud-breaker-cluster-threshold

Conversation

@justinpersaud

@justinpersaud justinpersaud commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

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:

 [PASS] 1: one failed host never opens it, failover still serves
 [PASS] 2: two failed hosts never open it
 [PASS] 3: three failed hosts open it at the threshold
 [PASS] 4: a dead cluster opens it at the threshold
 [PASS] 5: one dead host IS the whole cluster, so it opens
 [PASS] 6: a healthy call clears both keys

Phase 1 is the 2026-08-04 shape and fails on main. Happy to attach the script.

Deployment

  • I followed the steps in the README to ensure this PR is deployed properly

Version bump and tag still to come.

@justinpersaud justinpersaud self-assigned this Aug 4, 2026
@justinpersaud
justinpersaud marked this pull request as ready for review August 4, 2026 17:23
@justinpersaud justinpersaud changed the title Trip the circuit breaker only when the cluster lost quorum Do not open the circuit breaker when only one host failed Aug 5, 2026
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.
@justinpersaud
justinpersaud force-pushed the jpersaud-breaker-cluster-threshold branch from 4ee9a82 to f4fc3e0 Compare August 5, 2026 15:45
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.
@justinpersaud
justinpersaud requested review from flodnv and iwiznia August 5, 2026 17:45

@iwiznia iwiznia 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.

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?

Comment thread src/Client.php
$rawRequest .= $body;

$response = null;
$this->failedHosts = [];

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.

Probably better in getPossibleHosts?

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.

Why is that? Happy to move but not sure why

Comment thread src/Client.php Outdated
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))) {

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.

Why are you adding main + failovers? That count will be 8 in production.. I think you only want failoverHostConfigs here.

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.

This is an array union, so it doesn't add both together. It should still be 6.

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.

Oh, TIL that's how it works

Comment thread src/Client.php Outdated
if ($this->circuitBreakerThreshold <= 0 || !$this->isApcuAvailable()) {
return;
}
// A cluster only loses its leader once 3 nodes are unreachable; below that, blacklisting routes around them.

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.

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?

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.

fair, i will move it to all hosts

@justinpersaud

Copy link
Copy Markdown
Contributor Author

I am not sure what the circuit breaker is doing or is supposed to be doing...

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.
iwiznia
iwiznia previously approved these changes Aug 6, 2026
@justinpersaud

Copy link
Copy Markdown
Contributor Author

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?

@justinpersaud

Copy link
Copy Markdown
Contributor Author

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

@justinpersaud
justinpersaud requested a review from iwiznia August 6, 2026 21:33
iwiznia
iwiznia previously approved these changes Aug 6, 2026
@iwiznia

iwiznia commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

ok, make sure that the tag points to the correct commit when you create it.

@iwiznia

iwiznia commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Oh you already created it and it's pointing to the correct commit...

@justinpersaud

Copy link
Copy Markdown
Contributor Author

Yeah we should really just add the tag automation to this repo

@flodnv flodnv 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.

The comments are more confusing than the code 😅

Comment thread src/Client.php Outdated
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.

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.

What? This seems like an AI comment that taken out of its context, is meaningless (and very confusing)

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.

rewording it

Comment thread src/Client.php Outdated
if ($this->circuitBreakerThreshold <= 0 || !$this->isApcuAvailable()) {
return;
}
// While any host can still serve the call, blacklisting the bad ones is enough.

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.

I also don't understand this comment. Why is it talking about blacklisting, when what we're doing is an early return?

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.

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

@flodnv flodnv 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.

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 🤔

Comment thread src/Client.php Outdated
Comment thread src/Client.php Outdated
justinpersaud and others added 2 commits August 7, 2026 09:23
Co-authored-by: Florent De'Neve <florent@expensify.com>
Co-authored-by: Florent De'Neve <florent@expensify.com>
@justinpersaud

Copy link
Copy Markdown
Contributor Author

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?

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.

@justinpersaud

Copy link
Copy Markdown
Contributor Author

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.

@justinpersaud

Copy link
Copy Markdown
Contributor Author

No issues, so closing this as not needed

@justinpersaud

Copy link
Copy Markdown
Contributor Author

(noting I will delete the tags now too)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants