@yz-social/kdht 0.1.2 → 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.
@@ -0,0 +1,27 @@
1
+ name: CI/CD Build and Test
2
+
3
+ on:
4
+ push:
5
+ branches: [ main ]
6
+ pull_request:
7
+ branches: [ main ]
8
+ workflow_dispatch: # Allows manual running
9
+
10
+ jobs:
11
+ build-and-test:
12
+ runs-on: ubuntu-latest # Uses the latest Ubuntu runner
13
+
14
+ steps:
15
+ - name: Checkout Code
16
+ uses: actions/checkout@v5 # Action to check out your repository code
17
+
18
+ - name: Set up Node.js
19
+ uses: actions/setup-node@v4 # Action to set up Node.js environment
20
+ with:
21
+ node-version: '22.x'
22
+
23
+ - name: Install Dependencies
24
+ run: npm i # Some folks recommend 'npm ci', but I don't like the required package lock.
25
+
26
+ - name: Run Tests
27
+ run: npm test # Executes the 'test' script defined in your package.json
package/dht/kbucket.js CHANGED
@@ -41,10 +41,10 @@ export class KBucket {
41
41
  await this.node.locateNodes(targetKey); // Side-effect is to update this bucket.
42
42
  return true;
43
43
  }
44
- resetRefresh() { // We are organically performing a lookup in this bucket. Reset the timer.
44
+ resetRefresh(now = false) { // We are organically performing a lookup in this bucket. Reset the timer.
45
45
  // clearInterval(this.refreshTimer);
46
46
  // this.refreshTimer = this.node.repeat(() => this.refresh(), 'bucket');
47
- this.node.schedule(this.index, 'bucket', () => this.refresh());
47
+ this.node.schedule(this.index, 'bucket', () => this.refresh(), now ? 1 : undefined); // Not zero.
48
48
  }
49
49
 
50
50
  removeKey(key, deleteIfEmpty = true) { // Removes item specified by key (if present) from bucket and return 'present' if it was, else false.
package/dht/node.js CHANGED
@@ -11,38 +11,97 @@ import { NodeProbe } from './nodeProbe.js';
11
11
  export class Node extends NodeProbe {
12
12
  // These are the public methods for applications.
13
13
 
14
- async locateNodes(targetKey, number = this.constructor.k) { // Promise up to k best Contacts for targetKey (sorted closest first).
14
+ static diagnosticTrace = false; // Set to true for detailed store/read logging
15
+
16
+ async locateNodes(targetKey, number = this.constructor.k, includeSelf = false) { // Promise up to k best Contacts for targetKey (sorted closest first).
15
17
  // Side effect is to discover other nodes (and they us).
18
+ // includeSelf: If true, the local node is included as a candidate (useful for finding storage locations).
16
19
  targetKey = await this.ensureKey(targetKey);
17
- return await this.iterate(targetKey, 'findNodes', number);
20
+ return await this.iterate(targetKey, 'findNodes', number, false, false, includeSelf);
18
21
  }
19
- async locateValue(targetKey) { // Promise value stored for targetKey, or undefined.
22
+ async locateValue(targetKey, additionalTries = 1) { // Promise value stored for targetKey, or undefined.
20
23
  // Side effect is to discover other nodes (and they us).
21
24
  targetKey = await this.ensureKey(targetKey);
25
+ const trace = this.constructor.diagnosticTrace;
22
26
 
23
27
  // Optimization: should still work without this, but then there are more RPCs.
24
28
  const found = this.retrieveLocally(targetKey);
25
- if (found !== undefined) return found;
29
+ if (found !== undefined) {
30
+ if (trace) this.log(`locateValue(${targetKey}): found locally =>`, found);
31
+ return found;
32
+ }
26
33
 
27
34
  const result = await this.iterate(targetKey, 'findValue');
28
- if (Node.isValueResult(result)) return result.value;
35
+ if (Node.isValueResult(result)) {
36
+ if (trace) {
37
+ const responderInfo = result.responder ? ` (from ${result.responder.name})` : '';
38
+ this.log(`locateValue(${targetKey}): found in network =>`, result.value, responderInfo);
39
+ }
40
+ return result.value;
41
+ }
42
+ // Always log failures - this helps debug sporadic read failures
43
+ const queried = result.map(h => h.name).join(', ');
44
+ const distances = result.slice(0, 5).map(h => String(h.distance).length).join(',');
45
+ this.log(`locateValue(${targetKey}): NOT FOUND - queried ${result.length} nodes: ${queried} (dist digits: ${distances}...)`);
46
+
47
+ // Check which of the queried nodes actually have the value (for debugging)
48
+ const nodesWithValue = result.filter(h => h.node?.retrieveLocally(targetKey) !== undefined);
49
+ if (nodesWithValue.length > 0) {
50
+ this.log(` BUG: ${nodesWithValue.length} queried nodes actually have the value: ${nodesWithValue.map(h => h.name).join(', ')}`);
51
+ }
29
52
  return undefined;
30
53
  }
31
54
  async storeValue(targetKey, value) { // Convert targetKey to a bigint if necessary, and store k copies.
32
55
  // Promises the number of nodes that it was stored on.
33
56
  targetKey = await this.ensureKey(targetKey);
57
+ const trace = this.constructor.diagnosticTrace;
58
+
59
+ // Early exit if this node is no longer running (e.g., disconnected during scheduled replication)
60
+ if (!this.isRunning) {
61
+ if (trace) this.log(`storeValue(${targetKey}, ${value}): aborted - node disconnected`);
62
+ return 0;
63
+ }
64
+
34
65
  // Go until we are sure have written k.
35
66
  const k = this.constructor.k;
36
67
  let remaining = k;
37
68
  // Ask for more, than needed, and then store to each, one at a time, until we
38
69
  // have replicated k times.
39
- let helpers = await this.locateNodes(targetKey, remaining * 2);
70
+ let helpers = await this.locateNodes(targetKey, remaining * 2, true); // includeSelf: we're a valid storage location
71
+
72
+ // Check again after the async locateNodes call
73
+ if (!this.isRunning) {
74
+ if (trace) this.log(`storeValue(${targetKey}, ${value}): aborted after locateNodes - node disconnected`);
75
+ return 0;
76
+ }
77
+
78
+ if (trace) this.log(`storeValue(${targetKey}): locateNodes found ${helpers.length} helpers`);
40
79
  helpers = helpers.reverse(); // So we can save best-first by popping off the end.
80
+ const storedTo = []; // Track where we stored for diagnostics
41
81
  // TODO: batches in parallel, if the client and network can handle it. (For now, better to spread it out.)
42
82
  while (helpers.length && remaining) {
43
- const contact = helpers.pop().contact;
83
+ const helper = helpers.pop();
84
+ const contact = helper.contact;
44
85
  const stored = await contact.store(targetKey, value);
45
- if (stored) remaining--;
86
+ if (stored) {
87
+ remaining--;
88
+ storedTo.push(helper.name);
89
+ } else if (!this.isRunning) {
90
+ // Node disconnected mid-replication - no point continuing
91
+ if (trace) this.log(`storeValue(${targetKey}, ${value}): aborted mid-store - node disconnected`);
92
+ return k - remaining;
93
+ }
94
+ }
95
+ const storedCount = k - remaining;
96
+ if (trace || storedCount < k) {
97
+ // Explain why we got fewer than k stores
98
+ let reason = '';
99
+ if (!this.isRunning) {
100
+ reason = ' (node disconnected)';
101
+ } else if (helpers.length === 0 && storedCount < k) {
102
+ reason = ' (insufficient nodes found)';
103
+ }
104
+ this.log(`storeValue(${targetKey}, ${value}): stored to ${storedCount}/${k} nodes${storedTo.length ? ': ' + storedTo.join(', ') : ''}${reason}`);
46
105
  }
47
106
  return k - remaining;
48
107
  }
@@ -70,86 +129,4 @@ export class Node extends NodeProbe {
70
129
  this.log('joined', contact.sname);
71
130
  return this.contact; // Answering this node's home contact is handy for chaining or keeping track of contacts being made and joined.
72
131
  }
73
-
74
-
75
- // TODO: separate all this out: neither transport nor dht.
76
- // TODO: fragment/reassemble big messages.
77
- messagePromises = new Map(); // maps message messageTags => responders, separately from inFlight, above
78
- async message({targetKey, targetSname, excluded = [], requestTag, senderKey, senderSname = this.contact.sname, payload}) { // Send message to targetKey, using our existing routingTable contacts.
79
- //const MAX_PING_MS = 400;
80
- const MAX_MESSAGE_MS = 2e3;
81
- let responsePromise = null;
82
- if (!requestTag) { // If this is an outgoing request from us, promises the response.
83
- requestTag = uuidv4();
84
- senderKey = this.key;
85
- senderSname = this.contact.sname;
86
- responsePromise = new Promise(resolve => this.messagePromises.set(requestTag, resolve));
87
- }
88
- targetKey = targetKey.toString();
89
- senderKey = senderKey?.toString();
90
- // The excluded list of keys prevents cycles.
91
- excluded.push(this.key.toString()); // TODO: Find a way to not have an excluded list, or at least add junk for privacy.
92
- const body = JSON.stringify({targetKey, excluded, requestTag, senderKey, targetSname, senderSname, payload});
93
- const contacts = this.findClosestHelpers(BigInt(targetKey)).map(helper => helper.contact).filter(contact => contact.key !== this.key);
94
- this.log('=>', targetSname, 'message', requestTag, 'contacts:', contacts.map(c => c.sname));
95
- for (const contact of contacts) {
96
- if (excluded.includes(contact.key.toString())) { this.log('skiping excluded', contact.sname, 'for message', requestTag); continue; }
97
- //this.xlog('trying message through', contact.sname);
98
- if (!contact.connection) { this.log('skipping unconnected', contact.sname, 'for message', requestTag); continue; }
99
- if (!(await contact.sendRPC('ping', contact.key))) {
100
- this.xlog('failed to get ping', contact.sname, 'for message', requestTag);
101
- await this.removeContact(contact);
102
- continue;
103
- }
104
- let overlay = await contact.overlay;
105
- //this.xlog('hopping through', contact.sname, 'for message', requestTag, 'from', senderSname, 'to', targetSname);
106
- overlay.send(body);
107
- // FIXME: how do we know if a response was ultimately delivered? Don't we need a confirmation for that so that we can try a different route?
108
- const result = await responsePromise;
109
- //this.xlog('got result for message', requestTag, 'through', contact.sname);
110
- //const result = await Promise.race([responsePromise, Node.delay(MAX_MESSAGE_MS, 'fixme')]);
111
- // if (result === 'fixme') {
112
- // this.xlog(`message timeout to ${targetSname} via ${contact.sname}.`);
113
- // continue; // If we timeout, responsePromise is still valid.
114
- // }
115
- return result;
116
- break;
117
- }
118
- this.xlog(`No connected contacts to send message ${requestTag} to ${targetSname} among ${contacts.map(c => `${excluded.includes(c.key.toString()) ? 'excluded/' : (c.connection ? '' : 'unconnected/')}${c.sname}`).join(', ')}`);
119
- return null;
120
- }
121
- async messageHandler(dataString) {
122
- //this.log('got overlay', dataString);
123
- const data = JSON.parse(dataString);
124
- const {targetKey, excluded, requestTag, senderKey, targetSname, senderSname, payload} = data;
125
- Node.assert(targetKey, 'No targetKey to handle', dataString);
126
- Node.assert(payload, 'No payload to handle', dataString);
127
- Node.assert(requestTag, 'No request tag by which to answer', dataString);
128
- this.log('handling message', requestTag, 'from', senderSname, 'to', targetSname);
129
-
130
- // If intended for another node, pass it on.
131
- if (BigInt(targetKey) !== this.key) return await this.message(data);
132
-
133
- // If it is a response to something we sent, resolve the waiting promise with the payload.
134
- const responder = this.messagePromises.get(requestTag);
135
- this.messagePromises.delete(requestTag);
136
- if (responder) return responder(payload);
137
-
138
- // Finally, answer a request to us by messaging the sender with the same-tagged response.
139
- const [method, ...args] = payload;
140
- if (!(method in this)) return this.xlog('ignoring unrecognized request', {requestTag, method, args}); // Could be a double response.
141
-
142
- Node.assert(args[0] === senderSname, 'FIXME sender does not match signals payload sender', dataString);
143
- this.log('executing', method, 'from', senderSname, 'request:', requestTag);
144
- let response = await this[method](...args);
145
- Node.assert(senderKey, 'No sender key by which to answer', dataString);
146
- this.log('responding to', method, 'from', senderSname, 'request:', requestTag);
147
- return await this.message({targetKey: BigInt(senderKey), targetSname: senderSname, requestTag, payload: response});
148
- }
149
- async signal(...rest) {
150
- //this.xlog(`handling signals @ ${this.key} ${rest}`);
151
- const fixme = await this.contact.signals(...rest);
152
- //this.xlog(`returning signals @ ${this.key} ${fixme}`);
153
- return fixme;
154
- }
155
132
  }
@@ -72,25 +72,27 @@ export class NodeContacts extends NodeTransports {
72
72
  findContactByKey(key) { // findContact matching the specified key. To be found, contact must be in routingTable or looseTransports (which is different from existingContact()).
73
73
  return this.findContact(contact => contact.key === key);
74
74
  }
75
- ensureContact(contact, sponsor = null) { // Return existing contact, if any (including looseTransports), else clone a new one for this host. Set sponsor.
75
+ ensureContact(contact, sponsor) { // Return existing contact, if any (including looseTransports), else clone a new one for this host. Set sponsor.
76
+ // I.e., a Contact with node: contact.node and host: this.
76
77
  // Subtle: Contact clone uses existingContact (above) to reuse an existing contact on the host, if possible.
77
78
  // This is vital for bookkeeping through connections and sponsorship.
78
79
  contact = contact.clone(this);
79
- contact.noteSponsor(sponsor);
80
+ if (sponsor) contact.noteSponsor(sponsor);
80
81
  return contact;
81
82
  }
82
83
  routingTableSerializer = Promise.resolve();
83
84
  queueRoutingTableChange(thunk) { // Promise to resolve thunk() -- after all previous queued thunks have resolved.
84
85
  return this.routingTableSerializer = this.routingTableSerializer.then(thunk);
85
86
  }
86
- removeContact(contact) { // Removes from node entirely if present, from looseTransports or bucket as necessary.
87
+ removeContact(contact) { // Removes from node entirely if present, from looseTransports or bucket as necessary, returning bucket if that's where it was, else null.
87
88
  return this.queueRoutingTableChange(() => {
88
89
  delete this.contactDictionary[contact.name];
89
90
  const key = contact.key;
90
- if (this.removeLooseTransport(key)) return;
91
+ if (this.removeLooseTransport(key)) return null;
91
92
  const bucketIndex = this.getBucketIndex(key);
92
93
  const bucket = this.routingTable.get(bucketIndex);
93
- bucket?.removeKey(key); // Host might not yet have added node or anyone else as contact for that bucket yet, so maybe no bucket.
94
+ // Host might not yet have added node or anyone else as contact for that bucket yet, so maybe no bucket.
95
+ return bucket?.removeKey(key) ? bucket : null;
94
96
  });
95
97
  }
96
98
  addToRoutingTable(contact) { // Promise contact, and add it to the routing table if room.
@@ -1,15 +1,19 @@
1
1
  import { NodeContacts } from './nodeContacts.js';
2
+ import { Contact } from '../transports/contact.js';
2
3
 
3
4
  // The four methods we recevieve through RPCs.
4
5
  // These are not directly invoked by a Node on itself, but rather on other nodes
5
6
  // through Contact sendRPC.
6
7
  export class NodeMessages extends NodeContacts {
7
8
  ping(key) { // Respond with 'pong'. (RPC mechanism doesn't call unless connected.)
8
- return 'pong';
9
+ return 'pong'; // Answer something truthy. See isValueResult.
9
10
  }
10
11
  store(key, value) { // Tell the node to store key => value, returning truthy.
12
+ if (this.constructor.diagnosticTrace) {
13
+ this.log(`store RPC received: key=${key}, value=${value}`);
14
+ }
11
15
  this.storeLocally(key, value);
12
- return 'pong';
16
+ return 'pong'; // Answer something truthy. See isValueResult.
13
17
  }
14
18
  findNodes(key) { // Return k closest Contacts from routingTable.
15
19
  // TODO: Currently, this answers a list of Helpers. For security, it should be changed to a list of serialized Contacts.
@@ -21,9 +25,45 @@ export class NodeMessages extends NodeContacts {
21
25
  if (value !== undefined) return {value};
22
26
  return this.findClosestHelpers(key);
23
27
  }
24
- receiveRPC(method, sender, ...rest) {
25
- // The sender exists, so add it to the routing table, but give it a while to finish joining.
28
+ async signals(key, signals, forwardingExclusions = false) {
29
+ const origin = signals[0];
30
+ //this.xlog(this.key, 'handling signals request for', {origin, key, signals, forwardingExclusions});
31
+ //await this.constructor.delay(100); // fixme remove
32
+ if (!this.isRunning) return null;
33
+ if (this.key === key) return await this.contact.signals(...signals); // Yay, us!
34
+
35
+ let contact = this.findContactByKey(key); // If we have the target as a contact, use it directly.
36
+ if (!contact && !forwardingExclusions) return null;
37
+ if (contact) return await contact.sendRPC('signals', key, signals, forwardingExclusions);
38
+ // Forward recursively.
39
+ const contacts = this.findClosestHelpers(key).map(helper => helper.contact);
40
+ forwardingExclusions.push(this.name);
41
+ //this.xlog('forwarding signals', {key, forwardingExclusions, contacts: contacts.map(c => c.sname)});
42
+ for (const contact of contacts) {
43
+ // this.xlog('contact', contact.sname, contact.isRunning ? 'running' : 'dead',
44
+ // contact.connection ? 'connected': 'unconnected',
45
+ // forwardingExclusions.includes(contact.name) ? 'excluded' : 'allowed');
46
+ if (!contact.isRunning) continue;
47
+ if (!contact.connection) continue;
48
+ if (forwardingExclusions.includes(contact.name)) continue;
49
+ const response = await contact.sendRPC('signals', key, signals, forwardingExclusions);
50
+ //this.xlog('got response from', contact.sname);
51
+ if (response) return response;
52
+ }
53
+ return null;
54
+ }
55
+
56
+ messageResolvers = new Map(); // maps outgoing message tag => promise resolver being waited on.
57
+ receiveRPC(method, sender, ...rest) { // Process a deserialized RPC request, dispatching it to one of the above.
58
+ this.constructor.assert(typeof(method)==='string', 'no method', method, sender, rest);
59
+ this.constructor.assert(sender instanceof Contact, 'no sender', method, sender, rest);
60
+ this.constructor.assert(sender.host.key === this.key, 'sender', sender.host.name, 'not on receiver', this.name);
61
+ // The sender exists, so add it to the routing table, but asynchronously so as to allow it to finish joining.
26
62
  this.addToRoutingTable(sender);
63
+ if (!(method in this)) {
64
+ this.xlog('Does not handle method', method);
65
+ return null;
66
+ }
27
67
  return this[method](...rest);
28
68
  }
29
69
  }