UNPKG

spray-wrtc

Version:

Adaptive random peer-sampling protocol running on top of WebRTC

530 lines (498 loc) 18.1 kB
'use strict' const debug = (require('debug'))('spray-wrtc') const N2N = require('n2n-overlay-wrtc') const merge = require('lodash.merge') const PartialView = require('./partialview.js') const MExchange = require('./messages/mexchange.js') const MJoin = require('./messages/mjoin.js') const MLeave = require('./messages/mleave.js') const ExMessage = require('./exceptions/exmessage.js') const ExJoin = require('./exceptions/exjoin.js') /** * Implementation of the random peer-sampling Spray. */ class Spray extends N2N { /** * You can pass other parameters such as webrtc options * @param {object} [options = {}] Object with all options * @param {string} [options.pid = 'spray-wrtc'] The identifier of this * protocol. * @param {number} [options.delta] Every delta milliseconds, Spray shuffles * its partial view with its oldest neighbor. * @param {number} [options.a = 1] The number of arcs at each peer converges * to a*log(N) + b, where N is the number of peers in the network. * @param {nubmer} [options.b = 0] See above. */ constructor (options = {}) { // #0 initialize our N2N-parent super(merge({ pid: 'spray-wrtc', delta: 1000 * 60 * 2, timeout: 1000 * 60 * 1, a: 1, b: 0, retry: 5 }, options)) // #1 constants (from N2N) // this.PID = protocol identifier // this.PEER = peer Id comprising inview and outview Ids debug('[%s] Initalized with ==> %s ==>', this.PID, this.PEER) // #2 initialize the partial view containing ages this.partialView = new PartialView() // #3 initialize the connectedness state of this protocol this.state = 'disconnected' // #4 periodic shuffling this.periodic = null // #5 events this.on('receive', (peerId, message) => this._receive(peerId, message)) // this.on('stream', (peerId, message) => { } ); // (TODO) ?; this.on('open', (peerId) => { this._open(peerId) this._updateState() }) this.on('close', (peerId) => { this._close(peerId) this._updateState() }) this.on('fail', (peerId) => { this._onArcDown(peerId) this._updateState() }) }; /** * @private Start periodic shuffling. */ _start (delay = this.options.delta) { this.periodic = setInterval(() => { this._exchange() }, delay) }; /** * @private Stop periodic shuffling. */ _stop () { clearInterval(this.periodic) }; /** * @private Called each time this protocol receives a message. * @param {string} peerId The identifier of the peer that sent the message. * @param {object|MExchange|MJoin} message The message received. */ _receive (peerId, message) { if (message.type && message.type === 'MExchange') { this._onExchange(peerId, message) } else if (message.type && message.type === 'MJoin') { this._onJoin(peerId) } else if (message.type && message.type === 'MLeave') { this._onLeave(peerId) } else { throw new ExMessage('_receive', message, 'unhandled') }; }; /** * @private Behavior when a connection is ready to be added in the partial * view. * @param {string} peerId The identifier of the new neighbor. */ _open (peerId) { debug('[%s] Open %s ===> %s', this.PID, this.PEER, peerId) this.partialView.add(peerId) }; /** * @private Behavior when a connection is closed. * @param {string} peerId The identifier of the removed arc. */ _close (peerId) { debug('[%s] Close %s =†=> %s', this.PID, this.PEER, peerId) // wait 5 seconds before checking if the peer is still in the partialView or // NOTE: we do this cause of concurrency, The connection could be correctly deleted and if we delete the connection sooner, the number of arcs reinjected could be higher than expected. setTimeout(() => { if (!this.o.has(peerId) && this.partialView.has(peerId)) { this._onPeerDown(peerId) }; }, 5000) }; /** * @private Update the connectedness state of the peer. */ _updateState () { const remember = this.state if (this.i.size > 0 && this.o.size > 0 && remember !== 'connected') { this.state = 'connected' } else if (((this.i.size > 0 && this.o.size <= 0) || (this.o.size > 0 && this.i.size <= 0)) && remember !== 'partially connected') { this.state = 'partially connected' } else if (this.i.size <= 0 && this.o.size <= 0 && remember !== 'disconnected') { this.state = 'disconnected' // this._stop(); }; (remember !== this.state) && this.emit('statechange', this.state) }; /** * Joining a network. * @param {callback} sender Function that will be called each time an offer * arrives to this peer. It is the responsability of the caller to send * these offer (using sender) to the contact inside the network. * @returns {Promise} A promise that is resolved when the peer joins the * network -- the resolve contains the peerId; rejected after a timeout, or * already connected state. */ join (sender) { let result = new Promise((resolve, reject) => { // #0 connectedness state check (this.state !== 'disconnected') && reject(new ExJoin('join', 'Already connected.')) // #1 set timeout before reject let to = setTimeout(() => { reject(new ExJoin('join', 'Timeout exceeded.')) }, this.options.timeout) // #2 very first call, only done once this.once('open', (peerId) => { this.send(peerId, new MJoin(), this.options.retry) .then(() => { clearTimeout(to) this._start() // start shuffling process this._inject(this.options.a - 1, 0, peerId) resolve(peerId) }).catch(() => { reject(new ExJoin('join', 'Could not notify remote contact.')) }) }) }) // #3 engage the very first connection of this peer this.connect(sender) return result }; /** * @private Behavior of the contact peer when a newcomer arrives. * @param {string} peerId The identifier of the newcomer. */ _onJoin (peerId) { // cause of crash and rapid refresh // some connection can stay in the partialView after a crash // This appears in a 2-peers network where one of them refresh its "page". // We receive the join event before the 'close' event // we need to delete it before engaging the _onJoin process. this._checkPartialView() if (this.partialView.size > 0) { // #1 all neighbors -> peerId debug('[%s] %s ===> join %s ===> %s neighbors', this.PID, peerId, this.PEER, this.partialView.size) this.partialView.forEach((ages, neighbor) => { ages.forEach((age) => { this.connect(neighbor, peerId) }) }) } else { // #2 Seems like a 2-peer network; this -> peerId; debug('[%s] %s ===> join %s ===> %s', this.PID, peerId, this.PEER, peerId) this._inject(2 * this.options.a, 2 * this.options.b, peerId) this._start() }; }; /** * Leave the network. If time is given, it tries to patch the network before * leaving. * @param {number} [time = 0] The time (in milliseconds) given to this peer * to patch the network before trully leaving. */ leave (time = 0) { // ugly way const saveNITimeout = this.NI.options.timeout const saveNOTimeout = this.NO.options.timeout this.NI.options.timeout = time this.NO.options.timeout = time // #0 stop shufflings this._stop() if (time > 0) { // #1 patch the network; in total must remove a.log(N) + b arcs // inview -> this -> outview becomes inview -> outview // #A flatten the inview and the outview let inview = this.getInview() let flattenI = [] inview.forEach((occ, peerId) => flattenI.push(peerId)) let outview = this.getOutview() let flattenO = [] outview.forEach((occ, peerId) => flattenO.push(peerId)) // #B process the number of arc to save // (TODO) double check this proportion let toKeep = outview.size - this.options.a // #C bridge connections // (TODO) check more than 2 in flattenI and flattenO is ≠ for (let i = 0; i < Math.floor(toKeep); ++i) { const rnI = Math.floor(Math.random() * flattenI.length) let different = flattenO .filter((peerId) => peerId !== flattenI[rnI]) if (different.length > 0) { const rnO = Math.floor(Math.random() * different.length) this.connect(flattenI[rnI], different[rnO]) }; }; // (TODO) add probabilistic bridging if toKeep is a floating number flattenI.forEach((peerId) => { this.send(peerId, new MLeave(), this.options.retry) .catch((e) => { }) }) flattenO.forEach((peerId) => { this._onLeave(peerId) }) } else { // #2 just leave this.partialView.clear() this.disconnect() }; this.NI.options.timeout = saveNITimeout this.NO.options.timeout = saveNOTimeout }; /** * @private A remote peer we target just left the network. We remove it from * our partial view. * @param {string} peerId The identifier of the peer that just left. */ _onLeave (peerId) { if (this.partialView.has(peerId)) { debug('[%s] %s ==> ††† %s †††', this.PID, this.PEER, peerId) const occ = this.partialView.removeAll(peerId) for (let i = 0; i < occ; ++i) { this.disconnect(peerId) }; }; }; /** * Get k neighbors from the partial view. If k is not reached, it tries to * fill the gap with neighbors from the inview. It is worth noting that * each peer controls its outview but not its inview. The more the neigbhors * from the outview the better. * @param {number} k The number of neighbors requested. If k is not defined, * it returns every known identifiers of the partial view. * @return {string[]} Array of identifiers. */ getPeers (k) { let peers = [] if (typeof k === 'undefined') { // #1 get all the partial view this.partialView.forEach((occ, peerId) => { peers.push(peerId) }) } else { // #2 get random identifier from outview let out = [] this.partialView.forEach((ages, peerId) => out.push(peerId)) while (peers.length < k && out.length > 0) { let rn = Math.floor(Math.random() * out.length) peers.push(out[rn]) out.splice(rn, 1) }; // #3 get random identifier from the inview to fill k-entries let inView = [] this.i.forEach((occ, peerId) => inView.push(peerId)) while (peers.length < k && inView.length > 0) { let rn = Math.floor(Math.random() * inView.length) peers.push(inView[rn]) inView.splice(rn, 1) }; }; debug('[%s] %s provides %s peers', this.PID, this.PEER, peers.length) return peers }; /* ********************************* * Spray's protocol implementation * ***********************************/ /** * @private Check the partial view, i.e., weither or not connections are * still up and usable. */ _checkPartialView () { let down = [] this.partialView.forEach((ages, peerId) => { if (!this.o.has(peerId)) { down.push(peerId) }; }) down.forEach((peerId) => { this._onPeerDown(peerId) }) }; /** * @private Get a sample of the partial view. * @param {string} [peerId] The identifier of the oldest neighbor chosen to * perform a view exchange. * @return {string[]} An array containing the identifiers of neighbors from * this partial view. */ _getSample (peerId) { let sample = [] // #1 create a flatten version of the partial view let flatten = [] this.partialView.forEach((ages, neighbor) => { ages.forEach((age) => { flatten.push(neighbor) }) }) // #2 process the size of the sample const sampleSize = Math.ceil(flatten.length / 2) // #3 initiator removes a chosen neighbor entry and adds it to sample if (typeof peerId !== 'undefined') { flatten.splice(flatten.indexOf(peerId), 1) sample.push(peerId) }; // #4 add neighbors to the sample chosen at random while (sample.length < sampleSize) { const rn = Math.floor(Math.random() * flatten.length) sample.push(flatten[rn]) flatten.splice(rn, 1) }; return sample }; /** * @private Periodically called function that aims to balance the partial * view and to mix the neighborhoods. */ _exchange () { this._checkPartialView() // #0 if the partial view is empty --- could be due to disconnections, // failure, or _onExchange started with other peers --- skip this round. if (this.partialView.size <= 0) { return } this.partialView.increment() const oldest = this.partialView.oldest // #1 send the notification to oldest that we perform an exchange this.send(oldest, new MExchange(this.getInviewId()), this.options.retry) .then(() => { // #A setup the exchange // #2 get a sample from our partial view let sample = this._getSample(oldest) debug('[%s] %s ==> exchange %s ==> %s', this.PID, this.PEER, sample.length, oldest) // #3 replace occurrences to oldest by ours sample = sample.map((peerId) => { return ((peerId === oldest) && this.getInviewId()) || peerId }) // #4 connect oldest -> sample sample.forEach((peerId) => { this.connect(oldest, peerId) }) // #5 remove our own connection sample = sample.map((peerId) => { return ((peerId === this.getInviewId()) && oldest) || peerId }) sample.forEach((peerId) => { this.disconnect(peerId) if (peerId === oldest) { this.partialView.removeOldest(peerId) } else { this.partialView.removeYoungest(peerId) }; }) }).catch((e) => { // #B the peer cannot be reached, he is supposedly dead debug('[%s] %s =X> exchange =X> %s', this.PID, this.PEER, oldest) this._onPeerDown(oldest) }) }; /** * @private Behavior when this peer receives a shuffling request. * @param {string} neighbor The identifier of the peer that sent this * exchange request. * @param {MExchange} message message containing the identifier of the peer * that started the exchange. */ _onExchange (neighbor, message) { this._checkPartialView() // #1 get a sample of neighbors from our partial view this.partialView.increment() let sample = this._getSample() debug('[%s] %s ==> exchange %s ==> %s', this.PID, neighbor, sample.length, this.PEER) // #2 replace occurrences of the initiator by ours sample = sample.map((peerId) => { if (peerId === message.inview) return this.getInviewId() return peerId }) // #3 establish connections sample.forEach((peerId) => { this.connect(neighbor, peerId) }) // #4 inverse replacement sample = sample.map((peerId) => { if (peerId === this.getInviewId()) return message.inview return peerId }) // #5 disconnect arcs sample.forEach((peerId) => { this.disconnect(peerId) this.partialView.removeYoungest(peerId) }) }; /** * @private The function called when a neighbor is unreachable and * supposedly crashed/departed. It probabilistically duplicates an arc. * @param {string} peerId The identifier of the peer that seems down. */ _onPeerDown (peerId) { debug('[%s] onPeerDown ==> %s ==> XXX %s XXX', this.PID, this.PEER, peerId) // #1 remove all occurrences of the peer in the partial view const occ = this.partialView.removeAll(peerId) // #2 probabilistically recreate arcs to a known peer // (TODO) double check this const proba = this.options.a / (this.partialView.size + occ) if (this.partialView.size > 0) { // #A normal behavior for (let i = 0; i < occ; ++i) { if (Math.random() > proba) { // probabilistically duplicate the least frequent peers this.connect(null, this.partialView.leastFrequent) } } } else { // #B last chance behavior (TODO) ask inview }; }; /** * @private A connection failed to establish properly, systematically * duplicates an element of the partial view. * @param {string|null} peerId The identifier of the peer we failed to * establish a connection with. Null if it was yet to be known. */ _onArcDown (peerId) { debug('[%s] ==> %s =X> %s', this.PID, this.PEER, peerId || 'unknown') if (this.partialView.size > 0) { // #1 normal behavior this.connect(null, this.partialView.leastFrequent) } else { // #2 last chance behavior // (TODO) ask inview // const rn = Math.floor(Math.random() * this.i.size); // let it = this.i.keys(); // this.II.connect(null, this.i. }; }; /** * @private Inject a*log(N) + b arcs leading to peerId. When parameters are * not integers, the floating part is added probabilistically. * @param {number} a a * log * @param {number} b + b * @param {string} peerId The identifier of the peer to duplicate. */ _inject (a, b, peerId) { let copyA = a for (let i = 0; i < Math.floor(a); ++i) { this.connect(null, peerId) copyA -= 1 }; if (Math.random() < copyA) { this.connect(null, peerId) }; let copyB = b for (let i = 0; i < Math.floor(b); ++i) { this.connect(null, peerId) copyB -= 1 }; if (Math.random() < copyB) { this.connect(null, peerId) }; }; }; module.exports = Spray