From e0087a9f7a07dc03a2d34a384f726612171a5ab0 Mon Sep 17 00:00:00 2001 From: epriestley Date: Tue, 6 Jan 2015 17:50:40 -0800 Subject: [PATCH] Add JX.Leader: synchronization over localStorage Summary: Ref T6559. Adds a "JX.Leader" primitive to replace the synchronization over Flash. This does a couple of things: - Offers an "onBecomeLeader" event, which we can use to open the WebSocket. - Offers an "onReceiveMessage" event, which we can use to dispatch notifications and handle requests to play sounds and send desktop notifications. - Offers a "broadcast()" method, which we can use to send desktop notification and sound requests. Test Plan: Added some code like this: ``` if (!statics.leader) { statics.leader = true; JX.Leader.listen('onBecomeLeader', function() { JX.log("This tab is now the leader."); }); JX.Leader.listen('onReceiveBroadcast', function(message, is_leader) { JX.log('[' + (is_leader ? 'As Leader' : 'Not Leader') + '] ' + message); }); JX.Leader.start(); } ``` Then: - Saw first tab open become leader reliably in Safari, Chrome, Firefox. - Saw new tab become leader reliably when the first tab was closed. - Saw broadcast() work as documented and deliver messages with correct leadership-flag and uniqueness. Reviewers: joshuaspence, btrahan Reviewed By: btrahan Subscribers: epriestley Maniphest Tasks: T6559 Differential Revision: https://secure.phabricator.com/D11235 --- resources/celerity/map.php | 31 +- webroot/rsrc/externals/javelin/core/init.js | 3 + webroot/rsrc/externals/javelin/lib/Leader.js | 308 +++++++++++++++++++ 3 files changed, 329 insertions(+), 13 deletions(-) create mode 100644 webroot/rsrc/externals/javelin/lib/Leader.js diff --git a/resources/celerity/map.php b/resources/celerity/map.php index 70eaf7ca44..0e394c30d3 100644 --- a/resources/celerity/map.php +++ b/resources/celerity/map.php @@ -166,7 +166,7 @@ return array( 'rsrc/externals/javelin/core/__tests__/install.js' => 'c432ee85', 'rsrc/externals/javelin/core/__tests__/stratcom.js' => 'da194d4b', 'rsrc/externals/javelin/core/__tests__/util.js' => 'd3b157a9', - 'rsrc/externals/javelin/core/init.js' => 'b88ab49e', + 'rsrc/externals/javelin/core/init.js' => '76e1fd61', 'rsrc/externals/javelin/core/init_node.js' => 'd7dde471', 'rsrc/externals/javelin/core/install.js' => '1ffb3a9c', 'rsrc/externals/javelin/core/util.js' => '90e3fde9', @@ -193,6 +193,7 @@ return array( 'rsrc/externals/javelin/lib/DOM.js' => 'c4569c05', 'rsrc/externals/javelin/lib/History.js' => 'c60f4327', 'rsrc/externals/javelin/lib/JSON.js' => '69adf288', + 'rsrc/externals/javelin/lib/Leader.js' => '9f8874bb', 'rsrc/externals/javelin/lib/Mask.js' => '8a41885b', 'rsrc/externals/javelin/lib/Request.js' => '97258e55', 'rsrc/externals/javelin/lib/Resource.js' => '0f81f8df', @@ -405,7 +406,7 @@ return array( 'rsrc/js/application/policy/behavior-policy-rule-editor.js' => 'fe9a552f', 'rsrc/js/application/ponder/behavior-votebox.js' => '4e9b766b', 'rsrc/js/application/projects/behavior-boards-dropdown.js' => '0ec56e1d', - 'rsrc/js/application/projects/behavior-project-boards.js' => '8c2ab1e0', + 'rsrc/js/application/projects/behavior-project-boards.js' => '87cb6b51', 'rsrc/js/application/projects/behavior-project-create.js' => '065227cc', 'rsrc/js/application/projects/behavior-reorder-columns.js' => 'e1d25dfb', 'rsrc/js/application/releeph/releeph-preview-branch.js' => 'b2b4fbaf', @@ -631,7 +632,7 @@ return array( 'javelin-behavior-policy-control' => 'f3fef818', 'javelin-behavior-policy-rule-editor' => 'fe9a552f', 'javelin-behavior-ponder-votebox' => '4e9b766b', - 'javelin-behavior-project-boards' => '8c2ab1e0', + 'javelin-behavior-project-boards' => '87cb6b51', 'javelin-behavior-project-create' => '065227cc', 'javelin-behavior-refresh-csrf' => '7814b593', 'javelin-behavior-releeph-preview-branch' => 'b2b4fbaf', @@ -659,7 +660,8 @@ return array( 'javelin-history' => 'c60f4327', 'javelin-install' => '1ffb3a9c', 'javelin-json' => '69adf288', - 'javelin-magical-init' => 'b88ab49e', + 'javelin-leader' => '9f8874bb', + 'javelin-magical-init' => '76e1fd61', 'javelin-mask' => '8a41885b', 'javelin-reactor' => '77b1cf6f', 'javelin-reactor-dom' => 'b6d401d6', @@ -1350,6 +1352,15 @@ return array( '85ea0626' => array( 'javelin-install', ), + '87cb6b51' => array( + 'javelin-behavior', + 'javelin-dom', + 'javelin-util', + 'javelin-vector', + 'javelin-stratcom', + 'javelin-workflow', + 'phabricator-draggable-list', + ), '88236f00' => array( 'javelin-behavior', 'phabricator-keyboard-shortcut', @@ -1381,15 +1392,6 @@ return array( 'javelin-request', 'javelin-typeahead-source', ), - '8c2ab1e0' => array( - 'javelin-behavior', - 'javelin-dom', - 'javelin-util', - 'javelin-vector', - 'javelin-stratcom', - 'javelin-workflow', - 'phabricator-draggable-list', - ), '8c49f386' => array( 'javelin-install', 'javelin-util', @@ -1468,6 +1470,9 @@ return array( 'javelin-request', 'phabricator-shaped-request', ), + '9f8874bb' => array( + 'javelin-install', + ), 'a155550f' => array( 'javelin-install', 'javelin-dom', diff --git a/webroot/rsrc/externals/javelin/core/init.js b/webroot/rsrc/externals/javelin/core/init.js index 70ac0406dd..6863c77e4b 100644 --- a/webroot/rsrc/externals/javelin/core/init.js +++ b/webroot/rsrc/externals/javelin/core/init.js @@ -184,6 +184,9 @@ 'hashchange' ]; + if (window.localStorage) { + window_events.push('storage'); + } for (ii = 0; ii < window_events.length; ++ii) { JX.enableDispatch(window, window_events[ii]); diff --git a/webroot/rsrc/externals/javelin/lib/Leader.js b/webroot/rsrc/externals/javelin/lib/Leader.js new file mode 100644 index 0000000000..f259d79105 --- /dev/null +++ b/webroot/rsrc/externals/javelin/lib/Leader.js @@ -0,0 +1,308 @@ +/** + * @requires javelin-install + * @provides javelin-leader + * @javelin + */ + +/** + * Synchronize multiple tabs over LocalStorage. + * + * This class elects one tab as the "Leader". It remains the leader until it + * is closed. + * + * Tabs can conditionally call a function if they are the leader using + * @{method:callIfLeader}. This will trigger leader election, and call the + * function if the current tab is elected. This can be used to keep one + * websocket open across a group of tabs, or play a sound only once in response + * to a server state change. + * + * Tabs can broadcast messages to other tabs using @{method:broadcast}. Each + * message has an optional ID. When a tab receives multiple copies of a message + * with the same ID, copies after the first copy are discarded. This can be + * used in conjunction with @{method:callIfLeader} to allow multiple event + * responders to trigger a reaction to an event (like a sound) and ensure that + * it is played only once (not once for each notification), and by only one + * tab (not once for each tab). + * + * Finally, tabs can register a callback which will run if they become the + * leading tab, by listening for `onBecomeLeader`. + */ + +JX.install('Leader', { + + events: ['onBecomeLeader', 'onReceiveBroadcast'], + + statics: { + _interval: null, + _broadcastKey: 'JX.Leader.broadcast', + _leaderKey: 'JX.Leader.id', + + + /** + * Tracks leadership state. Since leadership election is asynchronous, + * we can't expose this directly without inconsistent behavior. + */ + _isLeader: false, + + + /** + * Keeps track of message IDs we've seen, so we send each message only + * once. + */ + _seen: {}, + + + /** + * Helps keep the list of seen message IDs from growing without bound. + */ + _seenList: [], + + + /** + * Elect a leader, triggering leadership callbacks if they are registered. + */ + start: function() { + var self = JX.Leader; + self.callIfLeader(JX.bag); + }, + + /** + * Call a method if this tab is the leader. + * + * This is asynchronous because leadership election is asynchronous. If + * the current tab is not the leader after any election takes place, the + * callback will not be invoked. + */ + callIfLeader: function(callback) { + JX.Leader._callIf(callback, JX.bag); + }, + + + /** + * Call a method after leader election. + * + * This is asynchronous because leadership election is asynchronous. The + * callback will be invoked after election takes place. + * + * This method is useful if you want to invoke a callback no matter what, + * but the callback behavior depends on whether this is the leader or + * not. + */ + call: function(callback) { + JX.Leader._callIf(callback, callback); + }, + + /** + * Elect a leader, then invoke either a leader callback or a follower + * callback. + */ + _callIf: function(leader_callback, follower_callback) { + + if (!window.localStorage) { + // If we don't have localStorage, pretend we're the only tab. + self._becomeLeader(); + leader_callback(); + return; + } + + var self = JX.Leader; + + // If we don't have an ID for this tab yet, generate one and register + // event listeners. + if (!self._id) { + self._id = 1 + parseInt(Math.random() * 1000000000, 10); + JX.Stratcom.listen('pagehide', null, self._pagehide); + JX.Stratcom.listen('storage', null, self._storage); + } + + // Read the current leadership lease. + var lease = self._read(); + + // If the lease is good, we're all set. + var now = +new Date(); + if (lease.until > now) { + if (lease.id === self._id) { + + // If we haven't installed an update timer yet, do so now. This will + // renew our lease every 5 seconds, making sure we hold it until the + // tab is closed. + if (!self._interval && lease.until > now + 10000) { + self._interval = window.setInterval(self._write, 5000); + } + + self._becomeLeader(); + leader_callback(); + } else { + follower_callback(); + } + return; + } + + // If the lease isn't good, try to become the leader. We don't have + // proper locking primitives for this, but can do a relatively good + // job. The algorithm here is: + // + // - Write our ID, trying to acquire the lease. + // - Delay for much longer than a write "could possibly" take. + // - Read the key back. + // - If nothing else overwrote the key, we become the leader. + // + // This avoids a race where our reads and writes could otherwise + // interleave with another tab's reads and writes, electing both or + // neither as the leader. + // + // This approximately follows an algorithm attributed to Fischer in + // "A Fast Mutual Exclusion Algorithm" (Leslie Lamport, 1985). That + // paper also describes a faster (but more complex) algorithm, but + // it's not problematic to add a significant delay here because + // leader election is not especially performance-sensitive. + + self._write(); + + window.setTimeout( + JX.bind(null, self._callIf, leader_callback, follower_callback), + 50); + }, + + + /** + * Send a message to all open tabs. + * + * Tabs can receive messages by listening to `onReceiveBroadcast`. + * + * @param string|null Message ID. If provided, subsequent messages with + * the same ID will be discarded. + * @param wild The message to send. + */ + broadcast: function(id, message) { + var self = JX.Leader; + if (id !== null) { + if (id in self._seen) { + return; + } + self._markSeen(id); + } + + if (window.localStorage) { + var json = JX.JSON.stringify( + { + id: id, + message: message, + + // LocalStorage only emits events if the value changes. Include + // a random component to make sure that broadcasts are never + // eaten. Although this is probably not often useful in a + // production system, it makes testing easier and more predictable. + uniq: parseInt(Math.random() * 1000000, 10) + }); + window.localStorage.setItem(self._broadcastKey, json); + } + + self._receiveBroadcast(message); + }, + + + /** + * Write a lease which names us as the leader. + */ + _write: function() { + var self = JX.Leader; + + var str = [self._id, ((+new Date()) + 16000)].join(':'); + window.localStorage.setItem(self._leaderKey, str); + }, + + + /** + * Read the current lease. + */ + _read: function() { + var self = JX.Leader; + + leader = window.localStorage.getItem(self._leaderKey) || '0:0'; + leader = leader.split(':'); + + return { + id: parseInt(leader[0], 10), + until: parseInt(leader[1], 10) + }; + }, + + + /** + * When the tab is closed, if we're the leader, release leadership. + * + * This will trigger a new election if there are other tabs open. + */ + _pagehide: function() { + var self = JX.Leader; + if (self._read().id === self._id) { + window.localStorage.removeItem(self._leaderKey); + } + }, + + + /** + * React to a storage update. + */ + _storage: function(e) { + var self = JX.Leader; + + var key = e.getRawEvent().key; + var new_value = e.getRawEvent().newValue; + + switch (key) { + case self._broadcastKey: + new_value = JX.JSON.parse(new_value); + if (new_value.id !== null) { + if (new_value.id in self._seen) { + return; + } + self._markSeen(new_value.id); + } + self._receiveBroadcast(new_value.message); + break; + case self._leaderKey: + // If the leader tab closed, elect a new leader. + if (new_value === null) { + self.callIfLeader(JX.bag); + } + break; + } + }, + + _receiveBroadcast: function(message) { + var self = JX.Leader; + new JX.Leader().invoke('onReceiveBroadcast', message, self._isLeader); + }, + + _becomeLeader: function() { + var self = JX.Leader; + if (self._isLeader) { + return; + } + + self._isLeader = true; + new JX.Leader().invoke('onBecomeLeader'); + }, + + /** + * Mark a message as seen. + * + * We keep a fixed-sized list of recent messages, and let old ones fall + * off the end after a while. + */ + _markSeen: function(id) { + var self = JX.Leader; + + self._seen[id] = true; + self._seenList.push(id); + while (self._seenList.length > 128) { + delete self._seen[self._seenList[0]]; + self._seenList.splice(0, 1); + } + } + + } +}); +