UNPKG

wikimedia-kad-fork

Version:

implementation of the kademlia dht for node

573 lines (485 loc) 16 kB
'use strict'; var assert = require('assert'); var async = require('async'); var _ = require('lodash'); var constants = require('./constants'); var utils = require('./utils'); var Message = require('./message'); var Logger = require('./logger'); var Bucket = require('./bucket'); var Contact = require('./contact'); var Item = require('./item'); /** * Represents a routing table of known {@link Contact}s; used by {@link Node}. * @constructor * @param {Object} options * @param {Logger} options.logger - Logger instance to use * @param {RPC} options.transport - Transport adapter (RPC) to use * @param {Function} options.validator - Key-Value validation function */ function Router(options) { if (!(this instanceof Router)) { return new Router(options); } this._log = options.logger || new Logger(4); this._buckets = {}; this._rpc = options.transport; this._self = this._rpc._contact; this._validator = options.validator; } /** * Execute this router's find operation with the shortlist * @param {String} type - One of "NODE" or "VALUE" * @param {String} key - Key to use for lookup * @param {Function} callback */ Router.prototype.lookup = function(type, key, callback) { assert(['NODE', 'VALUE'].indexOf(type) !== -1, 'Invalid search type'); var state = this._createLookupState(type, key); if (!state.closestNode) { return callback(new Error('Not connected to any peers')); } state.closestNodeDistance = utils.getDistance( state.hashedKey, state.closestNode.nodeID ); this._log.debug('performing network walk for %s %s', type, key); this._iterativeFind(state, state.shortlist, callback); }; /** * Creates a state machine for a lookup operation * @private * @param {String} type - One of 'NODE' or 'VALUE' * @param {String} key - 160 bit key * @returns {Object} Lookup state machine */ Router.prototype._createLookupState = function(type, key) { var state = { type: type, key: key, hashedKey: utils.createID(key), limit: constants.ALPHA, previousClosestNode: null, contacted: {}, foundValue: false, value: null, contactsWithoutValue: [] }; state.shortlist = this.getNearestContacts( key, state.limit, this._self.nodeID ); state.closestNode = state.shortlist[0]; return state; }; /** * Execute the find operation for this router type * @private * @param {Object} state - State machine returned from _createLookupState() * @param {Array} contacts - List of contacts to query * @param {Function} callback */ Router.prototype._iterativeFind = function(state, contacts, callback) { var self = this; this._log.debug('starting contact iteration for key %s', state.key); async.each(contacts, this._queryContact.bind(this, state), function() { self._log.debug('finished iteration, handling results'); self._handleQueryResults(state, callback); }); }; /** * Send this router's RPC message to the contact * @private * @param {Object} state - State machine returned from _createLookupState() * @param {Contact} contact - Contact to query * @param {Function} callback */ Router.prototype._queryContact = function(state, contactInfo, callback) { var self = this; var contact = this._rpc._createContact(contactInfo); var message = new Message({ method: 'FIND_' + state.type, params: { key: state.key, contact: this._self } }); this._log.debug('querying %s for key %s', contact.nodeID, state.key); this._rpc.send(contact, message, function(err, response) { if (err) { self._log.warn( 'query failed, removing contact for shortlist, reason %s', err.message ); self._removeFromShortList(state, contact.nodeID); self.removeContact(contact); return callback(); } self._handleFindResult(state, response, contact, callback); }); }; /** * Handle the results of the contact query * @private * @param {Object} state - State machine returned from _createLookupState() * @param {Message} message - Received response to FIND_* RPC * @param {Contact} contact - Sender of the message * @param {Function} callback */ Router.prototype._handleFindResult = function(state, msg, contact, callback) { var distance = utils.getDistance(state.hashedKey, contact.nodeID); state.contacted[contact.nodeID] = this.updateContact(contact); if (utils.compareKeys(distance, state.closestNodeDistance) === -1) { state.previousClosestNode = state.closestNode; state.closestNode = contact; state.closestNodeDistance = distance; } if (state.type === 'NODE') { this._addToShortList(state, msg.result.nodes); return callback(); } if (!msg.result.item) { state.contactsWithoutValue.push(contact); this._addToShortList(state, msg.result.nodes); return callback(); } this._validateFindResult(state, msg, contact, callback); }; /** * Validates the data returned from a find * @private * @param {Object} state - State machine returned from _createLookupState() * @param {Message} message - Received response to FIND_* RPC * @param {Contact} contact - Sender of the message * @param {Function} callback */ Router.prototype._validateFindResult = function(state, msg, contact, done) { var self = this; var item = msg.result.item; function rejectContact() { self._removeFromShortList(state, contact.nodeID); self.removeContact(contact); done(); } this._log.debug('validating result from %s', contact.nodeID); this._validateKeyValuePair(state.key, item.value, function(valid) { if (!valid) { self._log.warn('failed to validate key/value pair for %s', state.key); return rejectContact(); } state.foundValue = true; state.value = item.value; state.item = item; done(); }); }; /** * Add contacts to the shortlist, preserving nodeID uniqueness * @private * @param {Object} state - State machine returned from _createLookupState() * @param {Array} contacts - Contacts to add to the shortlist */ Router.prototype._addToShortList = function(state, contacts) { assert(Array.isArray(contacts), 'No contacts supplied'); state.shortlist = state.shortlist.concat(contacts); state.shortlist = _.uniq(state.shortlist, false, 'nodeID'); }; /** * Remove contacts with the nodeID from the shortlist * @private * @param {Object} state - State machine returned from _createLookupState() * @param {String} nodeID - Node ID of the contact to remove */ Router.prototype._removeFromShortList = function(state, nodeID) { state.shortlist = _.reject(state.shortlist, function(c) { return c.nodeID === nodeID; }); }; /** * Handle the results of all the executed queries * @private * @param {Object} state - State machine returned from _createLookupState() * @param {Function} callback */ Router.prototype._handleQueryResults = function(state, callback) { if (state.foundValue) { this._log.debug('a value was returned from query %s', state.key); return this._handleValueReturned(state, callback); } var closestNodeUnchanged = state.closestNode === state.previousClosestNode; var shortlistFull = state.shortlist.length >= constants.K; if (closestNodeUnchanged || shortlistFull) { this._log.debug( 'shortlist is full or there are no known nodes closer to key %s', state.key ); return callback(null, 'NODE', state.shortlist); } var remainingContacts = _.reject(state.shortlist, function(c) { return state.contacted[c.nodeID]; }); if (remainingContacts.length === 0) { this._log.debug('there are no more remaining contacts to query'); return callback(null, 'NODE', state.shortlist); } this._log.debug('continuing with iterative query for key %s', state.key); this._iterativeFind( state, remainingContacts.splice(0, constants.ALPHA), callback ); }; /** * Handle a value being returned and store at closest nodes that didn't have it * @private * @param {Object} state - State machine returned from _createLookupState() * @param {Function} callback */ Router.prototype._handleValueReturned = function(state, callback) { var self = this; var distances = state.contactsWithoutValue.map(function(contact) { return { distance: utils.getDistance(contact.nodeID, self._self.nodeID), contact: contact }; }); distances.sort(function(a, b) { return utils.compareKeys(a.distance, b.distance); }); if (distances.length >= 1) { var item = state.item; var closestWithoutValue = distances[0].contact; var message = new Message({ method: 'STORE', params: { item: new Item(item.key, item.value, item.publisher, item.timestamp), contact: this._self } }); this._rpc.send(closestWithoutValue, message); } callback(null, 'VALUE', state.value); }; /** * Refreshes the buckets farther than the closest known * @param {Array} contacts - Results returned from findNode() * @param {Function} done */ Router.prototype.refreshBucketsBeyondClosest = function(contacts, done) { var bucketIndexes = Object.keys(this._buckets); var leastBucket = _.min(bucketIndexes); function bucketFilter(index) { return index >= leastBucket; } var refreshBuckets = bucketIndexes.filter(bucketFilter); var queue = async.queue(this.refreshBucket.bind(this), 1); this._log.debug('refreshing buckets farthest than closest known'); refreshBuckets.forEach(function(index) { queue.push(index); }); done(); }; /** * Refreshes the bucket at the given index * @param {Number} index * @param {Function} callback */ Router.prototype.refreshBucket = function(index, callback) { var random = utils.getRandomInBucketRangeBuffer(index); this.findNode(random.toString('hex'), callback); }; /** * Search contacts for the value at given key * @param {String} key * @param {Function} callback */ Router.prototype.findValue = function(key, callback) { var self = this; this._log.debug('searching for value at key %s', key); this.lookup('VALUE', key, function(err, type, value) { if (err || type === 'NODE') { return callback(new Error('Failed to find value for key: ' + key)); } self._log.debug('found value for key %s', key); callback(null, value); }); }; /** * Search contacts for nodes close to the given key * @param {String} nodeID * @param {Function} callback */ Router.prototype.findNode = function(nodeID, callback) { var self = this; this._log.debug('searching for nodes close to key %s', nodeID); this.lookup('NODE', nodeID, function(err, type, contacts) { if (err) { return callback(err); } self._log.debug('found %d nodes close to key %s', contacts.length, nodeID); callback(null, contacts); }); }; /** * Update the contact's status * @param {Contact} contact - Contact to update * @param {Function} callback - Optional completion calback * @returns {Contact} */ Router.prototype.updateContact = function(contact, callback) { var bucketIndex = utils.getBucketIndex(this._self.nodeID, contact.nodeID); this._log.debug('updating contact %j', contact); assert(bucketIndex < constants.B); if (!this._buckets[bucketIndex]) { this._log.debug('creating new bucket for contact at index %d', bucketIndex); this._buckets[bucketIndex] = new Bucket(); } var bucket = this._buckets[bucketIndex]; contact.seen(); if (bucket.hasContact(contact.nodeID)) { this._moveContactToTail(contact, bucket, callback); } else if (bucket.getSize() < constants.K) { this._moveContactToHead(contact, bucket, callback); } else { this._pingContactAtHead(contact, bucket, callback); } return contact; }; Router.prototype.removeContact = function(contact, callback) { var bucketIndex = utils.getBucketIndex(this._self.nodeID, contact.nodeID); this._log.debug('removing contact %j', contact); assert(bucketIndex < constants.B); if (!this._buckets[bucketIndex]) { this._log.debug('creating new bucket for contact at index %d', bucketIndex); this._buckets[bucketIndex] = new Bucket(); } var bucket = this._buckets[bucketIndex]; bucket.removeContact(contact); return callback && callback(); }; /** * Move the contact to the bucket's tail * @private * @param {Contact} contact * @param {Bucket} bucket * @param {Function} callback */ Router.prototype._moveContactToTail = function(contact, bucket, callback) { this._log.debug('contact already in bucket, moving to tail'); bucket.removeContact(contact); bucket.addContact(contact); if (callback) { callback(); } }; /** * Move the contact to the bucket's head * @private * @param {Contact} contact * @param {Bucket} bucket * @param {Function} callback */ Router.prototype._moveContactToHead = function(contact, bucket, callback) { this._log.debug('contact not in bucket, moving to head'); bucket.addContact(contact); if (callback) { callback(); } }; /** * Ping the contact at head and if no response, replace with contact * @private * @param {Contact} contact * @param {Bucket} bucket * @param {Function} callback */ Router.prototype._pingContactAtHead = function(contact, bucket, callback) { var self = this; var ping = new Message({ method: 'PING', params: { contact: this._self } }); var head = bucket.getContact(0); this._log.debug('no room in bucket, sending PING to contact at head'); this._rpc.send(head, ping, function(err) { if (err) { self._log.debug('head contact did not respond, replacing with new'); bucket.removeContact(head); bucket.addContact(contact); } if (callback) { callback(); } }); }; /** * Return contacts closest to the given key * @param {String} key * @param {Number} limit - Maximum number of contacts to return * @param {String} nodeID * @returns {Array} */ Router.prototype.getNearestContacts = function(key, limit, nodeID) { var self = this; var contacts = []; var index = utils.getBucketIndex(this._self.nodeID, utils.createID(key)); var ascBucketIndex = index; var descBucketIndex = index; function addNearestFromBucket(bucket) { self._getNearestFromBucket( bucket, utils.createID(key), limit - contacts.length ).forEach(function addToContacts(contact) { var isContact = contact instanceof Contact; var poolNotFull = contacts.length < limit; var notRequester = contact.nodeID !== nodeID; if (isContact && poolNotFull && notRequester) { contacts.push(contact); } }); } addNearestFromBucket(this._buckets[index]); while (contacts.length < limit && ascBucketIndex < constants.B) { ascBucketIndex++; addNearestFromBucket(this._buckets[ascBucketIndex]); } while (contacts.length < limit && descBucketIndex >= 0) { descBucketIndex--; addNearestFromBucket(this._buckets[descBucketIndex]); } return contacts; }; /** * Get the contacts closest to the key from the given bucket * @private * @param {Bucket} bucket * @param {String} key * @param {Number} limit * @returns {Array} */ Router.prototype._getNearestFromBucket = function(bucket, key, limit) { if (!bucket) { return []; } var nearest = bucket.getContactList().map(function addDistance(contact) { return { contact: contact, distance: utils.getDistance(contact.nodeID, key) }; }).sort(function sortKeysByDistance(a, b) { return utils.compareKeys(a.distance, b.distance); }).splice(0, limit).map(function pluckContact(c) { return c.contact; }); return nearest; }; /** * Validates a key/value pair (defaults to true) * @private * @param {String} key * @param {String} value * @param {Function} callback */ Router.prototype._validateKeyValuePair = function(key, value, callback) { if (typeof this._validator === 'function') { return this._validator(key, value, callback); } callback(true); }; module.exports = Router;