Skip to content

Commit 3c7658b

Browse files
ptesavolclaude
andauthored
Phase A7: discovery and join (PeerDiscovery, DiscoverySession, RingDiscoverySession) (#65)
Ports the DHT discovery/join layer from streamr/network af966cf03 (v103.8.0-rc.3). - DiscoverySession: one Kademlia lookup toward a target id. The TS Gate/withTimeout/.finally-recursion fan-out becomes `parallelism` worker coroutines pulling from the shared frontier under the session mutex, awaited under one folly::coro::timeout (a `done` flag stands in for the Gate). Discovery is I/O-bound, so it is not offloaded to a dedicated executor like the Router's routing-table work. - RingDiscoverySession: the ring-topology counterpart. - PeerDiscovery: join orchestration (per entry point a discovery session toward the local id plus an optional distant/bit-flipped session, retry when no neighbours are found, periodic recovery once joined). shared_ptr- owned; retry/recovery are detached onto a dedicated worker executor (2nd- class maintenance off the caller/delivery threads) and torn down by the join abort signal. Supporting changes: - streamr.utils.scheduleAtInterval (new); ExecutorHelper now exports folly::Executor. - DhtNodeRpcRemote::getClosestPeers/getClosestRingPeers made virtual (like ping) so the discovery test can substitute them. - PeerManager::getNearbyContacts(); streamr.dht.consts (CONTROL_LAYER_NODE_SERVICE_ID). Tests: ports unit/DiscoverySession.test.ts with the createTestTopology partition-merge helper. The two join integration tests (DhtJoinPeerDiscovery, MultipleEntryPointJoining) build full DhtNodes over the Simulator and are deferred to phase A8 (DhtNode assembly). 170/170 dht unit, 12/12 dht integration, 51/51 utils unit; lint green. Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent b562f0b commit 3c7658b

10 files changed

Lines changed: 1470 additions & 2 deletions

File tree

‎packages/streamr-dht/CMakeLists.txt‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,7 @@ if(NOT IOS AND STREAMR_MODULES_SUPPORTED)
220220
test/unit/LocalDataStoreTest.cpp
221221
test/unit/StoreRpcLocalTest.cpp
222222
test/unit/StoreManagerTest.cpp
223+
test/unit/DiscoverySessionTest.cpp
223224
)
224225

225226
target_include_directories(streamr-dht-test-unit

‎packages/streamr-dht/modules/dht/DhtNodeRpcRemote.cppm‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,8 @@ public:
9595
this->getPeerDescriptor());
9696
}
9797

