From 287d91d4b2932b440fdf45cd47a4699b5902b27c Mon Sep 17 00:00:00 2001 From: Howard Stearns Date: Wed, 3 Feb 2016 20:57:35 -0800 Subject: [PATCH] better --- examples/libraries/virtualBaton.29.js | 220 ---------------------- examples/libraries/virtualBaton.31.js | 252 ++++++++++++++++++++++++++ examples/tests/testBaton.js | 2 +- 3 files changed, 253 insertions(+), 221 deletions(-) delete mode 100644 examples/libraries/virtualBaton.29.js create mode 100644 examples/libraries/virtualBaton.31.js diff --git a/examples/libraries/virtualBaton.29.js b/examples/libraries/virtualBaton.29.js deleted file mode 100644 index 6cdbbfe849..0000000000 --- a/examples/libraries/virtualBaton.29.js +++ /dev/null @@ -1,220 +0,0 @@ -"use strict"; -/*jslint nomen: true, plusplus: true, vars: true */ -/*global Entities, Script, MyAvatar, Messages, AvatarList, 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. -virtualBaton = function virtualBaton(options) { - var key = options.key, - channel = "io.highfidelity.virtualBaton:" + key, - exports = options.exports || {}, - claimCallback, - releaseCallback, - // paxos proposer state - nPromises = 0, - proposalNumber = 0, - nQuorum, - mostRecentInterested, - bestPromise = {number: 0}, - electionTimeout = options.electionTimeout || 1000, // ms. If no winner in this time, hold a new election. FIXME randomize - electionWatchdog, - recheckInterval = options.recheckInterval || 1000, // ms. Check that winners remain connected. FIXME rnadomize - recheckWatchdog, - // paxos acceptor state - bestProposal = {number: 0}, - accepted = {}; - if (!key) { - throw new Error("A VirtualBaton must specify a key."); - } - function debug() { - print.apply(print, [].map.call(arguments, JSON.stringify)); // fixme no console - } - function debugFlow() { - if (options.debugFlow) { debug.apply(null, arguments); } - } - function send(operation, data) { - if (options.debugSend) { debug('baton:', MyAvatar.sessionUUID, '=>', '-', operation, data); } - var message = JSON.stringify({op: operation, data: data}); - Messages.sendMessage(channel, message); - } - function localRelease() { - var callback = releaseCallback, oldAccepted = accepted; - releaseCallback = undefined; - accepted = {number: oldAccepted.number, proposerId: oldAccepted.proposerId}; // A copy without winner assigned, preserving number. - debugFlow('baton: localRelease', key, !!callback); - if (!callback) { return; } // Already released, but we might still receive a stale message. That's ok. - //Messages.messageReceived.disconnect(messageHandler); - //Messages.unsubscribe(channel); // Messages currently allow publishing without subscription. - callback(key); // Pass key so that clients may use the same handler for different batons. - return oldAccepted; - } - - // 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 acceptedId() { return accepted && accepted.winner; } - // Paxos makes several tests of one "proposal number" versus another, assuming - // that better proposals from the same proposer have a higher number, - // and different proposers use a different set of numbers. We achieve that - // by dividing the "number" into two parts, and integer and a proposerId, - // which keeps the combined number unique and yet still strictly ordered. - function betterNumber(number, best) { - // FIXME restore debug('baton: betterNumber', number, best); - //FIXME return ((number.number || 0) > best.number) && (!best.proposerId || (number.proposerId >= best.proposerId)); - return (number.number || 0) > best.number; - } - function propose() { - debugFlow('baton:', MyAvatar.sessionUUID, 'propose', !!claimCallback); - if (electionWatchdog) { Script.clearTimeout(electionWatchdog); } - if (!claimCallback) { return; } // We're not participating. - nPromises = 0; - proposalNumber = Math.max(proposalNumber, bestPromise.number); - nQuorum = Math.floor(AvatarList.getAvatarIdentifiers().length / 2) + 1; // N.B.: ASSUMES EVERY USER IS RUNNING THE SCRIPT! - send('prepare!', {number: ++proposalNumber, proposerId: MyAvatar.sessionUUID}); - electionWatchdog = Script.setTimeout(propose, electionTimeout); - } - function messageHandler(messageChannel, messageString, senderID) { - if (messageChannel !== channel) { return; } - var message = JSON.parse(messageString), data = message.data; - if (options.debugReceive) { debug('baton:', senderID, '=>', MyAvatar.sessionUUID, message.op, data); } - switch (message.op) { - case 'prepare!': - // Optimization: Don't waste time with low future proposals. - // Does not remove the need for betterNumber() to consider proposerId, because - // participants might not receive this prepare! message before their next proposal. - proposalNumber = Math.max(proposalNumber, data.number); - - if (betterNumber(data, bestProposal)) { - bestProposal = data; - if (claimCallback) { - // Optimization: Let the proposer know we're interested in the job if the proposer doesn't - // know who else to pick. Avoids needing to start multiple simultaneous proposals. - accepted.interested = MyAvatar.sessionUUID; - } - send('promise', accepted.winner ? // data must include proposerId so that proposer catalogs results. - {number: accepted.number, proposerId: data.proposerId, winner: accepted.winner} : - {number: data.number, proposerId: data.proposerId}); - } // FIXME nack? - break; - case 'promise': - if (data.proposerId !== MyAvatar.sessionUUID) { return; } // Only the proposer needs to do anything. - mostRecentInterested = mostRecentInterested || data.interested; - if (betterNumber(data, bestPromise)) { - bestPromise = data; - } - if (++nPromises >= nQuorum) { - if (!bestPromise.winner) { // we get to pick - bestPromise.winner = claimCallback ? MyAvatar.sessionUUID : mostRecentInterested; - } - send('accept!', bestPromise); - } - break; - case 'accept!': - if (!betterNumber(bestProposal, data)) { - accepted = data; - //send('accepted', accepted); // With the collapsed roles here, do we need this message? Maybe just go to 'accepted' case here? - messageHandler(messageChannel, JSON.stringify({op: 'accepted', data: accepted}), senderID); - } - break; - case 'accepted': - if (betterNumber(accepted, data)) { return; } - accepted = data; - if (acceptedId() === MyAvatar.sessionUUID) { // Note that we might not have been the proposer. - if (electionWatchdog) { - Script.clearTimeout(electionWatchdog); - electionWatchdog = null; - } - if (claimCallback) { - var callback = claimCallback; - claimCallback = undefined; - callback(key); - } else if (!releaseCallback) { // We won, but have been released and are no longer interested. - propose(); // Propose that someone else take the job. - } - } - break; - case 'release': - if (!betterNumber(accepted, data)) { // Unless our data is fresher... - accepted.winner = undefined; // ... allow next proposer to have his way. - if (recheckWatchdog) { - Script.clearInterval(recheckWatchdog); - recheckWatchdog = null; - } - } - break; - default: - print("Unrecognized virtualBaton message:", message); - } - } - Messages.messageReceived.connect(messageHandler); // FIXME MUST BE DONE. quorum will be wrong if no one claims and we only subscrbe with claims - Messages.subscribe(channel); // FIXME - - // Registers an intent to hold the baton: - // Calls onElection(key) once, if you are elected by the scripts - // to be the unique holder of the baton, which may be never. - // Calls onRelease(key) once, if you release the baton held by you, - // whether this is by you calling release(), or by loosing - // an election when you become disconnected. - // You may claim again at any time after the start of onRelease - // being called. Otherwise, you will not participate in further elections. - exports.claim = function claim(onElection, onRelease) { - debugFlow('baton:', MyAvatar.sessionUUID, 'claim'); - if (claimCallback) { - print("Ignoring attempt to claim virtualBaton " + key + ", which is already waiting for claim."); - return; - } - if (releaseCallback) { - print("Ignoring attempt to claim virtualBaton " + key + ", which is somehow incorrect released, and that should not happen."); - return; - } - claimCallback = onElection; - releaseCallback = onRelease; - //Messages.messageReceived.connect(messageHandler); - //Messages.subscribe(channel); - propose(); - }; - - // Release the baton you hold, or just log that you are not holding it. - exports.release = function release(optionalReplacementOnRelease) { - debugFlow('baton:', MyAvatar.sessionUUID, 'release'); - if (optionalReplacementOnRelease) { // E.g., maybe normal onRelease reclaims, but at shutdown you explicitly don't. - releaseCallback = optionalReplacementOnRelease; - } - if (acceptedId() !== MyAvatar.sessionUUID) { - print("Ignoring attempt to release virtualBaton " + key + ", which is not being held."); - return; - } - var released = localRelease(); - if (released) { - send('release', released); // Let everyone know right away, including old number in case we overlap with reclaim. - } - if (!claimCallback) { // No claim set in release callback. - propose(); // We are the distinguished proposer, but we'll pick anyone else interested. - } - }; - - return exports; -}; diff --git a/examples/libraries/virtualBaton.31.js b/examples/libraries/virtualBaton.31.js new file mode 100644 index 0000000000..5df4710d3e --- /dev/null +++ b/examples/libraries/virtualBaton.31.js @@ -0,0 +1,252 @@ +"use strict"; +/*jslint nomen: true, plusplus: true, vars: true */ +/*global Entities, Script, MyAvatar, Messages, AvatarList, 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. +virtualBaton = function virtualBaton(options) { + // Answer averages (number +/- variability). Avoids having everyone act in lockstep. + function randomize(number, variability) { + var randomPart = number * variability; + return number - (randomPart / 2) + (Math.random() * randomPart); + } + var key = options.key, + useOptimizations = (options.useOptimizations === undefined) ? true : options.useOptimizations, + exports = options.exports || {}, + electionTimeout = options.electionTimeout || randomize(1000, 0.2), // ms. If no winner in this time, hold a new election. + claimCallback, + releaseCallback, + ourId = MyAvatar.sessionUUID; // better be stable! + if (!key) { + throw new Error("A VirtualBaton must specify a key."); + } + function debug() { + print.apply(null, [].map.call(arguments, JSON.stringify)); + } + function debugFlow() { + if (options.debugFlow) { debug.apply(null, arguments); } + } + + // 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:" + key, + subchannelHandlers = {}, // Message channel string => {function, 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. + if (options.debugSend) { debug('baton:', ourId, '=>', destination, operation, data); } + sendHelper(subchannelKey(operation) + destination, data); + } + function send(operation, data) { // Send data for an operation on our channelKey. + if (options.debugSend) { debug('baton:', ourId, '=>', '-', operation, data); } + sendHelper(subchannelKeys[operation], data); + } + Messages.messageReceived.connect(function (channel, messageString, senderID) { + var handler = subchannelHandlers[channel]; + if (!handler) { return; } + var data = JSON.parse(messageString); + if (options.debugReceive) { debug('baton:', senderID, '=>', ourId, handler.op, data); } + handler.receiver(data); + }); + + // 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). + // + // Paxos makes several tests of one "proposal number" versus another, assuming + // that better proposals from the same proposer have a higher number, + // and different proposers use a different set of numbers. We achieve that + // by dividing the "number" into two parts, and integer and a proposerId, + // which keeps the combined number unique and yet still strictly ordered. + function betterNumber(number, best) { + // FIXME restore debug('baton: betterNumber', number, best); + //FIXME return ((number.number || 0) > best.number) && (!best.proposerId || (number.proposerId >= best.proposerId)); + return (number.number || 0) > best.number; + } + // Paxos Proposer behavior + var nPromises = 0, + proposalNumber = 0, + nQuorum, + mostRecentInterested, + bestPromise = {number: 0}, + electionWatchdog, + recheckInterval = options.recheckInterval || 1000, // ms. Check that winners remain connected. FIXME rnadomize + recheckWatchdog; + function propose() { + debugFlow('baton:', ourId, 'propose', !!claimCallback); + if (electionWatchdog) { Script.clearTimeout(electionWatchdog); } + if (!claimCallback) { return; } // We're not participating. + electionWatchdog = Script.setTimeout(propose, electionTimeout); + nPromises = 0; + proposalNumber = Math.max(proposalNumber, bestPromise.number); + nQuorum = Math.floor(AvatarList.getAvatarIdentifiers().length / 2) + 1; // N.B.: ASSUMES EVERY USER IS RUNNING THE SCRIPT! + send('prepare!', {number: ++proposalNumber, proposerId: ourId}); + } + // We create a distinguished promise subchannel for our id, because promises need only be sent to the proposer. + receive('promise' + ourId, function (data) { + if (data.proposerId !== ourId) { return; } // Only the proposer needs to do anything. + mostRecentInterested = mostRecentInterested || data.interested; + if (betterNumber(data, bestPromise)) { + bestPromise = data; + } + if (++nPromises >= nQuorum) { + var answer = {number: data.proposalNumber, proposerId: data.proposerId, winner: data.winner}; + if (!answer.winner) { // we get to pick + answer.winner = claimCallback ? ourId : mostRecentInterested; + } + send('accept!', answer); + } + }); + // Paxos Acceptor behavior + var bestProposal = {number: 0}, accepted = {}; + function acceptedId() { return accepted && accepted.winner; } + receive('prepare!', function (data) { + if (useOptimizations) { // Don't waste time with low future proposals. + proposalNumber = Math.max(proposalNumber, data.number); + } + if (betterNumber(data, bestProposal)) { + bestProposal = data; + if (claimCallback && useOptimizations) { + // Let the proposer know we're interested in the job if the proposer doesn't + // know who else to pick. Avoids needing to start multiple simultaneous proposals. + accepted.interested = ourId; + } + send1('promise', data.proposerId, + accepted.winner ? // data must include proposerId and number so that proposer catalogs results. + {number: accepted.number, winner: accepted.winner, proposerId: data.proposerId, proposalNumber: data.number} : + {proposerId: data.proposerId, proposalNumber: data.number}); + } // FIXME nack? + }); + receive('accept!', function (data) { + if (!betterNumber(bestProposal, data)) { + bestProposal = data; + if (useOptimizations) { + // The Paxos literature describe 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). + subchannelHandlers[subchannelKey('accepted') + ourId].receiver(data); + send1('accepted', data.proposerId, data); + } else { + send('accepted', data); + } + } + }); + // Paxos Learner behavior. + receive('accepted' + (useOptimizations ? ourId : ''), function (data) { // See note in 'accept!' regarding use of ourId here. + accepted = data; + if (acceptedId() === ourId) { // Note that we might not have been the proposer. + if (electionWatchdog) { + Script.clearTimeout(electionWatchdog); + electionWatchdog = null; + } + if (claimCallback) { + var callback = claimCallback; + claimCallback = undefined; + callback(key); + } else if (!releaseCallback) { // We won, but have been released and are no longer interested. + propose(); // Propose that someone else take the job. + } + } + }); + + receive('release', function (data) { + if (!betterNumber(accepted, data)) { // Unless our data is fresher... + debugFlow('baton:', ourId, 'handle release', data); + accepted.winner = null; // ... allow next proposer to have his way by making this explicitly null (not undefined). + if (recheckWatchdog) { + Script.clearInterval(recheckWatchdog); + recheckWatchdog = null; + } + } + }); + function localRelease() { + var callback = releaseCallback, oldAccepted = accepted; + releaseCallback = undefined; + accepted = {number: oldAccepted.number, proposerId: oldAccepted.proposerId}; // A copy without winner assigned, preserving number. + debugFlow('baton: localRelease', key, !!callback); + if (!callback) { return; } // Already released, but we might still receive a stale message. That's ok. + callback(key); // Pass key so that clients may use the same handler for different batons. + return oldAccepted; + } + + // Registers an intent to hold the baton: + // Calls onElection(key) once, if you are elected by the scripts + // to be the unique holder of the baton, which may be never. + // Calls onRelease(key) once, if you release the baton held by you, + // whether this is by you calling release(), or by loosing + // an election when you become disconnected. + // You may claim again at any time after the start of onRelease + // being called. Otherwise, you will not participate in further elections. + exports.claim = function claim(onElection, onRelease) { + debugFlow('baton:', ourId, 'claim'); + if (claimCallback) { + print("Ignoring attempt to claim virtualBaton " + key + ", which is already waiting for claim."); + return; + } + if (releaseCallback) { + print("Ignoring attempt to claim virtualBaton " + key + ", which is somehow incorrect released, and that should not happen."); + return; + } + claimCallback = onElection; + releaseCallback = onRelease; + propose(); + }; + + // Release the baton you hold, or just log that you are not holding it. + exports.release = function release(optionalReplacementOnRelease) { + debugFlow('baton:', ourId, 'release'); + if (optionalReplacementOnRelease) { // E.g., maybe normal onRelease reclaims, but at shutdown you explicitly don't. + releaseCallback = optionalReplacementOnRelease; + } + if (acceptedId() !== ourId) { + print("Ignoring attempt to release virtualBaton " + key + ", which is not being held."); + return; + } + var released = localRelease(); + if (released) { + send('release', released); // Let everyone know right away, including old number in case we overlap with reclaim. + } + if (!claimCallback) { // No claim set in release callback. + propose(); // We are the distinguished proposer, but we'll pick anyone else interested. + } + }; + + return exports; +}; diff --git a/examples/tests/testBaton.js b/examples/tests/testBaton.js index 9f4e4defca..74fa0d39e4 100644 --- a/examples/tests/testBaton.js +++ b/examples/tests/testBaton.js @@ -12,7 +12,7 @@ var Vec3, Quat, MyAvatar, Entities, Camera, Script, print; // All participants should run the test script. -Script.include("../libraries/virtualBaton.29.js"); +Script.include("../libraries/virtualBaton.31.js"); var TICKER_INTERVAL = 1000; // ms var baton = virtualBaton({key: 'io.highfidelity.testBaton', debugSend: true, debugFlow: true, debugReceive: true}); var ticker, countDown;