@yz-social/kdht 0.1.3 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/ci.yml +27 -0
- package/dht/kbucket.js +2 -2
- package/dht/node.js +67 -90
- package/dht/nodeContacts.js +7 -5
- package/dht/nodeMessages.js +44 -4
- package/dht/nodeProbe.js +240 -47
- package/dht/nodeRefresh.js +6 -4
- package/dht/nodeStorage.js +4 -0
- package/dht/nodeTransports.js +3 -3
- package/dht/nodeUtilities.js +1 -1
- package/package.json +8 -18
- package/portals/node.js +1 -0
- package/spec/bots.js +12 -8
- package/spec/dhtAcceptanceSpec.js +23 -9
- package/spec/dhtImplementation.js +3 -3
- package/spec/dhtInternalsSpec.js +304 -15
- package/spec/dhtKeySpec.js +0 -3
- package/spec/dhtWriteReadSpec.js +85 -0
- package/spec/portal.js +5 -2
- package/transports/contact.js +88 -20
- package/transports/simulations.js +87 -20
- package/transports/webrtc.js +98 -96
- package/spec/dhtWriteRead.js +0 -56
package/dht/nodeProbe.js
CHANGED
|
@@ -10,29 +10,49 @@ export class NodeProbe extends NodeMessages {
|
|
|
10
10
|
static isContactsResult(rpcResult) {
|
|
11
11
|
return Array.isArray(rpcResult);
|
|
12
12
|
}
|
|
13
|
-
async step(targetKey, finder, helper, keysSeen) {
|
|
13
|
+
async step(targetKey, finder, helper, keysSeen, trace) {
|
|
14
14
|
// Get up to k previously unseen Helpers from helper, adding results to keysSeen.
|
|
15
15
|
const contact = helper.contact;
|
|
16
16
|
// this.log('step with', contact.sname);
|
|
17
17
|
let results = await contact.sendRPC(finder, targetKey);
|
|
18
18
|
if (!results) { // disconnected
|
|
19
|
+
if (trace) this.log(helper.name, '=> disconnected');
|
|
19
20
|
this.log('removing unconnected contact', contact.sname);
|
|
20
21
|
await this.removeContact(contact);
|
|
21
|
-
return
|
|
22
|
+
return null; // signal that there is *no* response from this contact - to distinguish from a response that confirms that the contact is alive, even if there are (after filtering) no new contacts to try.
|
|
22
23
|
}
|
|
23
24
|
await this.addToRoutingTable(contact); // Live node, so update bucket.
|
|
24
25
|
// this.log('step added contact', contact.sname);
|
|
25
26
|
if (this.constructor.isContactsResult(results)) { // Keep only those that we have not seen, and note the new ones we have.
|
|
26
|
-
|
|
27
|
-
|
|
27
|
+
const rawResults = results;
|
|
28
|
+
results = results.filter(helper => !keysSeen.has(helper.key) && keysSeen.add(helper.key)); // add() returns the Set, which is truthy
|
|
29
|
+
// Results are (helpers around) contacts, with distance from the target. Set them up for this host, with contact as sponsor.
|
|
28
30
|
results = results.map(h => new Helper(this.ensureContact(h.contact, contact), h.distance));
|
|
29
|
-
|
|
31
|
+
if (trace) {
|
|
32
|
+
const filterMsg = results.length === rawResults.length ? "" : ` after removing ${rawResults.map(h => h.name).filter(name => !results.some(r => r.name === name)).join(', ')}`;
|
|
33
|
+
this.log(`${helper.name} => ${results.length ? results.map(h => h.name) : '<empty>'}${filterMsg}`);
|
|
34
|
+
}
|
|
35
|
+
} else if (trace && false) this.log(`${helper.name} => ${results}`);
|
|
36
|
+
|
|
30
37
|
return results;
|
|
31
38
|
}
|
|
32
|
-
|
|
33
|
-
|
|
39
|
+
|
|
40
|
+
static alpha = 3; // How many lookup requests are kept in flight concurrently.
|
|
41
|
+
static queryTimeoutMs = 10000; // Give up on a query after this many ms.
|
|
42
|
+
async iterate(targetKey, finder, k = this.constructor.k, trace = false, timing = false, includeSelf = false) {
|
|
34
43
|
// Promise a best-first list of k Helpers from the network, by repeatedly trying to improve our closest known by applying finder.
|
|
35
|
-
// But if any finder operation
|
|
44
|
+
// But if any finder operation answers isValueResult, answer that instead.
|
|
45
|
+
// Note: When a value is found, returns { value, responder } where responder is the Helper that provided the value.
|
|
46
|
+
// This allows callers to identify which node responded. Use isValueResult() to check, and result.value to get the value.
|
|
47
|
+
//
|
|
48
|
+
// Per Kademlia paper: keeps alpha requests in flight while making progress.
|
|
49
|
+
// If alpha consecutive responses fail to find new nodes, escalates to k parallel queries.
|
|
50
|
+
// Terminates when among the k closest nodes we've seen, none have outstanding queries
|
|
51
|
+
// (they've all either responded or timed out).
|
|
52
|
+
//
|
|
53
|
+
// includeSelf: If true, the local node is eligible to be included in the results (currently used only for storeValue).
|
|
54
|
+
|
|
55
|
+
if (trace) this.log(`iterate: key=${targetKey}, finder=${finder}, k=${k}`);
|
|
36
56
|
|
|
37
57
|
if (targetKey !== this.key) {
|
|
38
58
|
const bucketIndex = this.getBucketIndex(targetKey);
|
|
@@ -40,48 +60,221 @@ export class NodeProbe extends NodeMessages {
|
|
|
40
60
|
// Subtle: if we don't have one now, but will after, refreshes will be rescheduled by KBucket constructor.
|
|
41
61
|
bucket?.resetRefresh();
|
|
42
62
|
}
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
63
|
+
|
|
64
|
+
const alpha = this.constructor.alpha;
|
|
65
|
+
const queryTimeoutMs = this.constructor.queryTimeoutMs;
|
|
46
66
|
const isValueResult = this.constructor.isValueResult;
|
|
47
|
-
const
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
67
|
+
const iterateStartTime = timing ? Date.now() : 0;
|
|
68
|
+
let requestCount = 0;
|
|
69
|
+
|
|
70
|
+
// This is an iterative procedure, starting from the nodes among our own contacts that are
|
|
71
|
+
// closest to the specified target. The result of findClosestHelpers might turn out to include
|
|
72
|
+
// this node itself, but of course there is no point in sending ourselves a query to request
|
|
73
|
+
// what findClosestHelpers has already delivered, so the node is filtered out before starting
|
|
74
|
+
// the iteration. In the case of a caller that would be happy for this node to appear in the
|
|
75
|
+
// results (if it is indeed one of the closest), the caller should specify includeSelf=true;
|
|
76
|
+
// this is checked at the end of the method before handing the results back.
|
|
77
|
+
let allNodesSeen = this.findClosestHelpers(targetKey, 2*k).filter(h => h.key !== this.key);
|
|
78
|
+
const keysSeen = new Set(allNodesSeen.map(h => h.key)); // Every key we've seen at all (for filtering in step()).
|
|
79
|
+
keysSeen.add(this.key); // Prevent self from being added via other nodes' responses.
|
|
80
|
+
|
|
81
|
+
const pendingTimeouts = new Map(); // helper.key -> timeoutId (queries in flight)
|
|
82
|
+
const queryState = new Map(); // helper.key -> 'responded' | 'timedOut' | 'disconnected' (queries once they respond or are abandoned)
|
|
83
|
+
const queryResponders = []; // helpers that have responded (for building result)
|
|
84
|
+
let responsesWithoutNewNodes = 0; // count of successive empty responses
|
|
85
|
+
let maxInFlight = alpha; // Normal: alpha parallel queries. Escalates to k when a "round" of alpha queries all fail to find new nodes.
|
|
86
|
+
let iterationFinished = false;
|
|
87
|
+
|
|
88
|
+
let resolveIteration;
|
|
89
|
+
const iterationPromise = new Promise((resolve) => resolveIteration = (...args) => { iterationFinished = true; resolve(...args) }); // to be resolved with a value result, or undefined
|
|
90
|
+
|
|
91
|
+
// Check if termination condition is met:
|
|
92
|
+
// We're complete when we've found k 'responded' nodes with no unresolved nodes
|
|
93
|
+
// (pending or not-yet-queried) closer than them.
|
|
94
|
+
const isComplete = () => {
|
|
95
|
+
let respondedCount = 0;
|
|
96
|
+
for (const h of allNodesSeen) {
|
|
97
|
+
const state = queryState.get(h.key);
|
|
98
|
+
if (state === 'responded') {
|
|
99
|
+
respondedCount++;
|
|
100
|
+
if (respondedCount >= k) return true; // Found k responded with no unresolved gaps
|
|
101
|
+
} else if (!state) {
|
|
102
|
+
// No record means it's either a pending query, or that a query hasn't even been sent yet. We need to wait for it to resolve one way or another.
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
// 'timedOut' or 'disconnected': resolved but doesn't count, continue
|
|
106
|
+
}
|
|
107
|
+
// Exhausted list: fewer than k responsive nodes exist, but all are resolved
|
|
108
|
+
return true;
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
// Get the next closest node that needs to be queried
|
|
112
|
+
const getNextToQuery = () => {
|
|
113
|
+
for (const h of allNodesSeen) {
|
|
114
|
+
if (!pendingTimeouts.has(h.key) && !queryState.has(h.key)) {
|
|
115
|
+
return h;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return null;
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
// Handler for when a request completes. result is only expected if status='responded'.
|
|
122
|
+
const handleCompletion = (helper, status, result) => {
|
|
123
|
+
if (iterationFinished) return; // too late
|
|
124
|
+
|
|
125
|
+
if (!this.isRunning) {
|
|
126
|
+
resolveIteration();
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Clear the timeout (if still active)
|
|
131
|
+
const timeoutId = pendingTimeouts.get(helper.key);
|
|
132
|
+
if (timeoutId) {
|
|
133
|
+
clearTimeout(timeoutId);
|
|
134
|
+
pendingTimeouts.delete(helper.key);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (timing) {
|
|
138
|
+
const elapsed = Date.now() - iterateStartTime;
|
|
139
|
+
const label = status === 'responded' ? 'Response' : status === 'timedOut' ? 'Timeout' : 'Disconnected';
|
|
140
|
+
console.log(` ${label}: ${elapsed}ms - ${helper.name} (${pendingTimeouts.size} pending)`);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Handle disconnected or timed-out node
|
|
144
|
+
if (status !== 'responded') {
|
|
145
|
+
queryState.set(helper.key, status);
|
|
78
146
|
} else {
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
147
|
+
// Response arrived - mark as responded (even if previously marked as timed out)
|
|
148
|
+
queryState.set(helper.key, 'responded');
|
|
149
|
+
queryResponders.push(helper);
|
|
150
|
+
|
|
151
|
+
// Check for value result (immediate termination, after attempting to add one more storage node)
|
|
152
|
+
if (isValueResult(result)) {
|
|
153
|
+
// Store at closest node that didn't have it (if any). This can cause more than k copies in the network.
|
|
154
|
+
const sortedResponders = [...queryResponders].sort(Helper.compare);
|
|
155
|
+
for (const h of sortedResponders) {
|
|
156
|
+
if (h.key !== helper.key) { // Skip the one that returned the value
|
|
157
|
+
h.contact.store(targetKey, result.value);
|
|
158
|
+
break;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Include responder info in the result for diagnostics
|
|
163
|
+
resolveIteration({ value: result.value, responder: helper });
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Result is array of Helpers (may be empty if node had no new contacts)
|
|
168
|
+
// Merge new helpers into allNodesSeen and track progress
|
|
169
|
+
if (result.length > 0) {
|
|
170
|
+
allNodesSeen.push(...result);
|
|
171
|
+
allNodesSeen.sort(Helper.compare); // Keep sorted by distance (best-first).
|
|
172
|
+
responsesWithoutNewNodes = 0; // reset counter
|
|
173
|
+
maxInFlight = alpha; // Back to normal parallelism when making progress
|
|
174
|
+
} else {
|
|
175
|
+
responsesWithoutNewNodes++;
|
|
176
|
+
if (responsesWithoutNewNodes >= alpha && maxInFlight < k) {
|
|
177
|
+
// A "round" of alpha queries all failed to find new nodes.
|
|
178
|
+
// Per Kademlia paper: escalate to k parallel queries to cast a wider net.
|
|
179
|
+
maxInFlight = k;
|
|
180
|
+
if (trace) this.log('escalating to', k, 'parallel queries after', alpha, 'empty responses');
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Check termination
|
|
186
|
+
if (isComplete()) {
|
|
187
|
+
if (trace) this.log('terminated: k closest nodes all resolved');
|
|
188
|
+
resolveIteration();
|
|
189
|
+
return;
|
|
82
190
|
}
|
|
191
|
+
|
|
192
|
+
// Or terminate when network is exhausted (fewer than k nodes available)
|
|
193
|
+
if (pendingTimeouts.size === 0 && !getNextToQuery()) {
|
|
194
|
+
if (trace) this.log('terminated: network exhausted');
|
|
195
|
+
resolveIteration();
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Launch requests to maintain maxInFlight in flight
|
|
200
|
+
while (pendingTimeouts.size < maxInFlight && launchNext()) {
|
|
201
|
+
// launchNext returns false when no more candidates
|
|
202
|
+
}
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
// Launch a request to the next candidate
|
|
206
|
+
const launchNext = () => {
|
|
207
|
+
const helper = getNextToQuery();
|
|
208
|
+
if (!helper) return false;
|
|
209
|
+
|
|
210
|
+
// Set up active timeout that fires if no response arrives in time
|
|
211
|
+
const timeoutId = setTimeout(() => {
|
|
212
|
+
if (iterationFinished) return;
|
|
213
|
+
if (!pendingTimeouts.has(helper.key)) return; // Already resolved
|
|
214
|
+
|
|
215
|
+
if (trace) this.log('query timed out:', helper.name);
|
|
216
|
+
|
|
217
|
+
handleCompletion(helper, 'timedOut');
|
|
218
|
+
}, queryTimeoutMs);
|
|
219
|
+
|
|
220
|
+
pendingTimeouts.set(helper.key, timeoutId);
|
|
221
|
+
|
|
222
|
+
if (timing) {
|
|
223
|
+
requestCount++;
|
|
224
|
+
const elapsed = Date.now() - iterateStartTime;
|
|
225
|
+
console.log(` Launch ${requestCount}: ${elapsed}ms - ${helper.name} (${pendingTimeouts.size} pending)`);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
this.step(targetKey, finder, helper, keysSeen, trace)
|
|
229
|
+
.then(result => handleCompletion(helper, 'responded', result))
|
|
230
|
+
.catch(err => {
|
|
231
|
+
// Handle errors - treat as disconnected
|
|
232
|
+
handleCompletion(helper, 'disconnected');
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
return true;
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
// Handle edge case: no nodes to query (isolated node)
|
|
239
|
+
if (allNodesSeen.length === 0) {
|
|
240
|
+
const contactCount = this.contacts.length;
|
|
241
|
+
this.log(`iterate(${finder}): no nodes to query - isolated node with ${contactCount} contacts in routing table`);
|
|
242
|
+
return [];
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Launch initial alpha requests
|
|
246
|
+
for (let i = 0; i < alpha; i++) {
|
|
247
|
+
if (!launchNext()) break;
|
|
83
248
|
}
|
|
84
|
-
|
|
85
|
-
|
|
249
|
+
|
|
250
|
+
// Wait for iteration to complete (converged or exhausted, or found a value)
|
|
251
|
+
const valueResult = await iterationPromise; // undefined if no value result was received
|
|
252
|
+
|
|
253
|
+
if (timing) {
|
|
254
|
+
const totalElapsed = Date.now() - iterateStartTime;
|
|
255
|
+
console.log(` Total iterate time: ${totalElapsed}ms (${queryState.size} queried, ${queryResponders.length} responded)`);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
if (valueResult) {
|
|
259
|
+
if (trace) this.log(`value result: ${valueResult} after responses from ${queryResponders.length} nodes`);
|
|
260
|
+
return valueResult;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// If includeSelf is true, add self to the results so that on re-sort it might end up among the k closest.
|
|
264
|
+
// This is used for storeValue, where the local node is itself a valid storage location.
|
|
265
|
+
if (includeSelf) {
|
|
266
|
+
const selfDistance = this.constructor.distance(this.key, targetKey);
|
|
267
|
+
const selfHelper = new Helper(this.contact, selfDistance);
|
|
268
|
+
queryResponders.push(selfHelper);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Build result: k closest nodes that have actually responded
|
|
272
|
+
const closestResponsive = queryResponders
|
|
273
|
+
.sort(Helper.compare)
|
|
274
|
+
.slice(0, k);
|
|
275
|
+
|
|
276
|
+
if (trace) this.log('probe result', closestResponsive.map(helper => `${helper.name}@${String(helper.distance).slice(0,2)}[${String(helper.distance).length}]`).join(', '));
|
|
277
|
+
|
|
278
|
+
return closestResponsive;
|
|
86
279
|
}
|
|
87
280
|
}
|
package/dht/nodeRefresh.js
CHANGED
|
@@ -7,7 +7,7 @@ export class NodeRefresh extends NodeKeys {
|
|
|
7
7
|
super({refreshTimeIntervalMS, ...properties});
|
|
8
8
|
}
|
|
9
9
|
static stopRefresh() { // Stop all repeat timers in all instances the next time they come around.
|
|
10
|
-
this.
|
|
10
|
+
this.refreshTimeIntervalMS = 0;
|
|
11
11
|
}
|
|
12
12
|
stopRefresh() { // Stop repeat timeers in this instance.
|
|
13
13
|
this.refreshTimeIntervalMS = 0;
|
|
@@ -15,7 +15,10 @@ export class NodeRefresh extends NodeKeys {
|
|
|
15
15
|
isStopped(interval) {
|
|
16
16
|
return !this.isRunning || 0 === this.refreshTimeIntervalMS || 0 === this.constructor.refreshTimeIntervalMS || 0 === interval;
|
|
17
17
|
}
|
|
18
|
-
|
|
18
|
+
// The refreshTimeIntervalMS is the number of nominal number milliseconds we expect to be able to handle for short session timems.
|
|
19
|
+
// The actual period between bucket and data refreshes may be more or less than this, depending on how well we deal with churn.
|
|
20
|
+
// That actual average time between refereshes is the default target value here. E.g., this.refreshTimeIntervalMS / 2, or 1.5 * this.refreshTimeIntervalMS, etc.
|
|
21
|
+
fuzzyInterval(target = 2 * this.refreshTimeIntervalMS, margin = target/2) { // Like static fuzzyInterval with target defaulting to refreshTimeIntervalMS/2.
|
|
19
22
|
return this.constructor.fuzzyInterval(target, margin);
|
|
20
23
|
}
|
|
21
24
|
static fuzzyInterval(target, margin = target/2) {
|
|
@@ -32,7 +35,7 @@ export class NodeRefresh extends NodeKeys {
|
|
|
32
35
|
return this.workQueue = this.workQueue.then(thunk);
|
|
33
36
|
}
|
|
34
37
|
timers = new Map();
|
|
35
|
-
schedule(timerKey, statisticsKey, thunk) {
|
|
38
|
+
schedule(timerKey, statisticsKey, thunk, timeout = this.fuzzyInterval()) {
|
|
36
39
|
// Schedule thunk() to occur at a fuzzyInterval from now, cancelling any
|
|
37
40
|
// existing timer at the same key. This is used in such a way that:
|
|
38
41
|
// 1. A side effect of calling thunk() is that it will be scheduled again, if appropriate.
|
|
@@ -41,7 +44,6 @@ export class NodeRefresh extends NodeKeys {
|
|
|
41
44
|
// E.g., bucket index 1 === 1 and stored value key BigInt(1) === BigInt(1), but 1 !== BigInt(1)
|
|
42
45
|
if (this.isStopped()) return;
|
|
43
46
|
const start = Date.now();
|
|
44
|
-
const timeout = this.fuzzyInterval();
|
|
45
47
|
clearInterval(this.timers.get(timerKey));
|
|
46
48
|
this.timers.set(timerKey, setTimeout(async () => {
|
|
47
49
|
const lag = Date.now() - start - timeout;
|
package/dht/nodeStorage.js
CHANGED
|
@@ -5,7 +5,11 @@ export class NodeStorage extends NodeRefresh {
|
|
|
5
5
|
storage = new Map(); // keys must be preserved as bigint, not converted to string.
|
|
6
6
|
// TODO: store across sessions
|
|
7
7
|
storeLocally(key, value) { // Store in memory by a BigInt key (must be already hashed). Not persistent.
|
|
8
|
+
const hadValue = this.storage.has(key);
|
|
8
9
|
this.storage.set(key, value);
|
|
10
|
+
if (this.constructor.diagnosticTrace) {
|
|
11
|
+
this.log(`storeLocally(${key}, ${value}) - ${hadValue ? 'updated' : 'NEW'}`);
|
|
12
|
+
}
|
|
9
13
|
// TODO: The paper says this can be optimized.
|
|
10
14
|
// Claude.ai suggests just writing to the next in line, but that doesn't work.
|
|
11
15
|
this.schedule(key, 'storage', () => this.storeValue(key, value));
|
package/dht/nodeTransports.js
CHANGED
|
@@ -16,7 +16,7 @@ export class NodeTransports extends NodeStorage {
|
|
|
16
16
|
}
|
|
17
17
|
return false;
|
|
18
18
|
}
|
|
19
|
-
static maxTransports =
|
|
19
|
+
static maxTransports = 62; // FIXME: try 124
|
|
20
20
|
noteContactForTransport(contact) { // We're about to use this contact for a message, so keep track of it.
|
|
21
21
|
// Requires: if we later addToRoutingTable successfully, it should be removed from looseTransports.
|
|
22
22
|
// Requires: if we later remove contact because of a failed send, it should be removed from looseTransports.
|
|
@@ -36,7 +36,7 @@ export class NodeTransports extends NodeStorage {
|
|
|
36
36
|
}
|
|
37
37
|
let dropped = removeLast(this.looseTransports);
|
|
38
38
|
if (dropped) {
|
|
39
|
-
console.log('dropping loose transport', dropped.name
|
|
39
|
+
//console.log(this.name, 'dropping loose transport', dropped.name);
|
|
40
40
|
} else { // Find the bucket with the most connections.
|
|
41
41
|
let bestBucket = null, bestCount = 0;
|
|
42
42
|
this.forEachBucket(bucket => {
|
|
@@ -48,7 +48,7 @@ export class NodeTransports extends NodeStorage {
|
|
|
48
48
|
});
|
|
49
49
|
dropped = removeLast(bestBucket.contacts);
|
|
50
50
|
if (!dropped) console.log('Unable to find something to drop in', this.report(null));
|
|
51
|
-
else console.log('dropping transport', dropped.name, 'in',
|
|
51
|
+
//else console.log(this.name, 'dropping transport', dropped.name, 'in bucket', bestBucket.index, 'among', bestCount);
|
|
52
52
|
}
|
|
53
53
|
dropped.disconnectTransport();
|
|
54
54
|
}
|
package/dht/nodeUtilities.js
CHANGED
|
@@ -5,7 +5,7 @@ export class NodeUtilities {
|
|
|
5
5
|
}
|
|
6
6
|
isRunning = true;
|
|
7
7
|
static delay(ms, value) { // Promise to resolve (to nothing) after a given number of milliseconds
|
|
8
|
-
return new Promise(resolve => setTimeout(resolve, ms, value));
|
|
8
|
+
return new Promise(resolve => setTimeout(resolve, Math.max(0, ms), value));
|
|
9
9
|
}
|
|
10
10
|
static randomInteger(max) { // Return a random number between 0 (inclusive) and max (exclusive).
|
|
11
11
|
return Math.floor(Math.random() * max);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yz-social/kdht",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
4
4
|
"description": "Pure Kademlia base, for testing variations.",
|
|
5
5
|
"exports": {
|
|
6
6
|
".": "./index.js",
|
|
@@ -9,25 +9,15 @@
|
|
|
9
9
|
},
|
|
10
10
|
"type": "module",
|
|
11
11
|
"scripts": {
|
|
12
|
-
"
|
|
13
|
-
"
|
|
14
|
-
"
|
|
15
|
-
"
|
|
16
|
-
"
|
|
17
|
-
"
|
|
18
|
-
"load75": "node spec/portal.js --nPortals 10 --nBots 40 --nWrites 100",
|
|
19
|
-
"load100": "node spec/portal.js --nPortals 10 --nBots 90 --nWrites 100",
|
|
20
|
-
"load150": "node spec/portal.js --nPortals 15 --nBots 135 --nWrites 100",
|
|
21
|
-
"load200": "node spec/portal.js --nPortals 20 --nBots 180 --nWrites 100",
|
|
22
|
-
"thrash30": "node spec/portal.js --nPortals 10 --nBots 20 --thrash true --nWrites 100",
|
|
23
|
-
"thrash50": "node spec/portal.js --nPortals 10 --nBots 40 --thrash true --nWrites 100",
|
|
24
|
-
"thrash100": "node spec/portal.js --nPortals 10 --nBots 90 --thrash true --nWrites 100",
|
|
25
|
-
"thrash150": "node spec/portal.js --nPortals 15 --nBots 135 --thrash true --nWrites 100",
|
|
26
|
-
"thrash200": "node spec/portal.js --nPortals 20 --nBots 180 --thrash true --nWrites 100",
|
|
27
|
-
"test": "npx jasmine"
|
|
12
|
+
"start": "node spec/portal.js",
|
|
13
|
+
"stop": "pkill kdht",
|
|
14
|
+
"background": "npm stop; (npm start 1>server.log 2>&1 &); sleep 1",
|
|
15
|
+
"bots": "node spec/bots.js",
|
|
16
|
+
"thrashbots": "node spec/bots.js --thrash",
|
|
17
|
+
"test": "npx jasmine && echo '-- SUCCESS --' || echo '**** FAIL ****'"
|
|
28
18
|
},
|
|
29
19
|
"dependencies": {
|
|
30
|
-
"@yz-social/webrtc": "^0.1.
|
|
20
|
+
"@yz-social/webrtc": "^0.1.1",
|
|
31
21
|
"uuid": "^13.0.0"
|
|
32
22
|
},
|
|
33
23
|
"devDependencies": {
|
package/portals/node.js
CHANGED
|
@@ -28,6 +28,7 @@ export async function setup({baseURL, externalBaseURL = '', verbose, fixedSpacin
|
|
|
28
28
|
const bootstrap = joinURL && await contact.ensureRemoteContact(bootstrapName, joinURL);
|
|
29
29
|
process.send(contact.sname); // Report in to server as available for others to bootstrap through.
|
|
30
30
|
if (bootstrap) await contact.join(bootstrap);
|
|
31
|
+
contact.host.xlog('joined');
|
|
31
32
|
process.on('SIGINT', async () => {
|
|
32
33
|
console.log(process.title, 'Shutdown for Ctrl+C');
|
|
33
34
|
await contact.disconnect();
|
package/spec/bots.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import {cpus, availableParallelism } from 'node:os';
|
|
2
3
|
import cluster from 'node:cluster';
|
|
3
4
|
import process from 'node:process';
|
|
4
5
|
import { launchWriteRead } from './writes.js';
|
|
@@ -7,14 +8,16 @@ import { WebContact, Node } from '../index.js';
|
|
|
7
8
|
import yargs from 'yargs';
|
|
8
9
|
import { hideBin } from 'yargs/helpers';
|
|
9
10
|
|
|
11
|
+
const logicalCores = availableParallelism();
|
|
12
|
+
|
|
10
13
|
// Todo: Allow a remote portal to be specified (passing a host to WebContact.create/ensureRemoteContact).
|
|
11
14
|
const argv = yargs(hideBin(process.argv))
|
|
12
|
-
.usage(
|
|
15
|
+
.usage(`Launch nBots that connect to the network through the local portal. A bot is just an ordinary node that can only be contacted through another node. They provide either continuity or churn-testing, depend on whether or not they are told to 'thrash'. Model description "${cpus()[0].model}", ${logicalCores} logical cores.`)
|
|
13
16
|
.option('nBots', {
|
|
14
17
|
alias: 'n',
|
|
15
18
|
alias: 'nbots',
|
|
16
19
|
type: 'number',
|
|
17
|
-
default:
|
|
20
|
+
default: Math.min(logicalCores / 2, 2),
|
|
18
21
|
description: "The number of bots, which can only be reached through the network."
|
|
19
22
|
})
|
|
20
23
|
.option('baseURL', {
|
|
@@ -61,19 +64,20 @@ let contact = await WebContact.create({name: host, debug: argv.verbose});
|
|
|
61
64
|
let bootstrapName = await contact.fetchBootstrap(argv.baseURL);
|
|
62
65
|
let bootstrapContact = await contact.ensureRemoteContact(bootstrapName, argv.baseURL);
|
|
63
66
|
await contact.join(bootstrapContact);
|
|
67
|
+
contact.host.xlog('joined');
|
|
64
68
|
|
|
65
|
-
process.on('SIGINT', async () => {
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
});
|
|
69
|
+
// process.on('SIGINT', async () => {
|
|
70
|
+
// console.log(process.title, 'Shutdown for Ctrl+C');
|
|
71
|
+
// await contact.disconnect();
|
|
72
|
+
// process.exit(0);
|
|
73
|
+
// });
|
|
70
74
|
|
|
71
75
|
while (argv.thrash) {
|
|
72
76
|
await Node.delay(contact.host.fuzzyInterval(Node.refreshTimeIntervalMS));
|
|
73
77
|
const old = contact;
|
|
74
78
|
const next = uuidv4();
|
|
75
79
|
contact.host.xlog('disconnecting');
|
|
76
|
-
contact.disconnect();
|
|
80
|
+
await contact.disconnect();
|
|
77
81
|
await Node.delay(1e3); // TODO: remove?
|
|
78
82
|
|
|
79
83
|
contact = await WebContact.create({name: next, debug: argv.verbose});
|
|
@@ -10,7 +10,7 @@ const { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach} = glob
|
|
|
10
10
|
import { setupServerNodes, shutdownServerNodes,
|
|
11
11
|
start1, setupClientsByTime, shutdownClientNodes,
|
|
12
12
|
getContacts, getRandomLiveContact,
|
|
13
|
-
startThrashing, write1, read1, Node } from './dhtImplementation.js';
|
|
13
|
+
startThrashing, write1, read1, Node, Contact } from './dhtImplementation.js';
|
|
14
14
|
|
|
15
15
|
// Some definitions:
|
|
16
16
|
//
|
|
@@ -118,7 +118,13 @@ async function parallelReadAll(start = 0) {
|
|
|
118
118
|
const contacts = await getContacts();
|
|
119
119
|
const readPromises = await Promise.all(contacts.map(async (_, index) => {
|
|
120
120
|
if (index < start) return;
|
|
121
|
-
|
|
121
|
+
let value;
|
|
122
|
+
for (let liveTry = 0; liveTry < 4; liveTry++) {
|
|
123
|
+
const contact = await getRandomLiveContact();
|
|
124
|
+
value = await read1(contact, index);
|
|
125
|
+
if (contact.isRunning) break;
|
|
126
|
+
console.log('\n\n*** read from dead contact. retrying', liveTry, '***\n');
|
|
127
|
+
}
|
|
122
128
|
expect(value).toBe(index);
|
|
123
129
|
}));
|
|
124
130
|
return readPromises.length - start;
|
|
@@ -136,8 +142,8 @@ describe("DHT", function () {
|
|
|
136
142
|
function test(parameters = {}) {
|
|
137
143
|
// Define a suite of tests with the given parameters.
|
|
138
144
|
const {nServerNodes = 10,
|
|
139
|
-
pingTimeMS =
|
|
140
|
-
maxTransports =
|
|
145
|
+
pingTimeMS = Contact.pingTimeMS, // Round-trip network time. Implementation should pad network calls to achieve what is specified here.
|
|
146
|
+
maxTransports = Node.maxTransports, // How many direct connections are allowed per node?
|
|
141
147
|
maxClientNodes = Infinity, // If zero, will try to make as many as it can in refreshTimeIntervalMS.
|
|
142
148
|
refreshTimeIntervalMS = 15e3, // How long on average does a client stay up?
|
|
143
149
|
setupTimeMS = Math.max(2e3, refreshTimeIntervalMS), // How long to create the test nodes.
|
|
@@ -148,7 +154,7 @@ describe("DHT", function () {
|
|
|
148
154
|
} = parameters;
|
|
149
155
|
const suiteLabel = `Server nodes: ${nServerNodes}, setup time: ${setupTimeMS}, max client nodes: ${maxClientNodes ?? Infinity}, ping: ${pingTimeMS}ms, max connections: ${maxTransports}, refresh: ${refreshTimeIntervalMS.toFixed(3)}ms, pause before write: ${runtimeBeforeWriteMS.toFixed(3)}ms, pause before read: ${runtimeBeforeReadMS.toFixed(3)}ms, thrash before: ${startThrashingBefore}`;
|
|
150
156
|
|
|
151
|
-
describe(suiteLabel, function () {
|
|
157
|
+
describe(notes || suiteLabel, function () {
|
|
152
158
|
beforeAll(async function () {
|
|
153
159
|
console.log('\n' + suiteLabel);
|
|
154
160
|
if (notes) console.log(notes);
|
|
@@ -206,17 +212,25 @@ describe("DHT", function () {
|
|
|
206
212
|
|
|
207
213
|
// Each call here sets up a full suite of tests with the given parameters, which can be useful for development and debugging.
|
|
208
214
|
// For example:
|
|
215
|
+
test({maxClientNodes: 10, startThrashingBefore: 'never', runtimeBeforeWriteMS: 0, runtimeBeforeReadMS: 0, notes: "Smoke"});
|
|
209
216
|
test({pingTimeMS: 0, refreshTimeIntervalMS: 0, startThrashingBefore: 'never', notes: "Runs flat out if probing and disconnects turned off."});
|
|
210
217
|
test({setupTimeMS: 1e3, pingTimeMS: 0, startThrashingBefore: 'never', notes: "Probing on, but no disconnects or network delay."});
|
|
211
|
-
test({
|
|
212
|
-
test({
|
|
213
|
-
|
|
218
|
+
test({pingTimeMS: 0, refreshTimeIntervalMS: 5e3, notes: "Small networks allow faster thrash smoke-testing."});
|
|
219
|
+
test({notes: "Normal ops"});
|
|
220
|
+
|
|
221
|
+
// test({maxClientNodes: 55, setupTimeMS: 240e3, pingTimeMS: 40, maxTransports: 62,
|
|
222
|
+
// //startThrashingBefore: 'never', runtimeBeforeWriteMS: 0, runtimeBeforeReadMS: 0,
|
|
223
|
+
// notes: "Moderate transport-dropping for currently over-constricted contact limits."});
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
//test({maxTransports: 85, maxClientNodes: 90, pingTimeMS: 10, setupTimeMS: 20e3, notes: "Limit number of transports enough to exercise the reconnect logic."});
|
|
227
|
+
//test({maxClientNodes: 140, setupTimeMS: 60e3, pingTimeMS: 10, notes: "Relatively larger network size."});
|
|
214
228
|
|
|
215
229
|
//test({maxTransports: 95, maxClientNodes: 100, refreshTimeIntervalMS: 0, startThrashingBefore: 'never', notes: 'dev: no refresh, no thrashing'});
|
|
216
230
|
//test({maxTransports: 95, maxClientNodes: 100, startThrashingBefore: 'never', notes: 'dev: no thrashing'});
|
|
217
231
|
|
|
218
232
|
//test({maxClientNodes: 7, nServerNodes: 5, refreshTimeIntervalMS: 3e3, runtimeBeforeWriteMS: 0e3, runtimeBeforeReadMS: 0e3, startThrashingBefore: 'never'});
|
|
219
|
-
//test({maxClientNodes: 3, nServerNodes: 3, refreshTimeIntervalMS: 3e3, runtimeBeforeWriteMS: 6e3, runtimeBeforeReadMS: 6e3});
|
|
233
|
+
//test({maxClientNodes: 3, nServerNodes: 3, startThrashingBefore: 'never', refreshTimeIntervalMS: 3e3, runtimeBeforeWriteMS: 6e3, runtimeBeforeReadMS: 6e3});
|
|
220
234
|
|
|
221
235
|
|
|
222
236
|
// TODO:
|
|
@@ -11,10 +11,8 @@
|
|
|
11
11
|
|
|
12
12
|
// In the present case, these manipulate a Contact that directly contains a
|
|
13
13
|
// DHT node with simulated networking.
|
|
14
|
-
//import { InProcessWebContact as Contact, Node } from '../index.js';
|
|
15
|
-
//import { SimulatedContact as Contact, Node } from '../index.js';
|
|
16
14
|
import { SimulatedConnectionContact as Contact, Node } from '../index.js';
|
|
17
|
-
export { Node };
|
|
15
|
+
export { Node, Contact };
|
|
18
16
|
|
|
19
17
|
|
|
20
18
|
export async function start1(name, bootstrapContact, refreshTimeIntervalMS, isServerNode = false) {
|
|
@@ -24,6 +22,7 @@ export async function start1(name, bootstrapContact, refreshTimeIntervalMS, isSe
|
|
|
24
22
|
}
|
|
25
23
|
|
|
26
24
|
export async function startServerNode(name, bootstrapContact, refreshTimeIntervalMS) {
|
|
25
|
+
Node.refreshTimeIntervalMS = refreshTimeIntervalMS;
|
|
27
26
|
return await start1(name, bootstrapContact, refreshTimeIntervalMS, true);
|
|
28
27
|
}
|
|
29
28
|
|
|
@@ -187,5 +186,6 @@ async function serialSetupClientsByTime(refreshTimeIntervalMS, nServerNodes, max
|
|
|
187
186
|
export async function shutdownClientNodes(nServerNodes, nClientNodes) {
|
|
188
187
|
await stopThrashing();
|
|
189
188
|
await new Promise(resolve => setTimeout(resolve, 5e3));
|
|
189
|
+
Node.refreshTimeIntervalMS = 0;
|
|
190
190
|
await shutdown(nServerNodes, nClientNodes + nServerNodes);
|
|
191
191
|
}
|