98-
folly::coro::Task<std::vector<PeerDescriptor>> getClosestPeers(
98+
// Virtual (like ping()) so tests can substitute a deterministic result.
99+
virtual folly::coro::Task<std::vector<PeerDescriptor>> getClosestPeers(
99100
const DhtAddress& nodeId) {
100101
ClosestPeersRequest request;
101102
request.set_nodeid(Identifiers::getRawFromDhtAddress(nodeId));
@@ -112,7 +113,8 @@ public:
112113
co_return peers;
113114
}
114115

115-
folly::coro::Task<ClosestRingPeerDescriptors> getClosestRingPeers(
116+
// Virtual (like ping()) so tests can substitute a deterministic result.
117+
virtual folly::coro::Task<ClosestRingPeerDescriptors> getClosestRingPeers(
116118
const RingIdRaw& ringIdRaw) {
117119
ClosestRingPeersRequest request;
118120
request.set_ringid(ringIdRaw);

‎packages/streamr-dht/modules/dht/PeerManager.cppm‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -429,6 +429,21 @@ public:
429429
return this->nearbyContacts->getSize(excludedNodeIds);
430430
}
431431

432+
// TS returns the SortedContactList itself; the only consumer
433+
// (DiscoverySession) maps it straight to peer descriptors, so this returns
434+
// the descriptors directly and keeps the list access under the mutex.
435+
[[nodiscard]] std::vector<PeerDescriptor> getNearbyContacts() {
436+
std::scoped_lock lock(this->mutex);
437+
const auto contacts =
438+
this->nearbyContacts->getAllContactsInUndefinedOrder();
439+
std::vector<PeerDescriptor> result;
440+
result.reserve(contacts.size());
441+
for (const auto& contact : contacts) {
442+
result.push_back(contact->getPeerDescriptor());
443+
}
444+
return result;
445+
}
446+
432447
[[nodiscard]] size_t getNeighborCount() {
433448
std::scoped_lock lock(this->mutex);
434449
return this->neighbors->count();
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
// Module streamr.dht.consts
2+
// Ported from packages/dht/src/dht/consts.ts (v103.8.0-rc.3).
3+
module;
4+
5+
#include <string>
6+
7+
export module streamr.dht.consts;
8+
9+
import streamr.dht.Identifiers;
10+
11+
export namespace streamr::dht {
12+
13+
// TODO (from TS): move this to the trackerless-network package and make the
14+
// serviceId a required parameter.
15+
inline const ServiceID CONTROL_LAYER_NODE_SERVICE_ID{"layer0"};
16+
17+
} // namespace streamr::dht
Lines changed: 262 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,262 @@
1+
// Module streamr.dht.DiscoverySession
2+
// Ported from packages/dht/src/dht/discovery/DiscoverySession.ts
3+
// (v103.8.0-rc.3). One Kademlia-style lookup toward a target id: it keeps
4+
// up to `parallelism` getClosestPeers requests in flight against the closest
5+
// uncontacted nearby contacts, feeds the returned peers back into the
6+
// PeerManager, and finishes once the frontier is exhausted or `noProgressLimit`
7+
// consecutive requests fail to bring a closer neighbour.
8+
//
9+
// TS drives the fan-out through promise continuations: each request's
10+
// `.finally` re-invokes findMoreContacts, which tops the in-flight set back up
11+
// to `parallelism`. This port expresses the identical rolling concurrency as
12+
// `parallelism` worker coroutines that each pull the next-closest uncontacted
13+
// node from the shared frontier under the session mutex, so at most
14+
// `parallelism` requests run at once and each completion immediately refills.
15+
// The TS Gate/withTimeout pair becomes structured concurrency: findClosestNodes
16+
// awaits every worker under a single folly timeout (a `done` flag, guarded by
17+
// the mutex, stands in for the single-shot Gate). Because the workers are
18+
// awaited rather than detached, `this` outlives them and no self-pin is needed.
19+
//
20+
// Discovery is I/O-bound (it spends its time awaiting RPC responses, not
21+
// computing), so unlike the Router's routing-table work it is not offloaded to
22+
// a dedicated worker executor; the fan-out runs on whatever executor drives the
23+
// join and yields at every RPC await.
24+
module;
25+
26+
#include <chrono>
27+
#include <cstddef>
28+
#include <functional>
29+
#include <memory>
30+
#include <mutex>
31+
#include <optional>
32+
#include <set>
33+
#include <string>
34+
#include <utility>
35+
#include <vector>
36+
#include "packages/dht/protos/DhtRpc.pb.h"
37+
38+
#include <coroutine> // IWYU pragma: keep
39+
40+
export module streamr.dht.DiscoverySession;
41+
42+
import streamr.utils.AbortController;
43+
import streamr.utils.CoroutineHelper;
44+
import streamr.utils.Uuid;
45+
import streamr.logger.SLogger;
46+
import streamr.dht.DhtNodeRpcRemote;
47+
import streamr.dht.getClosestNodes;
48+
import streamr.dht.getPeerDistance;
49+
import streamr.dht.Identifiers;
50+
import streamr.dht.PeerManager;
51+
52+
// Hoisted from the former header (file scope, NOT exported).
53+
using streamr::logger::SLogger;
54+
using streamr::utils::AbortSignal;
55+
using streamr::utils::Uuid;
56+
57+
export namespace streamr::dht::discovery {
58+
59+
using ::dht::PeerDescriptor;
60+
using streamr::dht::DhtAddress;
61+
using streamr::dht::DhtAddressRaw;
62+
using streamr::dht::DhtNodeRpcRemote;
63+
using streamr::dht::Identifiers;
64+
using streamr::dht::PeerManager;
65+
using streamr::dht::contact::getClosestNodes;
66+
using streamr::dht::contact::GetClosestNodesOptions;
67+
using streamr::dht::helpers::getPeerDistance;
68+
69+
struct DiscoverySessionOptions {
70+
DhtAddress targetId;
71+
size_t parallelism;
72+
size_t noProgressLimit;
73+
PeerManager& peerManager;
74+
// Mutated by this session (and, when several entry points are joined at
75+
// once, by the sibling sessions that share the same set). Owned by the
76+
// caller; it outlives findClosestNodes.
77+
std::set<DhtAddress>& contactedPeers;
78+
AbortSignal& abortSignal;
79+
std::function<std::shared_ptr<DhtNodeRpcRemote>(const PeerDescriptor&)>
80+
createDhtNodeRpcRemote;
81+
};
82+
83+
class DiscoverySession {
84+
private:
85+
std::string id = Uuid::v4();
86+
size_t noProgressCounter = 0;
87+
std::set<DhtAddress> ongoingRequests;
88+
bool done = false; // the single-shot Gate, guarded by the mutex
89+
std::recursive_mutex mutex;
90+
DiscoverySessionOptions options;
91+
92+
void addContacts(const std::vector<PeerDescriptor>& contacts) {
93+
std::scoped_lock lock(this->mutex);
94+
if (this->options.abortSignal.aborted || this->done) {
95+
return;
96+
}
97+
for (const auto& contact : contacts) {
98+
this->options.peerManager.addContact(contact);
99+
}
100+
}
101+
102+
folly::coro::Task<std::vector<PeerDescriptor>>
103+
fetchClosestNeighborsFromRemote(PeerDescriptor peerDescriptor) {
104+
{
105+
std::scoped_lock lock(this->mutex);
106+
if (this->options.abortSignal.aborted || this->done) {
107+
co_return std::vector<PeerDescriptor>{};
108+
}
109+
}
110+
const auto nodeId =
111+
Identifiers::getNodeIdFromPeerDescriptor(peerDescriptor);
112+
SLogger::trace("Getting closest neighbors from remote: " + nodeId);
113+
const auto remote =
114+
this->options.createDhtNodeRpcRemote(peerDescriptor);
115+
auto returnedContacts =
116+
co_await remote->getClosestPeers(this->options.targetId);
117+
{
118+
std::scoped_lock lock(this->mutex);
119+
this->options.peerManager.setContactActive(nodeId);
120+
}
121+
co_return returnedContacts;
122+
}
123+
124+
// Returns nullopt when there are no neighbours yet (TS would index [0] of
125+
// an empty array; guarding avoids the undefined access).
126+
[[nodiscard]] std::optional<PeerDescriptor> getClosestNeighbor() {
127+
std::vector<PeerDescriptor> neighborDescriptors;
128+
const auto neighbors = this->options.peerManager.getNeighbors();
129+
neighborDescriptors.reserve(neighbors.size());
130+
for (const auto& neighbor : neighbors) {
131+
neighborDescriptors.push_back(neighbor->getPeerDescriptor());
132+
}
133+
const auto closest = getClosestNodes(
134+
this->options.targetId,
135+
neighborDescriptors,
136+
GetClosestNodesOptions{.maxCount = 1});
137+
if (closest.empty()) {
138+
return std::nullopt;
139+
}
140+
return closest.front();
141+
}
142+
143+
void onRequestSucceeded(
144+
const DhtAddress& nodeId, const std::vector<PeerDescriptor>& contacts) {
145+
std::scoped_lock lock(this->mutex);
146+
if (!this->ongoingRequests.contains(nodeId)) {
147+
return;
148+
}
149+
this->ongoingRequests.erase(nodeId);
150+
const auto targetIdRaw =
151+
Identifiers::getRawFromDhtAddress(this->options.targetId);
152+
const auto oldClosestNeighbor = this->getClosestNeighbor();
153+
this->addContacts(contacts);
154+
const auto newClosestNeighbor = this->getClosestNeighbor();
155+
// No prior neighbour means the newly added contacts are progress; a
156+
// new closest that is not nearer than the old one is not.
157+
if (oldClosestNeighbor.has_value() && newClosestNeighbor.has_value()) {
158+
const auto oldClosestDistance = getPeerDistance(
159+
targetIdRaw, DhtAddressRaw{oldClosestNeighbor->nodeid()});
160+
const auto newClosestDistance = getPeerDistance(
161+
targetIdRaw, DhtAddressRaw{newClosestNeighbor->nodeid()});
162+
if (newClosestDistance >= oldClosestDistance) {
163+
this->noProgressCounter++;
164+
}
165+
} else if (!newClosestNeighbor.has_value()) {
166+
this->noProgressCounter++;
167+
}
168+
}
169+
170+
void onRequestFailed(const DhtAddress& nodeId) {
171+
std::scoped_lock lock(this->mutex);
172+
if (!this->ongoingRequests.contains(nodeId)) {
173+
return;
174+
}
175+
this->ongoingRequests.erase(nodeId);
176+
this->options.peerManager.removeContact(nodeId);
177+
}
178+
179+
// One worker of the rolling fan-out: repeatedly claim the closest
180+
// uncontacted node and query it, until the session is done.
181+
folly::coro::Task<void> worker() {
182+
while (true) {
183+
std::optional<PeerDescriptor> node;
184+
{
185+
std::scoped_lock lock(this->mutex);
186+
if (this->options.abortSignal.aborted || this->done) {
187+
co_return;
188+
}
189+
if (this->noProgressCounter >= this->options.noProgressLimit) {
190+
this->done = true;
191+
co_return;
192+
}
193+
const auto uncontacted = getClosestNodes(
194+
this->options.targetId,
195+
this->options.peerManager.getNearbyContacts(),
196+
GetClosestNodesOptions{
197+
.maxCount = this->options.parallelism,
198+
.excludedNodeIds = this->options.contactedPeers});
199+
for (const auto& candidate : uncontacted) {
200+
const auto candidateId =
201+
Identifiers::getNodeIdFromPeerDescriptor(candidate);
202+
if (!this->ongoingRequests.contains(candidateId)) {
203+
node = candidate;
204+
break;
205+
}
206+
}
207+
if (!node.has_value()) {
208+
if (this->ongoingRequests.empty()) {
209+
this->done = true;
210+
}
211+
co_return;
212+
}
213+
const auto nodeId =
214+
Identifiers::getNodeIdFromPeerDescriptor(*node);
215+
this->ongoingRequests.insert(nodeId);
216+
// TS adds to contactedPeers synchronously at the start of
217+
// fetchClosestNeighborsFromRemote, before the await; claiming
218+
// it here (under the lock) keeps sibling workers from picking
219+
// the same node.
220+
this->options.contactedPeers.insert(nodeId);
221+
}
222+
const auto nodeId = Identifiers::getNodeIdFromPeerDescriptor(*node);
223+
try {
224+
const auto contacts =
225+
co_await this->fetchClosestNeighborsFromRemote(*node);
226+
this->onRequestSucceeded(nodeId, contacts);
227+
} catch (const std::exception& e) {
228+
SLogger::trace(
229+
"getClosestPeers failed: " + std::string(e.what()));
230+
this->onRequestFailed(nodeId);
231+
}
232+
}
233+
}
234+
235+
public:
236+
explicit DiscoverySession(DiscoverySessionOptions options)
237+
: options(std::move(options)) {}
238+
239+
[[nodiscard]] const std::string& getId() const { return this->id; }
240+
241+
folly::coro::Task<void> findClosestNodes(
242+
std::chrono::milliseconds timeout) {
243+
{
244+
std::scoped_lock lock(this->mutex);
245+
if (this->options.peerManager.getNearbyContactCount(
246+
this->options.contactedPeers) == 0) {
247+
co_return;
248+
}
249+
}
250+
std::vector<folly::coro::Task<void>> workers;
251+
workers.reserve(this->options.parallelism);
252+
for (size_t i = 0; i < this->options.parallelism; ++i) {
253+
workers.push_back(this->worker());
254+
}
255+
co_await streamr::utils::co_withCancellation(
256+
this->options.abortSignal.getCancellationToken(),
257+
folly::coro::timeout(
258+
folly::coro::collectAllRange(std::move(workers)), timeout));
259+
}
260+
};
261+
262+
} // namespace streamr::dht::discovery

0 commit comments

Comments
 (0)