Path: blob/master/webroot/rsrc/externals/javelin/lib/Leader.js
12242 views
/**1* @requires javelin-install2* @provides javelin-leader3* @javelin4*/56/**7* Synchronize multiple tabs over LocalStorage.8*9* This class elects one tab as the "Leader". It remains the leader until it10* is closed.11*12* Tabs can conditionally call a function if they are the leader using13* @{method:callIfLeader}. This will trigger leader election, and call the14* function if the current tab is elected. This can be used to keep one15* websocket open across a group of tabs, or play a sound only once in response16* to a server state change.17*18* Tabs can broadcast messages to other tabs using @{method:broadcast}. Each19* message has an optional ID. When a tab receives multiple copies of a message20* with the same ID, copies after the first copy are discarded. This can be21* used in conjunction with @{method:callIfLeader} to allow multiple event22* responders to trigger a reaction to an event (like a sound) and ensure that23* it is played only once (not once for each notification), and by only one24* tab (not once for each tab).25*26* Finally, tabs can register a callback which will run if they become the27* leading tab, by listening for `onBecomeLeader`.28*/2930JX.install('Leader', {3132events: ['onBecomeLeader', 'onReceiveBroadcast'],3334statics: {35_leaseDuration: 1500,3637_interval: null,38_timeout: null,39_broadcastKey: 'JX.Leader.broadcast',40_leaderKey: 'JX.Leader.id',414243/**44* Tracks leadership state. Since leadership election is asynchronous,45* we can't expose this directly without inconsistent behavior.46*/47_isLeader: false,484950/**51* Keeps track of message IDs we've seen, so we send each message only52* once.53*/54_seen: {},555657/**58* Helps keep the list of seen message IDs from growing without bound.59*/60_seenList: [],616263/**64* Elect a leader, triggering leadership callbacks if they are registered.65*/66start: function() {67var self = JX.Leader;68self.call(JX.bag);69},7071/**72* Call a method if this tab is the leader.73*74* This is asynchronous because leadership election is asynchronous. If75* the current tab is not the leader after any election takes place, the76* callback will not be invoked.77*/78callIfLeader: function(callback) {79JX.Leader._callIf(callback, JX.bag);80},818283/**84* Call a method after leader election.85*86* This is asynchronous because leadership election is asynchronous. The87* callback will be invoked after election takes place.88*89* This method is useful if you want to invoke a callback no matter what,90* but the callback behavior depends on whether this is the leader or91* not.92*/93call: function(callback) {94JX.Leader._callIf(callback, callback);95},9697/**98* Elect a leader, then invoke either a leader callback or a follower99* callback.100*/101_callIf: function(leader_callback, follower_callback) {102var self = JX.Leader;103104if (!window.localStorage) {105// If we don't have localStorage, pretend we're the only tab.106self._becomeLeader();107leader_callback();108return;109}110111// If we don't have an ID for this tab yet, generate one and register112// event listeners.113if (!self._id) {114self._id = 1 + parseInt(Math.random() * 1000000000, 10);115JX.Stratcom.listen('pagehide', null, self._pagehide);116JX.Stratcom.listen('storage', null, self._storage);117}118119// Read the current leadership lease.120var lease = self._read();121122// Stagger these delays so that they are unlikely to race one another.123var expire_delay = 50;124var usurp_delay = 75;125126// If the lease is good, we're all set.127var now = +new Date();128if (lease.until > now) {129if (lease.id === self._id) {130131// If we haven't installed an update timer yet, do so now. This will132// renew our lease every 5 seconds, making sure we hold it until the133// tab is closed.134var interval = parseInt(self._leaseDuration / 3, 10);135136if (!self._interval && lease.until > now + (interval * 2)) {137self._interval = window.setInterval(self._write, interval);138}139140self._becomeLeader();141leader_callback();142} else {143144// Set a callback to try to become the leader shortly after the145// current lease expires. This lets us quickly recover from cases146// where the leader goes missing.147148// In particular, this can happen in Safari if you close windows or149// quit the browser instead of browsing away: the "pagehide" event150// does not fire when the leader is simply destroyed, so it does not151// evict itself from the throne of power.152if (!self._timeout) {153var usurp_at = (lease.until - now) + usurp_delay;154self._timeout = window.setTimeout(self._usurp, usurp_at);155}156157follower_callback();158}159160return;161}162163// If the lease isn't good, try to become the leader. We don't have164// proper locking primitives for this, but can do a relatively good165// job. The algorithm here is:166//167// - Write our ID, trying to acquire the lease.168// - Delay for much longer than a write "could possibly" take.169// - Read the key back.170// - If nothing else overwrote the key, we become the leader.171//172// This avoids a race where our reads and writes could otherwise173// interleave with another tab's reads and writes, electing both or174// neither as the leader.175//176// This approximately follows an algorithm attributed to Fischer in177// "A Fast Mutual Exclusion Algorithm" (Leslie Lamport, 1985). That178// paper also describes a faster (but more complex) algorithm, but179// it's not problematic to add a significant delay here because180// leader election is not especially performance-sensitive.181182self._write();183184window.setTimeout(185JX.bind(null, self._callIf, leader_callback, follower_callback),186expire_delay);187},188189190/**191* Send a message to all open tabs.192*193* Tabs can receive messages by listening to `onReceiveBroadcast`.194*195* @param string|null Message ID. If provided, subsequent messages with196* the same ID will be discarded.197* @param wild The message to send.198*/199broadcast: function(id, message) {200var self = JX.Leader;201if (id !== null) {202if (id in self._seen) {203return;204}205self._markSeen(id);206}207208if (window.localStorage) {209var json = JX.JSON.stringify(210{211id: id,212message: message,213214// LocalStorage only emits events if the value changes. Include215// a random component to make sure that broadcasts are never216// eaten. Although this is probably not often useful in a217// production system, it makes testing easier and more predictable.218uniq: parseInt(Math.random() * 1000000, 10)219});220window.localStorage.setItem(self._broadcastKey, json);221}222223self._receiveBroadcast(message);224},225226227/**228* Write a lease which names us as the leader.229*/230_write: function() {231var self = JX.Leader;232233var str = [self._id, ((+new Date()) + self._leaseDuration)].join(':');234window.localStorage.setItem(self._leaderKey, str);235},236237238/**239* Read the current lease.240*/241_read: function() {242var self = JX.Leader;243244var leader = window.localStorage.getItem(self._leaderKey) || '0:0';245leader = leader.split(':');246247return {248id: parseInt(leader[0], 10),249until: parseInt(leader[1], 10)250};251},252253254/**255* When the tab is closed, if we're the leader, release leadership.256*257* This will trigger a new election if there are other tabs open.258*/259_pagehide: function() {260var self = JX.Leader;261if (self._read().id === self._id) {262window.localStorage.removeItem(self._leaderKey);263}264},265266267/**268* React to a storage update.269*/270_storage: function(e) {271var self = JX.Leader;272273var key = e.getRawEvent().key;274var new_value = e.getRawEvent().newValue;275276switch (key) {277case self._broadcastKey:278new_value = JX.JSON.parse(new_value);279if (new_value.id !== null) {280if (new_value.id in self._seen) {281return;282}283self._markSeen(new_value.id);284}285self._receiveBroadcast(new_value.message);286break;287case self._leaderKey:288// If the leader tab closed, elect a new leader.289if (new_value === null) {290self.callIfLeader(JX.bag);291}292break;293}294},295296_receiveBroadcast: function(message) {297var self = JX.Leader;298new JX.Leader().invoke('onReceiveBroadcast', message, self._isLeader);299},300301_becomeLeader: function() {302var self = JX.Leader;303if (self._isLeader) {304return;305}306307self._isLeader = true;308new JX.Leader().invoke('onBecomeLeader');309},310311312/**313* Try to usurp leadership position after a lease expiration.314*/315_usurp: function() {316var self = JX.Leader;317self._timeout = null;318self.call(JX.bag);319},320321322/**323* Mark a message as seen.324*325* We keep a fixed-sized list of recent messages, and let old ones fall326* off the end after a while.327*/328_markSeen: function(id) {329var self = JX.Leader;330331self._seen[id] = true;332self._seenList.push(id);333while (self._seenList.length > 128) {334delete self._seen[self._seenList[0]];335self._seenList.splice(0, 1);336}337}338339}340});341342343