381 lines · 18.9 KB · JavaScript
RawDownload
"use strict";/*jslint nomen: true, plusplus: true, vars: true *//*global Messages, Script, MyAvatar, AvatarList, Entities, print */ //  Created by Howard Stearns//  Copyright 2016 High Fidelity, Inc.//  //  Distributed under the Apache License, Version 2.0.//  See the accompanying file LICENSE or http://www.apache.org/licenses/LICENSE-2.0.html////  Allows cooperating scripts to pass a "virtual baton" between them,//  which is useful when part of a script should only be executed by//  the one participant that is holding this particular baton.// //  A virtual baton is simply any string agreed upon by the scripts//  that use it. Only one script at a time can hold the baton, and it//  holds it until that script releases it, or the other scripts//  determine that the holding script is not responding. The script//  automatically determines who among claimants has the baton, if anyone,//  and holds an "election" if necessary.////  See entityScript/tribble.js as an example, and the functions//  virtualBaton(), claim(), release().// // Answers a new virtualBaton for the given parameters, of which 'key'// is required.function virtualBatonf(options) {    // Answer averages (number +/- variability). Avoids having everyone act in lockstep.    function randomize(number, variability) {        var allowedDeviation = number * variability; // one side of the deviation range        var allowedDeviationRange = allowedDeviation * 2; // total range for +/- deviation        var randomDeviation = Math.random() * allowedDeviationRange;        var result = number - allowedDeviation + randomDeviation;        return result;    }    // Allow testing outside in a harness outside of High Fidelity.    // See sourceCodeSandbox/tests/mocha/test/testVirtualBaton.js    var globals = options.globals || {},        messages = globals.Messages || Messages,        myAvatar = globals.MyAvatar || MyAvatar,        avatarList = globals.AvatarList || AvatarList,        entities = globals.Entities || Entities,        timers = globals.Script || Script,        log = globals.print || print;     var batonName = options.batonName,  // The identify of the baton.        // instanceId is the identify of this particular copy of the script among all copies using the same batonName        // in the domain. For example, if you wanted only one entity among multiple entity scripts to hold the baton,        // you could specify virtualBaton({batonName: 'someBatonName', instanceId: MyAvatar.sessionUUID + entityID}).        instanceId = options.instanceId || myAvatar.sessionUUID,        // virtualBaton() returns the exports object with properties. You can pass in an object to be side-effected.        exports = options.exports || {},        // Handy to set false if we believe the optimizations are wrong, or to use both values in a test harness.        useOptimizations = (options.useOptimizations === undefined) ? true : options.useOptimizations,        electionTimeout = options.electionTimeout || randomize(500, 0.2), // ms. If no winner in this time, hold a new election.        recheckInterval = options.recheckInterval || randomize(500, 0.2), // ms. Check that winners remain connected.        // If you supply your own instanceId, you might also supply a connectionTest that answers        // truthy iff the given id is still valid and connected, and is run at recheckInterval. You        // can use exports.validId (see below), and the default answers truthy if id is valid or a        // concatenation of two valid ids. (This handles the most common cases of instanceId being        // either (the default) MyAvatar.sessionUUID, an entityID, or the concatenation (in either        // order) of both.)        connectionTest = options.connectionTest || function connectionTest(id) {            var idLength = 38;            if (id.length === idLength) {                return exports.validId(id);            }            return (id.length === 2 * idLength) && exports.validId(id.slice(0, idLength)) && exports.validId(id.slice(idLength));        };     if (!batonName) {        throw new Error("A virtualBaton must specify a batonName.");    }    // Truthy if id exists as either a connected avatar or valid entity.    exports.validId = function validId(id) {        var avatar = avatarList.getAvatar(id);        if (avatar && (avatar.sessionUUID === id)) {            return true;        }        var properties = entities.getEntityProperties(id, ['type']);        return properties && properties.type;    };     // Various logging, controllable through options.    function debug() { // Display the arguments not just [Object object].        log.apply(null, [].map.call(arguments, JSON.stringify));    }    function debugFlow() {        if (options.debugFlow) {            debug.apply(null, arguments);        }    }    function debugSend(destination, operation, data) {        if (options.debugSend) {            debug('baton:', batonName, instanceId, 's=>', destination, operation, data);        }    }    function debugReceive(senderID, operation, data) { // senderID is client sessionUUID -- not necessarily instanceID!        if (options.debugReceive) {            debug('baton:', batonName, senderID, '=>r', instanceId, operation, data);        }    }     // Messages: Just synactic sugar for hooking things up to Messages system.    // We create separate subchannel strings for each operation within our general channelKey, instead of using    // a switch in the receiver.    var channelKey = "io.highfidelity.virtualBaton:" + batonName,        subchannelHandlers = {}, // Message channel string => {receiver, op}        subchannelKeys = {};     // operation => Message channel string    function subchannelKey(operation) {        return channelKey + ':' + operation;    }    function receive(operation, handler) { // Record a handler for an operation on our channelKey        var subKey = subchannelKey(operation);        subchannelHandlers[subKey] = {receiver: handler, op: operation};        subchannelKeys[operation] = subKey;        messages.subscribe(subKey);    }    function sendHelper(subchannel, data) {        var message = JSON.stringify(data);        messages.sendMessage(subchannel, message);    }    function send1(operation, destination, data) { // Send data for an operation to just one destination on our channelKey.        debugSend(destination, operation, data);        sendHelper(subchannelKey(operation) + destination, data);    }    function send(operation, data) {  // Send data for an operation on our channelKey.        debugSend('-', operation, data);        sendHelper(subchannelKeys[operation], data);    }    function messageHandler(channel, messageString, senderID) {        var handler = subchannelHandlers[channel];        if (!handler) {            return;        }        var data = JSON.parse(messageString);        debugReceive(senderID, handler.op, data);        handler.receiver(data);    }    messages.messageReceived.connect(messageHandler);     var nPromises = 0, nAccepted = 0, electionWatchdog;     // It would be great if we had a way to know how many subscribers our channel has. Failing that...    var nNack = 0, previousNSubscribers = 0, lastGathering = 0, thisTimeout = electionTimeout;    function nSubscribers() { // Answer the number of subscribers.        // To find nQuorum, we need to know how many scripts are being run using this batonName, which isn't        // the same as the number of clients!        //        // If we overestimate by too much, we may fail to reach consensus, which triggers a new        // election proposal, so we take the number of acceptors to be the max(nPromises, nAccepted)        // + nNack reported in the previous round.        //        // If we understimate by too much, there can be different pockets on the Internet that each        // believe they have agreement on different holders of the baton, which is precisely what        // the virtualBaton is supposed to avoid. Therefore we need to allow 'nack' to gather stragglers.         var now = Date.now(), elapsed = now - lastGathering;        if (elapsed >= thisTimeout) {            previousNSubscribers = Math.max(nPromises, nAccepted) + nNack;            lastGathering = now;        } // ...otherwise we use the previous value unchanged.         // On startup, we do one proposal that we cannot possibly close, so that we'll        // lock things up for the full electionTimeout to gather responses.        if (!previousNSubscribers) {            var LARGE_INTEGER = Number.MAX_SAFE_INTEGER || (-1 >>> 1); // QT doesn't define the ECMA constant. Max int will do for our purposes.            previousNSubscribers = LARGE_INTEGER;        }        return previousNSubscribers;    }     // MAIN ALGORITHM    //    // Internally, this uses the Paxos algorith to hold elections.    // Alternatively, we could have the message server pick and maintain a winner, but it would    // still have to deal with the same issues of verification in the presence of lost/delayed/reordered messages.    // Paxos is known to be optimal under these circumstances, except that its best to have a dedicated proposer    // (such as the server).    function betterNumber(number, best) {        return (number.number || 0) > best.number;    }    // Paxos Proposer behavior    var proposalNumber = 0,        nQuorum = 0,        bestPromise = {number: 0},        claimCallback,        releaseCallback;    function propose() {  // Make a new proposal, so that we learn/update the proposalNumber and winner.        // Even though we send back a 'nack' if the proposal is obsolete, with network errors        // there's no way to know for certain that we've failed. The electionWatchdog will try a new        // proposal if we have not been accepted by a quorum after election Timeout.        if (electionWatchdog) {            // If we had a means of determining nSubscribers other than by counting, we could just            // timers.clearTimeout(electionWatchdog) and not return.            return;        }        thisTimeout = randomize(electionTimeout, 0.5); // Note use in nSubcribers.        electionWatchdog = timers.setTimeout(function () {            electionWatchdog = null;            propose();        }, thisTimeout);        var nAcceptors = nSubscribers();        nQuorum = Math.floor(nAcceptors / 2) + 1;         proposalNumber = Math.max(proposalNumber, bestPromise.number) + 1;        debugFlow('baton:', batonName, instanceId, 'propose', proposalNumber,                  'claim:', !!claimCallback, 'nAcceptors:', nAcceptors, nPromises, nAccepted, nNack);        nPromises = nAccepted = nNack = 0;        send('prepare!', {number: proposalNumber, proposerId: instanceId});    }    // We create a distinguished promise subchannel for our id, because promises need only be sent to the proposer.    receive('promise' + instanceId, function (data) {        if (betterNumber(data, bestPromise)) {            bestPromise = data;        }        if ((data.proposalNumber === proposalNumber) && (++nPromises >= nQuorum)) { // Note check for not being a previous round            var answer = {number: data.proposalNumber, proposerId: data.proposerId, winner: bestPromise.winner}; // Not data.number.            if (!answer.winner || (answer.winner === instanceId)) { // We get to pick.                answer.winner = claimCallback ? instanceId : null;            }            send('accept!', answer);        }    });    receive('nack' + instanceId, function (data) { // An acceptor reports more recent data...        if (data.proposalNumber === proposalNumber) {            nNack++;  // For updating nQuorum.            // IWBNI if we started our next proposal right now/here, but we need a decent nNack count.            // Lets save that optimization for another day...        }    });    // Paxos Acceptor behavior    var bestProposal = {number: 0}, accepted = {};    function acceptedId() {        return accepted && accepted.winner;    }    receive('prepare!', function (data) {        var response = {proposalNumber: data.number, proposerId: data.proposerId};        if (betterNumber(data, bestProposal)) {            bestProposal = data;            if (accepted.winner && connectionTest(accepted.winner)) {                response.number = accepted.number;                response.winner = accepted.winner;            }            send1('promise', data.proposerId, response);        } else {            send1('nack', data.proposerId, response);        }    });    receive('accept!', function (data) {        if (!betterNumber(bestProposal, data)) {            bestProposal = accepted = data; // Update both with current data. Might have missed the proposal earlier.            if (useOptimizations) {                // The Paxos literature describes every acceptor sending 'accepted' to                // every proposer and learner. In our case, these are the same nodes that received                // the 'accept!' message, so we can send to just the originating proposer and invoke                // our own accepted handler directly.                // Note that this optimization cannot be used with Byzantine Paxos (which needs another                // multi-broadcast to detect lying and collusion).                debugSend('/', 'accepted', data);                debugReceive(instanceId, 'accepted', data); // direct on next line, which doesn't get logging.                subchannelHandlers[subchannelKey('accepted') + instanceId].receiver(data);                if (data.proposerId !== instanceId) { // i.e., we didn't already do it directly on the line above.                    send1('accepted', data.proposerId, data);                }            } else {                send('accepted', data);            }        } else {            send1('nack', data.proposerId, {proposalNumber: data.number});        }    });    // Paxos Learner behavior.    function localRelease() {        var callback = releaseCallback;        debugFlow('baton:', batonName, 'localRelease', 'callback:', !!releaseCallback);        if (!releaseCallback) {            return;        } // Already released, but we might still receive a stale message. That's ok.        releaseCallback = undefined;        callback(batonName);  // Pass batonName so that clients may use the same handler for different batons.    }    receive('accepted' + (useOptimizations ? instanceId : ''), function (data) { // See note in 'accept!' regarding use of instanceId here.        if (betterNumber(accepted, data)) { // Especially when !useOptimizations, we can receive other acceptances late.            return;        }        var oldAccepted = accepted;        debugFlow('baton:', batonName, instanceId, 'accepted', data.number, data.winner);        accepted = data;        // If we are proposer, make sure we get a quorum of acceptances.        if ((data.proposerId === instanceId) && (data.number === proposalNumber) && (++nAccepted >= nQuorum)) {            if (electionWatchdog) {                timers.clearTimeout(electionWatchdog);                electionWatchdog = null;            }        }        // If we are the winner -- regardless of whether we were the proposer.        if (acceptedId() === instanceId) {            if (claimCallback) {                var callback = claimCallback;                claimCallback = undefined;                callback(batonName);            } else if (!releaseCallback) { // We won, but have been released and are no longer interested.                // Propose that someone else take the job.                timers.setTimeout(propose, 0); // Asynchronous to queue message handling if some are synchronous and others not.            }        } else if (releaseCallback && (oldAccepted.winner === instanceId)) { // We've been released by someone else!            localRelease(); // This can happen if enough people thought we'd disconnected.        }    });     // Public Interface    //    // Registers an intent to hold the baton:    // Calls onElection(batonName) once, if you are elected by the scripts    //    to be the unique holder of the baton, which may be never.    // Calls onRelease(batonName) once, if the baton held by you is released,    //    whether this is by you calling release(), or by losing    //    an election when you become disconnected.    // You may claim again at any time after the start of onRelease    // being called.    exports.claim = function claim(onElection, onRelease) {        debugFlow('baton:', batonName, instanceId, 'claim');        if (claimCallback) {            log("Ignoring attempt to claim virtualBaton " + batonName + ", which is already waiting for claim.");            return;        }        if (releaseCallback) {            log("Ignoring attempt to claim virtualBaton " + batonName + ", which is somehow incorrect released, and that should not happen.");            return;        }        claimCallback = onElection;        releaseCallback = onRelease;        propose();        return exports; // Allows chaining. e.g., var baton = virtualBaton({batonName: 'foo'}.claim(onClaim, onRelease);    };    // Release the baton you hold, or just log that you are not holding it.    exports.release = function release(optionalReplacementOnRelease) {        debugFlow('baton:', batonName, instanceId, 'release');        if (optionalReplacementOnRelease) { // If you want to change.            releaseCallback = optionalReplacementOnRelease;        }        if (acceptedId() !== instanceId) {            log("Ignoring attempt to release virtualBaton " + batonName + ", which is not being held.");            return;        }        localRelease();        if (!claimCallback) { // No claim set in release callback.            propose();        }        return exports;    };    exports.recheckWatchdog = timers.setInterval(function recheck() {        var holder = acceptedId();  // If we're waiting and we notice the holder is gone, ...        if (holder && claimCallback && !electionWatchdog && !connectionTest(holder)) {            bestPromise.winner = null; // used if the quorum agrees that old winner is not there            propose();  // ... propose an election.        }    }, recheckInterval);    exports.unload = function unload() { // Disconnect from everything.        messages.messageReceived.disconnect(messageHandler);        timers.clearInterval(exports.recheckWatchdog);        if (electionWatchdog) {            timers.clearTimeout(electionWatchdog);        }        electionWatchdog = claimCallback = releaseCallback = null;        Object.keys(subchannelHandlers).forEach(messages.unsubscribe);        debugFlow('baton:', batonName, instanceId, 'unload');        return exports;    };     // Gather nAcceptors by making two proposals with some gathering time, even without a claim.    propose();    return exports;}if (typeof module !== 'undefined') { // Allow testing in nodejs.    module.exports = virtualBatonf;} else {    virtualBaton = virtualBatonf;}