diff --git a/examples/libraries/virtualBaton.18.js b/examples/libraries/virtualBaton.18.js new file mode 100644 index 0000000000..c44bac0760 --- /dev/null +++ b/examples/libraries/virtualBaton.18.js @@ -0,0 +1,197 @@ +"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 || {}, + timeout = options.timeout || 5, // seconds + claimCallback, + releaseCallback, + // paxos proposer state + nPromises = 0, + nQuorum, + mostRecentInterested, + bestPromise = {number: 0}, + // paxos acceptor state + bestProposal = {number: 0}, + accepted = null; + if (!key) { + throw new Error("A VirtualBaton must specify a key."); + } + function debug() { + print.apply(null, [].map.call(arguments, JSON.stringify)); + } + function send(operation, data) { + debug('baton: send', operation, data); + var message = JSON.stringify({op: operation, data: data}); + Messages.sendMessage(channel, message); + } + function doRelease() { + var callback = releaseCallback, oldAccepted = accepted; + accepted = releaseCallback = undefined; + debug('baton: doRelease', 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. + send('release', oldAccepted); // This order is less crufty. + callback(key); // Pass key so that clients may use the same handler for different batons. + } + + // 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) { + debug('baton: betterNumber', number, best); + return ((number.number || 0) > best.number) && (!best.proposerId || (number.proposerId >= best.proposerId)); + } + function propose(claim) { + debug('baton: propose', claim); + if (!claimCallback) { return; } // We're not participating. + nPromises = 0; + nQuorum = Math.floor(AvatarList.getAvatarIdentifiers.length / 2) + 1; + bestPromise.proposerId = MyAvatar.sessionUUID; + bestPromise.number++; + bestPromise.winner = claim; + send('prepare!', bestPromise); + // Fixme: set a watchdog that is cancelled when we send accept!, and which propose(claim) when it goes off. + } + + function messageHandler(messageChannel, messageString, senderID) { + if (messageChannel !== channel) { return; } + var message = JSON.parse(messageString), data = message.data; + debug('baton: received from', senderID, 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. + //FIXME bestPromise.number = Math.max(bestPromise.number, data.number); + + if (betterNumber(data, bestProposal)) { + var response = accepted || data; + if (!response.winner && 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. + response.interested = MyAvatar.sessionUUID; + } + bestProposal = data; + send('promise', response); + } // 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); + } + // FIXME: start interval (with a little random offset?) that claims if winner is ever not in AvatarList and we still claimCallback + break; + case 'accepted': + accepted = data; + if (acceptedId() === MyAvatar.sessionUUID) { // Note that we might not been the proposer. + if (claimCallback) { + var callback = claimCallback; + claimCallback = undefined; + callback(key); + } else { // We won, but 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. + } + break; + default: + print("Unrecognized virtualBaton message:", message); + } + } + // 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) { + debug('baton: 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(MyAvatar.sessionUUID); + }; + + // Release the baton you hold, or just log that you are not holding it. + exports.release = function release(optionalReplacementOnRelease) { + debug('baton: 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; + } + doRelease(); + 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 new file mode 100644 index 0000000000..3a44ecd92a --- /dev/null +++ b/examples/tests/testBaton.js @@ -0,0 +1,27 @@ +"use strict"; +/*jslint nomen: true, plusplus: true, vars: true*/ +var Vec3, Quat, MyAvatar, Entities, Camera, Script, print; + +Script.include("../libraries/virtualBaton.18.js"); +var TICKER_INTERVAL = 1000; // ms +var baton = virtualBaton({key: 'io.highfidelity.testBaton'}); +var ticker, countDown; + +// Tick every TICKER_INTERVAL. +function gotBaton(key) { + print("gotBaton", key); + countDown = 20; + ticker = Script.startInterval(function () { + print("tick"); + }, 1000); +} +// If we've lost the baton (e.g., to network problems), stop ticking +// but ask for the baton back (waiting indefinitely to get it). +function lostBaton(key) { + print("lostBaton", key); + Script.clearInterval(ticker); + baton.claim(gotBaton, lostBaton); +} +baton.claim(gotBaton, lostBaton); + +