Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Parametric route search #844

Merged
merged 24 commits into from
Feb 11, 2019
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
98deed9
Avoid making a copy of all the vertices in the graph in dijkstra
araspitzu Jan 30, 2019
96f66e0
Implement boundaries for graph searching with cost, cltv, and size
araspitzu Jan 30, 2019
ead28db
Enable searching for routes with size/CLTV/fee limits
araspitzu Jan 31, 2019
6559f21
expose the RouteParams in RouteRequest
araspitzu Jan 31, 2019
2ae43f5
Expose the RouteParams in SendPayment
araspitzu Jan 31, 2019
0ac2538
Rename DEFAULT_ROUTE_MAX_LENGTH
araspitzu Jan 31, 2019
2a55963
Use relaxed params for route request in integration test
araspitzu Jan 31, 2019
df77fee
If we couldn't find a route on the first attempt, retry relaxing the …
araspitzu Jan 31, 2019
ac272b7
Merge branch 'master' into route_fast_searching
araspitzu Jan 31, 2019
09aa95c
Relax maxFeePct in route request during RouterSpec
araspitzu Jan 31, 2019
05b5015
Avoid returning an empty path, collapse the route not found cases int…
araspitzu Feb 6, 2019
bfb9df3
When retrying to search for a route, relax 'maxCltv'
araspitzu Feb 6, 2019
7ac15a0
Merge branch 'master' into route_fast_searching
araspitzu Feb 6, 2019
4487b0a
Finish merging master
araspitzu Feb 6, 2019
4f7efc3
Move the default params for route searching in the conf, refactor tog…
araspitzu Feb 6, 2019
61e7174
Remove max-payment-fee in favor of router.search-max-fee-pct
araspitzu Feb 8, 2019
f95ce08
Group search params configurations into a block
araspitzu Feb 8, 2019
582f2f7
Add comments
araspitzu Feb 8, 2019
e4e69f4
Rename ROUTE_MAX_LENGTH
araspitzu Feb 8, 2019
daee9fe
Add formatter commands for tighter formatting
araspitzu Feb 8, 2019
faffac3
Merge branch 'master' into route_fast_searching
araspitzu Feb 8, 2019
c9f93e2
Rename config keys for router path-finding
araspitzu Feb 11, 2019
f0ac922
Add new config keys to IntegrationSpec conf
araspitzu Feb 11, 2019
db5f709
Formatting
araspitzu Feb 11, 2019
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ class PaymentLifecycle(sourceNodeId: PublicKey, router: ActorRef, register: Acto

when(WAITING_FOR_REQUEST) {
case Event(c: SendPayment, WaitingForRequest) =>
router ! RouteRequest(sourceNodeId, c.targetNodeId, c.amountMsat, c.assistedRoutes, randomize = c.randomize)
router ! RouteRequest(sourceNodeId, c.targetNodeId, c.amountMsat, c.assistedRoutes, randomize = c.randomize, routeParams = c.routeParams)
goto(WAITING_FOR_ROUTE) using WaitingForRoute(sender, c, failures = Nil)
}

Expand Down Expand Up @@ -103,12 +103,12 @@ class PaymentLifecycle(sourceNodeId: PublicKey, router: ActorRef, register: Acto
// in that case we don't know which node is sending garbage, let's try to blacklist all nodes except the one we are directly connected to and the destination node
val blacklist = hops.map(_.nextNodeId).drop(1).dropRight(1)
log.warning(s"blacklisting intermediate nodes=${blacklist.mkString(",")}")
router ! RouteRequest(sourceNodeId, c.targetNodeId, c.amountMsat, c.assistedRoutes, ignoreNodes ++ blacklist, ignoreChannels, c.randomize)
router ! RouteRequest(sourceNodeId, c.targetNodeId, c.amountMsat, c.assistedRoutes, ignoreNodes ++ blacklist, ignoreChannels, c.randomize, c.routeParams)
goto(WAITING_FOR_ROUTE) using WaitingForRoute(s, c, failures :+ UnreadableRemoteFailure(hops))
case Success(e@ErrorPacket(nodeId, failureMessage: Node)) =>
log.info(s"received 'Node' type error message from nodeId=$nodeId, trying to route around it (failure=$failureMessage)")
// let's try to route around this node
router ! RouteRequest(sourceNodeId, c.targetNodeId, c.amountMsat, c.assistedRoutes, ignoreNodes + nodeId, ignoreChannels, c.randomize)
router ! RouteRequest(sourceNodeId, c.targetNodeId, c.amountMsat, c.assistedRoutes, ignoreNodes + nodeId, ignoreChannels, c.randomize, c.routeParams)
goto(WAITING_FOR_ROUTE) using WaitingForRoute(s, c, failures :+ RemoteFailure(hops, e))
case Success(e@ErrorPacket(nodeId, failureMessage: Update)) =>
log.info(s"received 'Update' type error message from nodeId=$nodeId, retrying payment (failure=$failureMessage)")
Expand Down Expand Up @@ -136,18 +136,18 @@ class PaymentLifecycle(sourceNodeId: PublicKey, router: ActorRef, register: Acto
// in any case, we forward the update to the router
router ! failureMessage.update
// let's try again, router will have updated its state
router ! RouteRequest(sourceNodeId, c.targetNodeId, c.amountMsat, c.assistedRoutes, ignoreNodes, ignoreChannels, c.randomize)
router ! RouteRequest(sourceNodeId, c.targetNodeId, c.amountMsat, c.assistedRoutes, ignoreNodes, ignoreChannels, c.randomize, c.routeParams)
} else {
// this node is fishy, it gave us a bad sig!! let's filter it out
log.warning(s"got bad signature from node=$nodeId update=${failureMessage.update}")
router ! RouteRequest(sourceNodeId, c.targetNodeId, c.amountMsat, c.assistedRoutes, ignoreNodes + nodeId, ignoreChannels, c.randomize)
router ! RouteRequest(sourceNodeId, c.targetNodeId, c.amountMsat, c.assistedRoutes, ignoreNodes + nodeId, ignoreChannels, c.randomize, c.routeParams)
}
goto(WAITING_FOR_ROUTE) using WaitingForRoute(s, c, failures :+ RemoteFailure(hops, e))
case Success(e@ErrorPacket(nodeId, failureMessage)) =>
log.info(s"received an error message from nodeId=$nodeId, trying to use a different channel (failure=$failureMessage)")
// let's try again without the channel outgoing from nodeId
val faultyChannel = hops.find(_.nodeId == nodeId).map(hop => ChannelDesc(hop.lastUpdate.shortChannelId, hop.nodeId, hop.nextNodeId))
router ! RouteRequest(sourceNodeId, c.targetNodeId, c.amountMsat, c.assistedRoutes, ignoreNodes, ignoreChannels ++ faultyChannel.toSet, c.randomize)
router ! RouteRequest(sourceNodeId, c.targetNodeId, c.amountMsat, c.assistedRoutes, ignoreNodes, ignoreChannels ++ faultyChannel.toSet, c.randomize, c.routeParams)
goto(WAITING_FOR_ROUTE) using WaitingForRoute(s, c, failures :+ RemoteFailure(hops, e))
}

Expand All @@ -166,7 +166,7 @@ class PaymentLifecycle(sourceNodeId: PublicKey, router: ActorRef, register: Acto
} else {
log.info(s"received an error message from local, trying to use a different channel (failure=${t.getMessage})")
val faultyChannel = ChannelDesc(hops.head.lastUpdate.shortChannelId, hops.head.nodeId, hops.head.nextNodeId)
router ! RouteRequest(sourceNodeId, c.targetNodeId, c.amountMsat, c.assistedRoutes, ignoreNodes, ignoreChannels + faultyChannel, c.randomize)
router ! RouteRequest(sourceNodeId, c.targetNodeId, c.amountMsat, c.assistedRoutes, ignoreNodes, ignoreChannels + faultyChannel, c.randomize, c.routeParams)
goto(WAITING_FOR_ROUTE) using WaitingForRoute(s, c, failures :+ LocalFailure(t))
}

Expand All @@ -193,7 +193,15 @@ object PaymentLifecycle {
/**
* @param maxFeePct set by default to 3% as a safety measure (even if a route is found, if fee is higher than that payment won't be attempted)
*/
case class SendPayment(amountMsat: Long, paymentHash: BinaryData, targetNodeId: PublicKey, assistedRoutes: Seq[Seq[ExtraHop]] = Nil, finalCltvExpiry: Long = Channel.MIN_CLTV_EXPIRY, maxAttempts: Int = 5, maxFeePct: Double = 0.03, randomize: Boolean = true) {
case class SendPayment(amountMsat: Long,
paymentHash: BinaryData,
targetNodeId: PublicKey,
assistedRoutes: Seq[Seq[ExtraHop]] = Nil,
finalCltvExpiry: Long = Channel.MIN_CLTV_EXPIRY,
maxAttempts: Int = 5,
maxFeePct: Double = 0.03,
randomize: Boolean = true,
routeParams: RouteParams = Router.DEFAULT_ROUTE_PARAMS) {
require(amountMsat > 0, s"amountMsat must be > 0")
}
case class CheckPayment(paymentHash: BinaryData)
Expand Down
106 changes: 55 additions & 51 deletions eclair-core/src/main/scala/fr/acinq/eclair/router/Graph.scala
Original file line number Diff line number Diff line change
Expand Up @@ -9,23 +9,24 @@ import Router._

object Graph {

case class WeightedNode(key: PublicKey, weight: Long)
case class WeightedPath(path: Seq[GraphEdge], weight: Long)
case class RichWeight(cost: Long, length: Int, cltv: Int)
araspitzu marked this conversation as resolved.
Show resolved Hide resolved
case class WeightedNode(key: PublicKey, weight: RichWeight)
case class WeightedPath(path: Seq[GraphEdge], weight: RichWeight)

/**
* This comparator must be consistent with the "equals" behavior, thus for two weighted nodes with
* the same weight we distinguish them by their public key. See https://docs.oracle.com/javase/8/docs/api/java/util/Comparator.html
*/
object QueueComparator extends Ordering[WeightedNode] {
override def compare(x: WeightedNode, y: WeightedNode): Int = {
val weightCmp = x.weight.compareTo(y.weight)
val weightCmp = x.weight.cost.compareTo(y.weight.cost)
if (weightCmp == 0) x.key.toString().compareTo(y.key.toString())
else weightCmp
}
}

implicit object PathComparator extends Ordering[WeightedPath] {
override def compare(x: WeightedPath, y: WeightedPath): Int = y.weight.compareTo(x.weight)
override def compare(x: WeightedPath, y: WeightedPath): Int = y.weight.cost.compareTo(x.weight.cost)
}
/**
* Yen's algorithm to find the k-shortest (loopless) paths in a graph, uses dijkstra as search algo. Is guaranteed to terminate finding
Expand All @@ -37,7 +38,7 @@ object Graph {
* @param pathsToFind
* @return
*/
def yenKshortestPaths(graph: DirectedGraph, sourceNode: PublicKey, targetNode: PublicKey, amountMsat: Long, ignoredEdges: Set[ChannelDesc], extraEdges: Set[GraphEdge], pathsToFind: Int): Seq[WeightedPath] = {
def yenKshortestPaths(graph: DirectedGraph, sourceNode: PublicKey, targetNode: PublicKey, amountMsat: Long, ignoredEdges: Set[ChannelDesc], extraEdges: Set[GraphEdge], pathsToFind: Int, boundaries: RichWeight => Boolean): Seq[WeightedPath] = {

var allSpurPathsFound = false

Expand All @@ -47,8 +48,8 @@ object Graph {
val candidates = new mutable.PriorityQueue[WeightedPath]

// find the shortest path, k = 0
val shortestPath = dijkstraShortestPath(graph, sourceNode, targetNode, amountMsat, ignoredEdges, extraEdges)
shortestPaths += WeightedPath(shortestPath, pathCost(shortestPath, amountMsat))
val shortestPath = dijkstraShortestPath(graph, sourceNode, targetNode, amountMsat, ignoredEdges, extraEdges, RichWeight(amountMsat, 0, 0), boundaries)
shortestPaths += WeightedPath(shortestPath, pathWeight(shortestPath, amountMsat, isPartial = false))

// main loop
for(k <- 1 until pathsToFind) {
Expand All @@ -65,6 +66,7 @@ object Graph {

// select the subpath from the source to the spur node of the k-th previous shortest path
val rootPathEdges = if(i == 0) prevShortestPath.head :: Nil else prevShortestPath.take(i)
val rootPathWeight = pathWeight(rootPathEdges, amountMsat, isPartial = true)

// links to be removed that are part of the previous shortest path and which share the same root path
val edgesToIgnore = shortestPaths.flatMap { weightedPath =>
Expand All @@ -76,7 +78,7 @@ object Graph {
}

// find the "spur" path, a subpath going from the spur edge to the target avoiding previously found subpaths
val spurPath = dijkstraShortestPath(graph, spurEdge.desc.a, targetNode, amountMsat, ignoredEdges ++ edgesToIgnore.toSet, extraEdges)
val spurPath = dijkstraShortestPath(graph, spurEdge.desc.a, targetNode, amountMsat, ignoredEdges ++ edgesToIgnore.toSet, extraEdges, rootPathWeight, boundaries)

// if there wasn't a path the spur will be empty
if(spurPath.nonEmpty) {
Expand All @@ -87,10 +89,9 @@ object Graph {
case false => rootPathEdges ++ spurPath
}

//val totalPath = concat(rootPathEdges, spurPath.toList)
val candidatePath = WeightedPath(totalPath, pathCost(totalPath, amountMsat))
val candidatePath = WeightedPath(totalPath, pathWeight(totalPath, amountMsat, isPartial = false))

if (!shortestPaths.contains(candidatePath) && !candidates.exists(_ == candidatePath)) {
if (boundaries(candidatePath.weight) && !shortestPaths.contains(candidatePath) && !candidates.exists(_ == candidatePath)) {
candidates.enqueue(candidatePath)
}

Expand All @@ -110,13 +111,6 @@ object Graph {
shortestPaths
}

// Calculates the total cost of a path (amount + fees), direct channels with the source will have a cost of 0 (pay no fees)
def pathCost(path: Seq[GraphEdge], amountMsat: Long): Long = {
path.drop(1).foldRight(amountMsat) { (edge, cost) =>
edgeWeight(edge, cost, isNeighborTarget = false)
}
}

/**
* Finds the shortest path in the graph, uses a modified version of Dijsktra's algorithm that computes
* the shortest path from the target to the source (this is because we want to calculate the weight of the
Expand All @@ -131,30 +125,29 @@ object Graph {
* @return
*/

def dijkstraShortestPath(g: DirectedGraph, sourceNode: PublicKey, targetNode: PublicKey, amountMsat: Long, ignoredEdges: Set[ChannelDesc], extraEdges: Set[GraphEdge]): Seq[GraphEdge] = {

// optionally add the extra edges to the graph
val graphVerticesWithExtra = extraEdges.nonEmpty match {
case true => g.vertexSet() ++ extraEdges.map(_.desc.a) ++ extraEdges.map(_.desc.b)
case false => g.vertexSet()
}
def dijkstraShortestPath(g: DirectedGraph,
sourceNode: PublicKey,
targetNode: PublicKey,
amountMsat: Long,
ignoredEdges: Set[ChannelDesc],
extraEdges: Set[GraphEdge],
initialWeight: RichWeight,
boundaries: RichWeight => Boolean): Seq[GraphEdge] = {

// the graph does not contain source/destination nodes
if (!graphVerticesWithExtra.contains(sourceNode)) return Seq.empty
if (!graphVerticesWithExtra.contains(targetNode)) return Seq.empty
if (!g.containsVertex(sourceNode)) return Seq.empty
if (!g.containsVertex(targetNode) && (extraEdges.nonEmpty && !extraEdges.exists(_.desc.b == targetNode))) return Seq.empty

val maxMapSize = graphVerticesWithExtra.size + 1
val maxMapSize = 100 // conservative estimation to avoid over allocating memory

// this is not the actual optimal size for the maps, because we only put in there all the vertices in the worst case scenario.
val cost = new java.util.HashMap[PublicKey, Long](maxMapSize)
val cost = new java.util.HashMap[PublicKey, RichWeight](maxMapSize)
val prev = new java.util.HashMap[PublicKey, GraphEdge](maxMapSize)
val vertexQueue = new org.jheaps.tree.SimpleFibonacciHeap[WeightedNode, Short](QueueComparator)
val pathLength = new java.util.HashMap[PublicKey, Int](maxMapSize)

// initialize the queue and cost array with the base cost (amount to be routed)
cost.put(targetNode, amountMsat)
vertexQueue.insert(WeightedNode(targetNode, amountMsat))
pathLength.put(targetNode, 0) // the source node has distance 0
// initialize the queue and cost array with the initial weight
cost.put(targetNode, initialWeight)
vertexQueue.insert(WeightedNode(targetNode, initialWeight))

var targetFound = false

Expand All @@ -171,45 +164,45 @@ object Graph {
case false => g.getIncomingEdgesOf(current.key) ++ extraEdges.filter(_.desc.b == current.key)
}

val currentWeight = cost.get(current.key)

// for each neighbor
currentNeighbors.foreach { edge =>

val neighbor = edge.desc.a

// calculate the length of the partial path given the new edge (current -> neighbor)
val neighborPathLength = pathLength.get(current.key) + 1

// note: 'cost' contains the smallest known cumulative cost (amount + fees) necessary to reach 'current' so far
// note: there is always an entry for the current in the 'cost' map
val newMinimumKnownCost = edgeWeight(edge, cost.get(current.key), neighbor == sourceNode)
val newMinimumKnownWeight = RichWeight(
cost = edgeWeight(edge, currentWeight.cost, initialWeight.length == 0 && neighbor == sourceNode),
length = currentWeight.length + 1,
cltv = currentWeight.cltv + edge.update.cltvExpiryDelta
)

// test for ignored edges
if (edge.update.htlcMaximumMsat.forall(newMinimumKnownCost <= _) &&
newMinimumKnownCost >= edge.update.htlcMinimumMsat &&
neighborPathLength <= ROUTE_MAX_LENGTH && // ignore this edge if it would make the path too long
if (edge.update.htlcMaximumMsat.forall(newMinimumKnownWeight.cost <= _) &&
newMinimumKnownWeight.cost >= edge.update.htlcMinimumMsat &&
boundaries(newMinimumKnownWeight) && // ignore this edge if it violates the boundary checks
!ignoredEdges.contains(edge.desc)
) {

// we call containsKey first because "getOrDefault" is not available in JDK7
val neighborCost = cost.containsKey(neighbor) match {
case false => Long.MaxValue
case false => RichWeight(Long.MaxValue, 0, 0)
case true => cost.get(neighbor)
}

// if this neighbor has a shorter distance than previously known
if (newMinimumKnownCost < neighborCost) {

// update the total length of this partial path
pathLength.put(neighbor, neighborPathLength)
if (newMinimumKnownWeight.cost < neighborCost.cost) {

// update the visiting tree
prev.put(neighbor, edge)

// update the queue
vertexQueue.insert(WeightedNode(neighbor, newMinimumKnownCost)) // O(1)
vertexQueue.insert(WeightedNode(neighbor, newMinimumKnownWeight)) // O(1)

// update the minimum known distance array
cost.put(neighbor, newMinimumKnownCost)
cost.put(neighbor, newMinimumKnownWeight)
}
}
}
Expand All @@ -222,7 +215,7 @@ object Graph {
case false => Seq.empty[GraphEdge]
case true =>
// we traverse the list of "previous" backward building the final list of edges that make the shortest path
val edgePath = new mutable.ArrayBuffer[GraphEdge](ROUTE_MAX_LENGTH)
val edgePath = new mutable.ArrayBuffer[GraphEdge](DEFAULT_ROUTE_MAX_LENGTH)
var current = prev.get(sourceNode)

while (current != null) {
Expand All @@ -239,14 +232,25 @@ object Graph {
*
* @param edge the edge for which we want to compute the weight
* @param amountWithFees the value that this edge will have to carry along
* @param isNeighborTarget true if the receiving vertex of this edge is the target node (source in a reversed graph), which has cost 0
* @param isNeighborSource true if the receiving vertex of this edge is the target node (source in a reversed graph), which has cost 0
* @return the new amount updated with the necessary fees for this edge
*/
private def edgeWeight(edge: GraphEdge, amountWithFees: Long, isNeighborTarget: Boolean): Long = isNeighborTarget match {
private def edgeWeight(edge: GraphEdge, amountWithFees: Long, isNeighborSource: Boolean): Long = isNeighborSource match {
case false => amountWithFees + nodeFee(edge.update.feeBaseMsat, edge.update.feeProportionalMillionths, amountWithFees)
case true => amountWithFees
}

// Calculates the total cost of a path (amount + fees), direct channels with the source will have a cost of 0 (pay no fees)
def pathWeight(path: Seq[GraphEdge], amountMsat: Long, isPartial: Boolean): RichWeight = {
path.drop(if(isPartial) 0 else 1).foldRight(RichWeight(amountMsat, 0, 0)) { (edge, prev) =>
RichWeight(
cost = edgeWeight(edge, prev.cost, isNeighborSource = false),
cltv = prev.cltv + edge.update.cltvExpiryDelta,
length = prev.length + 1
)
}
}

/**
* A graph data structure that uses the adjacency lists, stores the incoming edges of the neighbors
*/
Expand Down
Loading