Skip to content

Commit 75cb777

Browse files
Prevent loops and improve shortest paths perf (#1747)
Reverse the flow of yen's k-shortest path: go backwards like we do in dijkstra. Better tracking of already explored spur paths which improves performance (especially tail latency).
1 parent c6a76af commit 75cb777

File tree

2 files changed

+152
-41
lines changed

2 files changed

+152
-41
lines changed

eclair-core/src/main/scala/fr/acinq/eclair/router/Graph.scala

+35-41
Original file line numberDiff line numberDiff line change
@@ -98,49 +98,50 @@ object Graph {
9898
boundaries: RichWeight => Boolean): Seq[WeightedPath] = {
9999
// find the shortest path (k = 0)
100100
val targetWeight = RichWeight(amount, 0, CltvExpiryDelta(0), 0)
101-
val shortestPath = dijkstraShortestPath(graph, sourceNode, sourceNode, targetNode, ignoredEdges, ignoredVertices, extraEdges, targetWeight, boundaries, currentBlockHeight, wr)
101+
val shortestPath = dijkstraShortestPath(graph, sourceNode, targetNode, ignoredEdges, ignoredVertices, extraEdges, targetWeight, boundaries, currentBlockHeight, wr)
102102
if (shortestPath.isEmpty) {
103103
return Seq.empty // if we can't even find a single path, avoid returning a Seq(Seq.empty)
104104
}
105105

106+
case class PathWithSpur(p: WeightedPath, spurIndex: Int)
107+
implicit object PathWithSpurComparator extends Ordering[PathWithSpur] {
108+
override def compare(x: PathWithSpur, y: PathWithSpur): Int = y.p.weight.compare(x.p.weight)
109+
}
110+
106111
var allSpurPathsFound = false
107-
val shortestPaths = new mutable.Queue[WeightedPath]
108-
shortestPaths.enqueue(WeightedPath(shortestPath, pathWeight(sourceNode, shortestPath, amount, currentBlockHeight, wr)))
112+
val shortestPaths = new mutable.Queue[PathWithSpur]
113+
shortestPaths.enqueue(PathWithSpur(WeightedPath(shortestPath, pathWeight(sourceNode, shortestPath, amount, currentBlockHeight, wr)), 0))
109114
// stores the candidates for the k-th shortest path, sorted by path cost
110-
val candidates = new mutable.PriorityQueue[WeightedPath]
115+
val candidates = new mutable.PriorityQueue[PathWithSpur]
111116

112117
// main loop
113118
for (k <- 1 until pathsToFind) {
114119
if (!allSpurPathsFound) {
115-
val prevShortestPath = shortestPaths(k - 1).path
116-
// for every edge in the path, we will try to find a different path after that edge
117-
for (i <- prevShortestPath.indices) {
118-
// select the spur node as the i-th element of the previous shortest path
119-
val spurNode = prevShortestPath(i).desc.a
120-
// select the sub-path from the source to the spur node
121-
val rootPathEdges = prevShortestPath.take(i)
120+
val PathWithSpur(WeightedPath(prevShortestPath, _), spurIndex) = shortestPaths(k - 1)
121+
// for every new edge in the path, we will try to find a different path before that edge
122+
for (i <- spurIndex until prevShortestPath.length) {
123+
// select the spur node as the i-th element from the target of the previous shortest path
124+
val spurNode = prevShortestPath(prevShortestPath.length - 1 - i).desc.b
125+
// select the sub-path from the spur node to the target
126+
val rootPathEdges = prevShortestPath.takeRight(i)
122127
// we ignore all the paths that we have already fully explored in previous iterations
123-
// if for example the spur node is B, and we already found shortest paths starting with A-B-C and A-B-D,
124-
// we want to ignore the B-C and B-D edges
125-
// +-- C -- [...]
126-
// |
127-
// A -- B --+-- D -- [...]
128-
// |
129-
// +-- E -- [...]
130-
val alreadyExploredEdges = shortestPaths.collect { case p if p.path.take(i) == rootPathEdges => p.path(i).desc }.toSet
131-
// we also want to ignore any link that can lead back to the previous node (we only want to go forward)
132-
val returningEdges = rootPathEdges.lastOption.map(last => graph.getEdgesBetween(last.desc.b, last.desc.a).map(_.desc).toSet).getOrElse(Set.empty)
128+
// if for example the spur node is D, and we already found shortest paths ending with A->D->E and B->D->E,
129+
// we want to ignore the A->D and B->D edges
130+
// [...] --> A --+
131+
// |
132+
// [...] --> B --+--> D --> E
133+
// |
134+
// [...] --> C --+
135+
val alreadyExploredEdges = shortestPaths.collect { case p if p.p.path.takeRight(i) == rootPathEdges => p.p.path(p.p.path.length - 1 - i).desc }.toSet
136+
// we also want to ignore any vertex on the root path to prevent loops
137+
val alreadyExploredVertices = rootPathEdges.map(_.desc.b).toSet
138+
val rootPathWeight = pathWeight(sourceNode, rootPathEdges, amount, currentBlockHeight, wr)
133139
// find the "spur" path, a sub-path going from the spur node to the target avoiding previously found sub-paths
134-
val spurPath = dijkstraShortestPath(graph, sourceNode, spurNode, targetNode, ignoredEdges ++ alreadyExploredEdges ++ returningEdges, ignoredVertices, extraEdges, targetWeight, boundaries, currentBlockHeight, wr)
140+
val spurPath = dijkstraShortestPath(graph, sourceNode, spurNode, ignoredEdges ++ alreadyExploredEdges, ignoredVertices ++ alreadyExploredVertices, extraEdges, rootPathWeight, boundaries, currentBlockHeight, wr)
135141
if (spurPath.nonEmpty) {
136-
// candidate k-shortest path is made of the root path and the new spur path, but the cost of the spur
137-
// path is likely higher than previous shortest paths, so we need to validate that the root path can
138-
// relay the increased amount.
139-
val completePath = rootPathEdges ++ spurPath
142+
val completePath = spurPath ++ rootPathEdges
140143
val candidatePath = WeightedPath(completePath, pathWeight(sourceNode, completePath, amount, currentBlockHeight, wr))
141-
if (boundaries(candidatePath.weight) && !shortestPaths.contains(candidatePath) && !candidates.exists(_ == candidatePath) && validatePath(completePath, amount)) {
142-
candidates.enqueue(candidatePath)
143-
}
144+
candidates.enqueue(PathWithSpur(candidatePath, i))
144145
}
145146
}
146147
}
@@ -154,7 +155,7 @@ object Graph {
154155
}
155156
}
156157

157-
shortestPaths.toSeq
158+
shortestPaths.map(_.p).toSeq
158159
}
159160

160161
/**
@@ -163,8 +164,7 @@ object Graph {
163164
* graph @param g is optimized for querying the incoming edges given a vertex.
164165
*
165166
* @param g the graph on which will be performed the search
166-
* @param sender node sending the payment (may be different from sourceNode when calculating partial paths)
167-
* @param sourceNode the starting node of the path we're looking for
167+
* @param sourceNode the starting node of the path we're looking for (payer)
168168
* @param targetNode the destination node of the path
169169
* @param ignoredEdges channels that should be avoided
170170
* @param ignoredVertices nodes that should be avoided
@@ -175,7 +175,6 @@ object Graph {
175175
* @param wr ratios used to 'weight' edges when searching for the shortest path
176176
*/
177177
private def dijkstraShortestPath(g: DirectedGraph,
178-
sender: PublicKey,
179178
sourceNode: PublicKey,
180179
targetNode: PublicKey,
181180
ignoredEdges: Set[ChannelDesc],
@@ -222,7 +221,7 @@ object Graph {
222221
val neighbor = edge.desc.a
223222
// NB: this contains the amount (including fees) that will need to be sent to `neighbor`, but the amount that
224223
// will be relayed through that edge is the one in `currentWeight`.
225-
val neighborWeight = addEdgeWeight(sender, edge, current.weight, currentBlockHeight, wr)
224+
val neighborWeight = addEdgeWeight(sourceNode, edge, current.weight, currentBlockHeight, wr)
226225
val canRelayAmount = current.weight.cost <= edge.capacity &&
227226
edge.balance_opt.forall(current.weight.cost <= _) &&
228227
edge.update.htlcMaximumMsat.forall(current.weight.cost <= _) &&
@@ -295,19 +294,14 @@ object Graph {
295294

296295
/**
297296
* Calculate the minimum amount that the start node needs to receive to be able to forward @amountWithFees to the end
298-
* node. To avoid infinite loops caused by zero-fee edges, we use a lower bound fee of 1 msat.
297+
* node.
299298
*
300299
* @param edge the edge we want to cross
301300
* @param amountToForward the value that this edge will have to carry along
302301
* @return the new amount updated with the necessary fees for this edge
303302
*/
304303
private def addEdgeFees(edge: GraphEdge, amountToForward: MilliSatoshi): MilliSatoshi = {
305-
if (edgeHasZeroFee(edge)) amountToForward + nodeFee(baseFee = 1 msat, proportionalFee = 0, amountToForward)
306-
else amountToForward + nodeFee(edge.update.feeBaseMsat, edge.update.feeProportionalMillionths, amountToForward)
307-
}
308-
309-
private def edgeHasZeroFee(edge: GraphEdge): Boolean = {
310-
edge.update.feeBaseMsat.toLong == 0 && edge.update.feeProportionalMillionths == 0
304+
amountToForward + nodeFee(edge.update.feeBaseMsat, edge.update.feeProportionalMillionths, amountToForward)
311305
}
312306

313307
/** Validate that all edges along the path can relay the amount with fees. */

eclair-core/src/test/scala/fr/acinq/eclair/router/RouteCalculationSpec.scala

+117
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import org.scalatest.{ParallelTestExecution, Tag}
3232
import scodec.bits._
3333

3434
import scala.collection.immutable.SortedMap
35+
import scala.collection.mutable
3536
import scala.util.{Failure, Random, Success}
3637

3738
/**
@@ -1545,6 +1546,122 @@ class RouteCalculationSpec extends AnyFunSuite with ParallelTestExecution {
15451546
}
15461547
}
15471548

1549+
test("loop trap") {
1550+
// +-----------------+
1551+
// | |
1552+
// | v
1553+
// A --> B --> C --> D --> E
1554+
// ^ |
1555+
// | |
1556+
// F <---+
1557+
val g = DirectedGraph(List(
1558+
makeEdge(1L, a, b, 1000 msat, 1000),
1559+
makeEdge(2L, b, c, 1000 msat, 1000),
1560+
makeEdge(3L, c, d, 1000 msat, 1000),
1561+
makeEdge(4L, d, e, 1000 msat, 1000),
1562+
makeEdge(5L, b, e, 1000 msat, 1000),
1563+
makeEdge(6L, c, f, 1000 msat, 1000),
1564+
makeEdge(7L, f, b, 1000 msat, 1000),
1565+
))
1566+
1567+
val Success(routes) = findRoute(g, a, e, DEFAULT_AMOUNT_MSAT, DEFAULT_MAX_FEE, numRoutes = 3, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = 400000)
1568+
assert(routes.length == 2)
1569+
val route1 :: route2 :: Nil = routes
1570+
assert(route2Ids(route1) === 1 :: 5 :: Nil)
1571+
assert(route2Ids(route2) === 1 :: 2 :: 3 :: 4 :: Nil)
1572+
}
1573+
1574+
test("reversed loop trap") {
1575+
// +-----------------+
1576+
// | |
1577+
// v |
1578+
// A <-- B <-- C <-- D <-- E
1579+
// | ^
1580+
// | |
1581+
// F ----+
1582+
val g = DirectedGraph(List(
1583+
makeEdge(1L, b, a, 1000 msat, 1000),
1584+
makeEdge(2L, c, b, 1000 msat, 1000),
1585+
makeEdge(3L, d, c, 1000 msat, 1000),
1586+
makeEdge(4L, e, d, 1000 msat, 1000),
1587+
makeEdge(5L, e, b, 1000 msat, 1000),
1588+
makeEdge(6L, f, c, 1000 msat, 1000),
1589+
makeEdge(7L, b, f, 1000 msat, 1000),
1590+
))
1591+
1592+
val Success(routes) = findRoute(g, e, a, DEFAULT_AMOUNT_MSAT, DEFAULT_MAX_FEE, numRoutes = 3, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = 400000)
1593+
assert(routes.length == 2)
1594+
val route1 :: route2 :: Nil = routes
1595+
assert(route2Ids(route1) === 5 :: 1 :: Nil)
1596+
assert(route2Ids(route2) === 4 :: 3 :: 2 :: 1 :: Nil)
1597+
}
1598+
1599+
test("k-shortest paths must be distinct") {
1600+
// +----> N ---> N N ---> N ----+
1601+
// / \ / \ / \
1602+
// A +--+ (...) +--+ B
1603+
// \ / \ / \ /
1604+
// +----> N ---> N N ---> N ----+
1605+
1606+
def makeEdges(n: Int): Seq[GraphEdge] = {
1607+
val nodes = new Array[(PublicKey, PublicKey)](n)
1608+
for (i <- nodes.indices) {
1609+
nodes(i) = (randomKey.publicKey, randomKey.publicKey)
1610+
}
1611+
val q = new mutable.Queue[GraphEdge]
1612+
// One path is shorter to maximise the overlap between the n-shortest paths, they will all be like the shortest path with a single hop changed.
1613+
q.enqueue(makeEdge(1L, a, nodes(0)._1, 100 msat, 90))
1614+
q.enqueue(makeEdge(2L, a, nodes(0)._2, 100 msat, 100))
1615+
for (i <- 0 until (n - 1)) {
1616+
q.enqueue(makeEdge(4 * i + 3, nodes(i)._1, nodes(i + 1)._1, 100 msat, 90))
1617+
q.enqueue(makeEdge(4 * i + 4, nodes(i)._1, nodes(i + 1)._2, 100 msat, 90))
1618+
q.enqueue(makeEdge(4 * i + 5, nodes(i)._2, nodes(i + 1)._1, 100 msat, 100))
1619+
q.enqueue(makeEdge(4 * i + 6, nodes(i)._2, nodes(i + 1)._2, 100 msat, 100))
1620+
}
1621+
q.enqueue(makeEdge(4 * n, nodes(n - 1)._1, b, 100 msat, 90))
1622+
q.enqueue(makeEdge(4 * n + 1, nodes(n - 1)._2, b, 100 msat, 100))
1623+
q.toSeq
1624+
}
1625+
1626+
val g = DirectedGraph(makeEdges(10))
1627+
1628+
val Success(routes) = findRoute(g, a, b, DEFAULT_AMOUNT_MSAT, 100000000 msat, numRoutes = 10, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = 400000)
1629+
assert(routes.distinct.length == 10)
1630+
}
1631+
1632+
test("all paths are shortest") {
1633+
// +----> N ---> N N ---> N ----+
1634+
// / \ / \ / \
1635+
// A +--+ (...) +--+ B
1636+
// \ / \ / \ /
1637+
// +----> N ---> N N ---> N ----+
1638+
1639+
def makeEdges(n: Int): Seq[GraphEdge] = {
1640+
val nodes = new Array[(PublicKey, PublicKey)](n)
1641+
for (i <- nodes.indices) {
1642+
nodes(i) = (randomKey.publicKey, randomKey.publicKey)
1643+
}
1644+
val q = new mutable.Queue[GraphEdge]
1645+
q.enqueue(makeEdge(1L, a, nodes(0)._1, 100 msat, 100))
1646+
q.enqueue(makeEdge(2L, a, nodes(0)._2, 100 msat, 100))
1647+
for (i <- 0 until (n - 1)) {
1648+
q.enqueue(makeEdge(4 * i + 3, nodes(i)._1, nodes(i + 1)._1, 100 msat, 100))
1649+
q.enqueue(makeEdge(4 * i + 4, nodes(i)._1, nodes(i + 1)._2, 100 msat, 100))
1650+
q.enqueue(makeEdge(4 * i + 5, nodes(i)._2, nodes(i + 1)._1, 100 msat, 100))
1651+
q.enqueue(makeEdge(4 * i + 6, nodes(i)._2, nodes(i + 1)._2, 100 msat, 100))
1652+
}
1653+
q.enqueue(makeEdge(4 * n, nodes(n - 1)._1, b, 100 msat, 100))
1654+
q.enqueue(makeEdge(4 * n + 1, nodes(n - 1)._2, b, 100 msat, 100))
1655+
q.toSeq
1656+
}
1657+
1658+
val g = DirectedGraph(makeEdges(10))
1659+
1660+
val Success(routes) = findRoute(g, a, b, DEFAULT_AMOUNT_MSAT, 100000000 msat, numRoutes = 10, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = 400000)
1661+
assert(routes.distinct.length == 10)
1662+
val fees = routes.map(_.fee)
1663+
assert(fees.forall(_ == fees.head))
1664+
}
15481665
}
15491666

15501667
object RouteCalculationSpec {

0 commit comments

Comments
 (0)