Skip to content

Commit 57f6853

Browse files
authored
fix: SBR threshold for down-all-when-indirectly-connected (#32875)
Threshold for when all are downed instead of only the detected indirectly connected, 50% enabled by default
1 parent d3ea481 commit 57f6853

File tree

12 files changed

+469
-150
lines changed

12 files changed

+469
-150
lines changed

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,3 +106,5 @@ native/
106106

107107
# attachments created by scripts/prepare-downloads.sh
108108
akka-docs/src/main/paradox/attachments
109+
110+
.claude

akka-cluster/src/main/resources/reference.conf

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -426,10 +426,18 @@ akka.cluster.split-brain-resolver {
426426
# In a malfunctioning network there can be situations where nodes are observed as unreachable
427427
# via some network links, but they are still indirectly connected via other nodes, i.e. it's
428428
# not a clean network partition.
429-
# By default it will keep fully connected nodes and down all the indirectly connected nodes,
430-
# but when this flag is enabled it will down all nodes as precaution for the possible
431-
# uncertainty that indirectly connected nodes can cause.
432-
down-all-when-indirectly-connected = off
429+
# By default it will keep fully connected nodes and down all the indirectly connected nodes.
430+
#
431+
# However, there is a risk with asymmetric partitions. As a safeguard, if a
432+
# DownIndirectlyConnected decision would down more than a certain
433+
# fraction of the cluster members, SBR will instead down all nodes.
434+
#
435+
# The value can be 'on', 'off', or a double threshold between 0.0 and 1.0:
436+
# - 'on': Always down all when there are indirectly connected
437+
# - 'off': Disables the safeguard entirely
438+
# - A double value: if more or equal to this fraction of members
439+
# would be downed by a DownIndirectlyConnected decision, down all instead
440+
down-all-when-indirectly-connected = 0.5
433441

434442
}
435443
#//#split-brain-resolver

akka-cluster/src/main/scala/akka/cluster/sbr/SplitBrainResolver.scala

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -435,16 +435,33 @@ import akka.remote.artery.ThisActorSystemQuarantinedEvent
435435
*/
436436
def actOnDecision(decision: Decision): Set[UniqueAddress] = {
437437
val nodesToDown =
438-
if (settings.DownAllWhenIndirectlyConnected && decision.isIndirectlyConnected) {
439-
strategy.nodesToDown(DownAll)
440-
} else {
441-
try {
442-
strategy.nodesToDown(decision)
443-
} catch {
444-
case e: IllegalStateException =>
445-
log.warning(e.getMessage)
438+
try {
439+
val nodes = strategy.nodesToDown(decision)
440+
// Safeguard for indirectly connected decisions:
441+
// If the decision would down more than a threshold fraction of members,
442+
// convert to DownAll to prevent split brain from stale gossip in asymmetric partitions.
443+
if (decision.isIndirectlyConnected) {
444+
val threshold = settings.DownAllWhenIndirectlyConnectedThreshold
445+
val memberCount = strategy.members.size
446+
if (memberCount > 0 && threshold < 1.0 && nodes.size.toDouble / memberCount >= threshold) {
447+
log.warning(
448+
ClusterLogMarker.sbrDowning(decision),
449+
"SBR indirectly connected decision would down [{}] of [{}] members which is more than " +
450+
"the configured down-all-when-indirectly-connected threshold of [{}], downing all instead",
451+
nodes.size,
452+
memberCount,
453+
settings.DownAllWhenIndirectlyConnectedThreshold)
446454
strategy.nodesToDown(DownAll)
455+
} else {
456+
nodes
457+
}
458+
} else {
459+
nodes
447460
}
461+
} catch {
462+
case e: IllegalStateException =>
463+
log.warning(e.getMessage)
464+
strategy.nodesToDown(DownAll)
448465
}
449466

450467
observeDecision(decision, nodesToDown, unreachableDataCenters)

akka-cluster/src/main/scala/akka/cluster/sbr/SplitBrainResolverSettings.scala

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -55,10 +55,10 @@ import akka.util.Helpers.Requiring
5555
val DownAllWhenUnstable: FiniteDuration = {
5656
val key = "down-all-when-unstable"
5757
Helpers.toRootLowerCase(cc.getString("down-all-when-unstable")) match {
58-
case "on" =>
58+
case "on" | "true" =>
5959
// based on stable-after
6060
4.seconds.max(DowningStableAfter * 3 / 4)
61-
case "off" =>
61+
case "off" | "false" =>
6262
// disabled
6363
Duration.Zero
6464
case _ =>
@@ -67,8 +67,20 @@ import akka.util.Helpers.Requiring
6767
}
6868
}
6969

70-
val DownAllWhenIndirectlyConnected: Boolean =
71-
cc.getBoolean("down-all-when-indirectly-connected")
70+
val DownAllWhenIndirectlyConnectedThreshold: Double = {
71+
val key = "down-all-when-indirectly-connected"
72+
Helpers.toRootLowerCase(cc.getString(key)) match {
73+
case "on" | "true" =>
74+
0.0
75+
case "off" | "false" =>
76+
1.0
77+
case _ =>
78+
val value = cc.getDouble(key)
79+
if (value < 0.0 || value > 1.0)
80+
throw new ConfigurationException(s"$key must be 'on', 'off', or a double between 0.0 and 1.0, was [$value]")
81+
value
82+
}
83+
}
7284

7385
// the individual sub-configs below should only be called when the strategy has been selected
7486

akka-cluster/src/multi-jvm/scala/akka/cluster/sbr/DownAllIndirectlyConnected5NodeSpec.scala

Lines changed: 0 additions & 125 deletions
This file was deleted.

akka-cluster/src/multi-jvm/scala/akka/cluster/sbr/DownAllIndirectlyConnected3NodeSpec.scala renamed to akka-cluster/src/multi-jvm/scala/akka/cluster/sbr/DownAllWhenIndirectlyConnected3NodeSpec.scala

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import akka.cluster.MultiNodeClusterSpec
1414
import akka.remote.testkit.Direction
1515
import akka.remote.testkit.MultiNodeConfig
1616

17-
object DownAllIndirectlyConnected3NodeSpec extends MultiNodeConfig {
17+
object DownAllWhenIndirectlyConnected3NodeSpec extends MultiNodeConfig {
1818
val node1 = role("node1")
1919
val node2 = role("node2")
2020
val node3 = role("node3")
@@ -40,12 +40,12 @@ object DownAllIndirectlyConnected3NodeSpec extends MultiNodeConfig {
4040
testTransport(on = true)
4141
}
4242

43-
class DownAllIndirectlyConnected3NodeSpecMultiJvmNode1 extends DownAllIndirectlyConnected3NodeSpec
44-
class DownAllIndirectlyConnected3NodeSpecMultiJvmNode2 extends DownAllIndirectlyConnected3NodeSpec
45-
class DownAllIndirectlyConnected3NodeSpecMultiJvmNode3 extends DownAllIndirectlyConnected3NodeSpec
43+
class DownAllWhenIndirectlyConnected3NodeSpecMultiJvmNode1 extends DownAllWhenIndirectlyConnected3NodeSpec
44+
class DownAllWhenIndirectlyConnected3NodeSpecMultiJvmNode2 extends DownAllWhenIndirectlyConnected3NodeSpec
45+
class DownAllWhenIndirectlyConnected3NodeSpecMultiJvmNode3 extends DownAllWhenIndirectlyConnected3NodeSpec
4646

47-
class DownAllIndirectlyConnected3NodeSpec extends MultiNodeClusterSpec(DownAllIndirectlyConnected3NodeSpec) {
48-
import DownAllIndirectlyConnected3NodeSpec._
47+
class DownAllWhenIndirectlyConnected3NodeSpec extends MultiNodeClusterSpec(DownAllWhenIndirectlyConnected3NodeSpec) {
48+
import DownAllWhenIndirectlyConnected3NodeSpec._
4949

5050
"A 3-node cluster with down-all-when-indirectly-connected=on" should {
5151
"down all when two unreachable but can talk via third" in {
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
/*
2+
* Copyright (C) 2020-2025 Lightbend Inc. <https://www.lightbend.com>
3+
*/
4+
5+
package akka.cluster.sbr
6+
7+
import scala.concurrent.duration._
8+
9+
import com.typesafe.config.ConfigFactory
10+
11+
import akka.cluster.Cluster
12+
import akka.cluster.MemberStatus
13+
import akka.cluster.MultiNodeClusterSpec
14+
import akka.remote.testkit.Direction
15+
import akka.remote.testkit.MultiNodeConfig
16+
17+
object DownAllWhenIndirectlyConnected5NodeSpec extends MultiNodeConfig {
18+
val node1 = role("node1")
19+
val node2 = role("node2")
20+
val node3 = role("node3")
21+
val node4 = role("node4")
22+
val node5 = role("node5")
23+
24+
commonConfig(ConfigFactory.parseString("""
25+
akka {
26+
loglevel = INFO
27+
cluster {
28+
downing-provider-class = "akka.cluster.sbr.SplitBrainResolverProvider"
29+
split-brain-resolver.active-strategy = keep-majority
30+
split-brain-resolver.stable-after = 6s
31+
# default down-all-when-indirectly-connected = 0.5 (50% threshold)
32+
33+
run-coordinated-shutdown-when-down = off
34+
}
35+
36+
actor.provider = cluster
37+
38+
test.filter-leeway = 10s
39+
}
40+
"""))
41+
42+
testTransport(on = true)
43+
}
44+
45+
class DownAllWhenIndirectlyConnected5NodeSpecMultiJvmNode1 extends DownAllWhenIndirectlyConnected5NodeSpec
46+
class DownAllWhenIndirectlyConnected5NodeSpecMultiJvmNode2 extends DownAllWhenIndirectlyConnected5NodeSpec
47+
class DownAllWhenIndirectlyConnected5NodeSpecMultiJvmNode3 extends DownAllWhenIndirectlyConnected5NodeSpec
48+
class DownAllWhenIndirectlyConnected5NodeSpecMultiJvmNode4 extends DownAllWhenIndirectlyConnected5NodeSpec
49+
class DownAllWhenIndirectlyConnected5NodeSpecMultiJvmNode5 extends DownAllWhenIndirectlyConnected5NodeSpec
50+
51+
class DownAllWhenIndirectlyConnected5NodeSpec extends MultiNodeClusterSpec(DownAllWhenIndirectlyConnected5NodeSpec) {
52+
import DownAllWhenIndirectlyConnected5NodeSpec._
53+
54+
"A 5-node cluster with down-all-when-indirectly-connected threshold" should {
55+
"down all when indirectly connected cycle involves 3 of 5 nodes (60% > 50% threshold)" in {
56+
// This test creates an indirect connection cycle involving nodes 1, 2, 3:
57+
// node1 <-> node2: blocked
58+
// node2 <-> node3: blocked
59+
// node3 <-> node1: blocked
60+
// Nodes 4 and 5 can still reach everyone, so gossip flows via them.
61+
//
62+
// This creates a cycle in the unreachability graph where nodes 1, 2, 3 are both
63+
// observers and subjects. The DownIndirectlyConnected decision would down all 3,
64+
// which is 60% of the cluster - exceeding the 50% threshold.
65+
// Therefore, SBR should down ALL nodes instead.
66+
67+
val cluster = Cluster(system)
68+
69+
runOn(node1) {
70+
cluster.join(cluster.selfAddress)
71+
}
72+
enterBarrier("node1 joined")
73+
runOn(node2, node3, node4, node5) {
74+
cluster.join(node(node1).address)
75+
}
76+
within(10.seconds) {
77+
awaitAssert {
78+
cluster.state.members.size should ===(5)
79+
cluster.state.members.foreach {
80+
_.status should ===(MemberStatus.Up)
81+
}
82+
}
83+
}
84+
enterBarrier("Cluster formed")
85+
86+
// Create indirect connection cycle: node1 <-> node2 <-> node3 <-> node1
87+
// All connections between these three nodes are blocked, but they can still
88+
// communicate via node4 and node5
89+
runOn(node1) {
90+
testConductor.blackhole(node1, node2, Direction.Both).await
91+
testConductor.blackhole(node2, node3, Direction.Both).await
92+
testConductor.blackhole(node3, node1, Direction.Both).await
93+
}
94+
enterBarrier("blackholed-indirectly-connected-cycle")
95+
96+
// All nodes should be downed because the indirectly connected set (3 nodes)
97+
// exceeds the 50% threshold
98+
runOn(node1, node2, node3, node4, node5) {
99+
awaitCond(cluster.isTerminated, max = 15.seconds)
100+
}
101+
102+
enterBarrier("done")
103+
}
104+
105+
}
106+
107+
}

akka-cluster/src/multi-jvm/scala/akka/cluster/sbr/IndirectlyConnected3NodeSpec.scala

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ object IndirectlyConnected3NodeSpec extends MultiNodeConfig {
2626
downing-provider-class = "akka.cluster.sbr.SplitBrainResolverProvider"
2727
split-brain-resolver.active-strategy = keep-majority
2828
split-brain-resolver.stable-after = 6s
29+
split-brain-resolver.down-all-when-indirectly-connected = off
2930
3031
run-coordinated-shutdown-when-down = off
3132
}

akka-cluster/src/multi-jvm/scala/akka/cluster/sbr/IndirectlyConnected5NodeSpec.scala

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ object IndirectlyConnected5NodeSpec extends MultiNodeConfig {
2828
downing-provider-class = "akka.cluster.sbr.SplitBrainResolverProvider"
2929
split-brain-resolver.active-strategy = keep-majority
3030
split-brain-resolver.stable-after = 6s
31+
split-brain-resolver.down-all-when-indirectly-connected = off
3132
3233
run-coordinated-shutdown-when-down = off
3334
}

0 commit comments

Comments
 (0)