Skip to content

Commit ce80d38

Browse files
committed
chore(kad): continuous lookup pipeline
1 parent 8a7b54e commit ce80d38

3 files changed

Lines changed: 155 additions & 92 deletions

File tree

libp2p/protocols/kademlia/find.nim

Lines changed: 141 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# SPDX-License-Identifier: Apache-2.0 OR MIT
22
# Copyright (c) Status Research & Development GmbH
33

4-
import std/[tables, sequtils, algorithm, net]
4+
import std/[tables, sequtils, algorithm, net, sets]
55
import chronos, chronicles, results
66
import ../../[peerid, peerinfo, switch, multihash, peeraddrpolicy, wire]
77
import ../protocol
@@ -21,7 +21,6 @@ type LookupState* = ref object
2121
shortlist*: Table[PeerId, XorDistance]
2222
responded*: Table[PeerId, RespondedStatus]
2323
attempts*: Table[PeerId, int]
24-
inflight*: Table[PeerId, seq[FutureBase]]
2524

2625
type DispatchProc* = proc(
2726
kad: KadDHT, peer: PeerId, target: Key
@@ -366,77 +365,88 @@ proc findNodeDispatch*(
366365
): Future[Result[Message, string]] {.async: (raises: [CancelledError]), gcsafe.} =
367366
return await dispatchFindNode(kad, peer, target)
368367

369-
proc lookOnce*(
370-
kad: KadDHT,
371-
state: LookupState,
372-
rtable: RoutingTable,
373-
dispatch: DispatchProc,
374-
onReply: ReplyHandler,
375-
): Future[bool] {.async: (raises: [CancelledError]).} =
376-
## Run a single round of the iterative lookup against ``state.target``.
377-
## Returns ``false`` when no further peers can be queried, signalling the
378-
## caller to stop driving the lookup.
379-
let toQuery = state.selectCloserPeers(kad.config.alpha)
380-
if toQuery.len() == 0:
381-
return false
382-
383-
for peerId in toQuery:
384-
state.attempts[peerId] = state.attempts.getOrDefault(peerId, 0) + 1
385-
386-
debug "Lookup queries", peersToQuery = toQuery.mapIt(it.shortLog())
387-
368+
type DispatchOutcome = enum
369+
Completed
370+
Errored
371+
372+
type DispatchResult = object
373+
peer: PeerId
374+
outcome: DispatchOutcome
375+
msg: Message
376+
377+
type RpcFuture = Future[DispatchResult].Raising([CancelledError])
378+
379+
type Attempt = object
380+
peer: PeerId
381+
fut: RpcFuture
382+
deadline: Moment
383+
abandoned: bool
384+
## its ``timeout`` elapsed; the slot is freed but the RPC
385+
## keeps running so it can still deliver, and its late result is ignored.
386+
387+
proc runDispatch(
388+
kad: KadDHT, peerId: PeerId, target: Key, dispatch: DispatchProc
389+
): Future[DispatchResult] {.async: (raises: [CancelledError]).} =
390+
let res = await dispatch(kad, peerId, target)
391+
if res.isErr():
392+
error "Kad lookup: RPC error", peer = peerId.shortLog(), msg = res.error()
393+
return DispatchResult(peer: peerId, outcome: Errored)
394+
return DispatchResult(peer: peerId, outcome: Completed, msg: res.value())
395+
396+
func activePeers(pending: seq[Attempt]): HashSet[PeerId] {.raises: [].} =
397+
var peers = initHashSet[PeerId]()
398+
for a in pending:
399+
if not a.abandoned:
400+
peers.incl(a.peer)
401+
peers
402+
403+
proc refill(
404+
kad: KadDHT, state: LookupState, pending: var seq[Attempt], dispatch: DispatchProc
405+
) {.raises: [].} =
406+
## Keep up to ``alpha`` RPCs in flight by dispatching the next-closest
407+
## not-yet-active peers into any free slots.
408+
var active = pending.activePeers()
388409
let target = state.target
389-
let dispatchWithPeer = proc(
390-
peerId: PeerId
391-
): Future[(PeerId, Result[Message, string])] {.
392-
async: (raises: [CancelledError]), gcsafe
393-
.} =
394-
let msg = await dispatch(kad, peerId, target)
395-
return (peerId, msg)
396-
397-
let rpcBatch = toQuery.mapIt(dispatchWithPeer(it))
398-
for (fut, peerId) in zip(rpcBatch, toQuery):
399-
state.inflight.mgetOrPut(peerId, @[]).add(FutureBase(fut))
400-
let completedRPCBatch = await rpcBatch.collectCompleted(kad.config.timeout)
401-
402-
for (fut, peerId) in zip(rpcBatch, toQuery):
403-
if not fut.finished() or fut.cancelled():
404-
continue
405-
if fut.failed():
406-
state.responded[peerId] = RespondedStatus.Failed
407-
else:
408-
let (_, res) = fut.value()
409-
if res.isErr():
410-
state.responded[peerId] = RespondedStatus.Failed
411-
error "Kad lookup: RPC error", peer = peerId.shortLog(), msg = res.error()
412-
else:
413-
state.responded[peerId] = RespondedStatus.Success
414-
415-
var toCancel: seq[FutureBase]
416-
for (peerId, res) in completedRPCBatch:
417-
let reply = res.valueOr:
410+
for (peerId, _) in state.sortedShortlist():
411+
if active.len >= kad.config.alpha:
412+
break
413+
if peerId in active:
418414
continue
419-
let newPeerInfos = state.updateShortlist(reply)
420-
kad.switch.updatePeers(
421-
kad.config.addressPolicy, rtable, newPeerInfos, kad.config.limits.maxPeersPerIp,
422-
kad.config.limits.maxPeersPerIpv4Subnet, kad.config.limits.maxPeersPerIpv6Subnet,
415+
state.attempts[peerId] = state.attempts.getOrDefault(peerId, 0) + 1
416+
debug "Lookup query", peer = peerId.shortLog()
417+
pending.add(
418+
Attempt(
419+
peer: peerId,
420+
fut: kad.runDispatch(peerId, target, dispatch),
421+
deadline: Moment.now() + kad.config.timeout,
422+
abandoned: false,
423+
)
423424
)
424-
await onReply(peerId, Opt.some(reply), state)
425-
426-
# Evicted peers are no longer eligible for retries, so cancel any abandoned RPCs.
427-
for peerId in state.inflight.keys.toSeq:
428-
if not state.shortlist.hasKey(peerId):
429-
toCancel.add(state.inflight.getOrDefault(peerId).filterIt(not it.finished()))
430-
state.inflight.del(peerId)
431-
432-
for peerId in toQuery:
433-
if state.responded.hasKey(peerId) or
434-
state.attempts.getOrDefault(peerId, 0) > kad.config.retries:
435-
toCancel.add(state.inflight.getOrDefault(peerId).filterIt(not it.finished()))
436-
state.inflight.del(peerId)
437-
await toCancel.cancelAndWait()
425+
active.incl(peerId)
426+
427+
proc awaitProgress(pending: seq[Attempt]) {.async: (raises: [CancelledError]).} =
428+
## Wake as soon as any in-flight RPC finishes or the earliest active slot's
429+
## ``timeout`` elapses, whichever comes first.
430+
var earliest = Opt.none(Moment)
431+
for a in pending:
432+
if not a.abandoned and (earliest.isNone or a.deadline < earliest.get()):
433+
earliest = Opt.some(a.deadline)
434+
435+
let timer = sleepAsync(
436+
if earliest.isSome:
437+
max(earliest.get() - Moment.now(), ZeroDuration)
438+
else:
439+
InfiniteDuration
440+
)
441+
defer:
442+
timer.cancelSoon()
438443

439-
return true
444+
var futs = pending.mapIt(FutureBase(it.fut))
445+
futs.add(FutureBase(timer))
446+
try:
447+
discard await race(futs)
448+
except ValueError:
449+
raiseAssert "race() cannot raise ValueError on a non-empty future list"
440450

441451
proc iterativeLookup*(
442452
kad: KadDHT,
@@ -446,17 +456,71 @@ proc iterativeLookup*(
446456
onReply: ReplyHandler,
447457
stopCond: StopCond,
448458
): Future[LookupState] {.async: (raises: [CancelledError]).} =
459+
## Drive an iterative lookup as a continuous ``alpha``-concurrency pipeline:
460+
## keep up to ``alpha`` RPCs in flight and re-dispatch the next-closest
461+
## unqueried peer as soon as any slot frees, so wall-clock tracks throughput
462+
## rather than the slowest peer in a synchronized round. A slow peer only
463+
## stalls its own slot for ``timeout`` before it is abandoned and retried.
449464
let state = LookupState.init(kad, target)
465+
var pending: seq[Attempt]
450466

451-
while not stopCond(state):
452-
if not await kad.lookOnce(state, rtable, dispatch, onReply):
453-
break
467+
defer:
468+
await pending.mapIt(it.fut).cancelAndWait()
469+
470+
while true:
471+
# Resolve finished RPCs; time out overdue ones by freeing their slot while
472+
# leaving the RPC running (its late reply is ignored).
473+
let now = Moment.now()
474+
var completed: seq[DispatchResult]
475+
var stillPending: seq[Attempt]
476+
for a in pending:
477+
if a.fut.finished():
478+
if not a.abandoned and not a.fut.cancelled():
479+
completed.add(a.fut.value())
480+
continue
481+
if not a.abandoned and now >= a.deadline:
482+
stillPending.add(
483+
Attempt(peer: a.peer, fut: a.fut, deadline: a.deadline, abandoned: true)
484+
)
485+
else:
486+
stillPending.add(a)
487+
pending = stillPending
488+
489+
var toCancel: seq[RpcFuture]
490+
for res in completed:
491+
case res.outcome
492+
of Errored:
493+
state.responded[res.peer] = RespondedStatus.Failed
494+
of Completed:
495+
state.responded[res.peer] = RespondedStatus.Success
496+
let newPeerInfos = state.updateShortlist(res.msg)
497+
kad.switch.updatePeers(
498+
kad.config.addressPolicy, rtable, newPeerInfos,
499+
kad.config.limits.maxPeersPerIp, kad.config.limits.maxPeersPerIpv4Subnet,
500+
kad.config.limits.maxPeersPerIpv6Subnet,
501+
)
502+
await onReply(res.peer, Opt.some(res.msg), state)
503+
504+
# A responded peer needs no duplicate retries, and a newly discovered closer
505+
# peer can evict an in-flight target from the shortlist; cancel both.
506+
var keep: seq[Attempt]
507+
for a in pending:
508+
if state.responded.hasKey(a.peer) or not state.shortlist.hasKey(a.peer):
509+
toCancel.add(a.fut)
510+
else:
511+
keep.add(a)
512+
pending = keep
513+
await toCancel.cancelAndWait()
514+
515+
# Once the stop condition holds, dispatch no new peers but keep draining the
516+
# replies already in flight, mirroring the old round that finished its batch
517+
# before terminating.
518+
if not stopCond(state):
519+
kad.refill(state, pending, dispatch)
454520

455-
var leftover: seq[FutureBase]
456-
for futs in state.inflight.values:
457-
leftover.add(futs.filterIt(not it.finished()))
458-
if leftover.len > 0:
459-
await leftover.cancelAndWait()
521+
if pending.activePeers().len == 0:
522+
break
523+
await awaitProgress(pending)
460524

461525
state
462526

libp2p/protocols/service_discovery/random_find.nim

Lines changed: 13 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# SPDX-License-Identifier: Apache-2.0 OR MIT
22
# Copyright (c) Status Research & Development GmbH
33

4-
import std/[sequtils, sets, tables]
4+
import std/[sequtils, sets]
55
import chronos, chronicles, results
66
import ../../[peerid, peerinfo, switch, multihash, routing_record, extended_peer_record]
77
import ../protocol
@@ -22,23 +22,22 @@ proc randomRecords(
2222

2323
let randomKey = randomPeerId.toKey()
2424

25-
let state = LookupState.init(disco, randomKey)
2625
var queried: HashSet[PeerId]
2726
var getValFuts: seq[Future[Result[Message, string]].Raising([CancelledError])]
2827

28+
# Issue a getValue as soon as a peer responds, so they run in parallel with
29+
# the rest of the lookup.
30+
let onReply = proc(
31+
peerId: PeerId, msg: Opt[Message], state: LookupState
32+
): Future[void] {.async: (raises: []), gcsafe.} =
33+
if peerId notin queried:
34+
queried.incl(peerId)
35+
getValFuts.add(disco.dispatchGetVal(peerId, peerId.toKey()))
36+
2937
try:
30-
while not closestAvailableStop(state):
31-
let progressed =
32-
await disco.lookOnce(state, disco.rtable, findNodeDispatch, noopReply)
33-
if not progressed:
34-
break
35-
36-
# Issue getValue requests as soon as peers respond, so they run in
37-
# parallel with the next lookup round.
38-
for peerId, status in state.responded:
39-
if status == RespondedStatus.Success and peerId notin queried:
40-
queried.incl(peerId)
41-
getValFuts.add(disco.dispatchGetVal(peerId, peerId.toKey()))
38+
discard await disco.iterativeLookup(
39+
randomKey, findNodeDispatch, onReply, closestAvailableStop
40+
)
4241
except CancelledError as e:
4342
await noCancel allFutures(getValFuts.mapIt(it.cancelAndWait()))
4443
raise e

tests/libp2p/service_discovery/component/test_find_random.nim

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ suite "Service Discovery Component - Find Random":
2626
check record.peerId in peerIds
2727

2828
asyncTest "lookupRandom completes when there are no peers to query":
29-
# With an empty routing table the shortlist is empty, so lookOnce returns
29+
# With an empty routing table the shortlist is empty, so the lookup returns
3030
# immediately and lookupRandom must complete rather than hang.
3131
let discos = setupServiceDiscoveryNodes(1)
3232
startAndDeferStop(discos)

0 commit comments

Comments
 (0)