Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
freebsd
GitHub Repository: freebsd/phabricator
Path: blob/master/webroot/rsrc/externals/javelin/lib/Leader.js
12242 views
1
/**
2
* @requires javelin-install
3
* @provides javelin-leader
4
* @javelin
5
*/
6
7
/**
8
* Synchronize multiple tabs over LocalStorage.
9
*
10
* This class elects one tab as the "Leader". It remains the leader until it
11
* is closed.
12
*
13
* Tabs can conditionally call a function if they are the leader using
14
* @{method:callIfLeader}. This will trigger leader election, and call the
15
* function if the current tab is elected. This can be used to keep one
16
* websocket open across a group of tabs, or play a sound only once in response
17
* to a server state change.
18
*
19
* Tabs can broadcast messages to other tabs using @{method:broadcast}. Each
20
* message has an optional ID. When a tab receives multiple copies of a message
21
* with the same ID, copies after the first copy are discarded. This can be
22
* used in conjunction with @{method:callIfLeader} to allow multiple event
23
* responders to trigger a reaction to an event (like a sound) and ensure that
24
* it is played only once (not once for each notification), and by only one
25
* tab (not once for each tab).
26
*
27
* Finally, tabs can register a callback which will run if they become the
28
* leading tab, by listening for `onBecomeLeader`.
29
*/
30
31
JX.install('Leader', {
32
33
events: ['onBecomeLeader', 'onReceiveBroadcast'],
34
35
statics: {
36
_leaseDuration: 1500,
37
38
_interval: null,
39
_timeout: null,
40
_broadcastKey: 'JX.Leader.broadcast',
41
_leaderKey: 'JX.Leader.id',
42
43
44
/**
45
* Tracks leadership state. Since leadership election is asynchronous,
46
* we can't expose this directly without inconsistent behavior.
47
*/
48
_isLeader: false,
49
50
51
/**
52
* Keeps track of message IDs we've seen, so we send each message only
53
* once.
54
*/
55
_seen: {},
56
57
58
/**
59
* Helps keep the list of seen message IDs from growing without bound.
60
*/
61
_seenList: [],
62
63
64
/**
65
* Elect a leader, triggering leadership callbacks if they are registered.
66
*/
67
start: function() {
68
var self = JX.Leader;
69
self.call(JX.bag);
70
},
71
72
/**
73
* Call a method if this tab is the leader.
74
*
75
* This is asynchronous because leadership election is asynchronous. If
76
* the current tab is not the leader after any election takes place, the
77
* callback will not be invoked.
78
*/
79
callIfLeader: function(callback) {
80
JX.Leader._callIf(callback, JX.bag);
81
},
82
83
84
/**
85
* Call a method after leader election.
86
*
87
* This is asynchronous because leadership election is asynchronous. The
88
* callback will be invoked after election takes place.
89
*
90
* This method is useful if you want to invoke a callback no matter what,
91
* but the callback behavior depends on whether this is the leader or
92
* not.
93
*/
94
call: function(callback) {
95
JX.Leader._callIf(callback, callback);
96
},
97
98
/**
99
* Elect a leader, then invoke either a leader callback or a follower
100
* callback.
101
*/
102
_callIf: function(leader_callback, follower_callback) {
103
var self = JX.Leader;
104
105
if (!window.localStorage) {
106
// If we don't have localStorage, pretend we're the only tab.
107
self._becomeLeader();
108
leader_callback();
109
return;
110
}
111
112
// If we don't have an ID for this tab yet, generate one and register
113
// event listeners.
114
if (!self._id) {
115
self._id = 1 + parseInt(Math.random() * 1000000000, 10);
116
JX.Stratcom.listen('pagehide', null, self._pagehide);
117
JX.Stratcom.listen('storage', null, self._storage);
118
}
119
120
// Read the current leadership lease.
121
var lease = self._read();
122
123
// Stagger these delays so that they are unlikely to race one another.
124
var expire_delay = 50;
125
var usurp_delay = 75;
126
127
// If the lease is good, we're all set.
128
var now = +new Date();
129
if (lease.until > now) {
130
if (lease.id === self._id) {
131
132
// If we haven't installed an update timer yet, do so now. This will
133
// renew our lease every 5 seconds, making sure we hold it until the
134
// tab is closed.
135
var interval = parseInt(self._leaseDuration / 3, 10);
136
137
if (!self._interval && lease.until > now + (interval * 2)) {
138
self._interval = window.setInterval(self._write, interval);
139
}
140
141
self._becomeLeader();
142
leader_callback();
143
} else {
144
145
// Set a callback to try to become the leader shortly after the
146
// current lease expires. This lets us quickly recover from cases
147
// where the leader goes missing.
148
149
// In particular, this can happen in Safari if you close windows or
150
// quit the browser instead of browsing away: the "pagehide" event
151
// does not fire when the leader is simply destroyed, so it does not
152
// evict itself from the throne of power.
153
if (!self._timeout) {
154
var usurp_at = (lease.until - now) + usurp_delay;
155
self._timeout = window.setTimeout(self._usurp, usurp_at);
156
}
157
158
follower_callback();
159
}
160
161
return;
162
}
163
164
// If the lease isn't good, try to become the leader. We don't have
165
// proper locking primitives for this, but can do a relatively good
166
// job. The algorithm here is:
167
//
168
// - Write our ID, trying to acquire the lease.
169
// - Delay for much longer than a write "could possibly" take.
170
// - Read the key back.
171
// - If nothing else overwrote the key, we become the leader.
172
//
173
// This avoids a race where our reads and writes could otherwise
174
// interleave with another tab's reads and writes, electing both or
175
// neither as the leader.
176
//
177
// This approximately follows an algorithm attributed to Fischer in
178
// "A Fast Mutual Exclusion Algorithm" (Leslie Lamport, 1985). That
179
// paper also describes a faster (but more complex) algorithm, but
180
// it's not problematic to add a significant delay here because
181
// leader election is not especially performance-sensitive.
182
183
self._write();
184
185
window.setTimeout(
186
JX.bind(null, self._callIf, leader_callback, follower_callback),
187
expire_delay);
188
},
189
190
191
/**
192
* Send a message to all open tabs.
193
*
194
* Tabs can receive messages by listening to `onReceiveBroadcast`.
195
*
196
* @param string|null Message ID. If provided, subsequent messages with
197
* the same ID will be discarded.
198
* @param wild The message to send.
199
*/
200
broadcast: function(id, message) {
201
var self = JX.Leader;
202
if (id !== null) {
203
if (id in self._seen) {
204
return;
205
}
206
self._markSeen(id);
207
}
208
209
if (window.localStorage) {
210
var json = JX.JSON.stringify(
211
{
212
id: id,
213
message: message,
214
215
// LocalStorage only emits events if the value changes. Include
216
// a random component to make sure that broadcasts are never
217
// eaten. Although this is probably not often useful in a
218
// production system, it makes testing easier and more predictable.
219
uniq: parseInt(Math.random() * 1000000, 10)
220
});
221
window.localStorage.setItem(self._broadcastKey, json);
222
}
223
224
self._receiveBroadcast(message);
225
},
226
227
228
/**
229
* Write a lease which names us as the leader.
230
*/
231
_write: function() {
232
var self = JX.Leader;
233
234
var str = [self._id, ((+new Date()) + self._leaseDuration)].join(':');
235
window.localStorage.setItem(self._leaderKey, str);
236
},
237
238
239
/**
240
* Read the current lease.
241
*/
242
_read: function() {
243
var self = JX.Leader;
244
245
var leader = window.localStorage.getItem(self._leaderKey) || '0:0';
246
leader = leader.split(':');
247
248
return {
249
id: parseInt(leader[0], 10),
250
until: parseInt(leader[1], 10)
251
};
252
},
253
254
255
/**
256
* When the tab is closed, if we're the leader, release leadership.
257
*
258
* This will trigger a new election if there are other tabs open.
259
*/
260
_pagehide: function() {
261
var self = JX.Leader;
262
if (self._read().id === self._id) {
263
window.localStorage.removeItem(self._leaderKey);
264
}
265
},
266
267
268
/**
269
* React to a storage update.
270
*/
271
_storage: function(e) {
272
var self = JX.Leader;
273
274
var key = e.getRawEvent().key;
275
var new_value = e.getRawEvent().newValue;
276
277
switch (key) {
278
case self._broadcastKey:
279
new_value = JX.JSON.parse(new_value);
280
if (new_value.id !== null) {
281
if (new_value.id in self._seen) {
282
return;
283
}
284
self._markSeen(new_value.id);
285
}
286
self._receiveBroadcast(new_value.message);
287
break;
288
case self._leaderKey:
289
// If the leader tab closed, elect a new leader.
290
if (new_value === null) {
291
self.callIfLeader(JX.bag);
292
}
293
break;
294
}
295
},
296
297
_receiveBroadcast: function(message) {
298
var self = JX.Leader;
299
new JX.Leader().invoke('onReceiveBroadcast', message, self._isLeader);
300
},
301
302
_becomeLeader: function() {
303
var self = JX.Leader;
304
if (self._isLeader) {
305
return;
306
}
307
308
self._isLeader = true;
309
new JX.Leader().invoke('onBecomeLeader');
310
},
311
312
313
/**
314
* Try to usurp leadership position after a lease expiration.
315
*/
316
_usurp: function() {
317
var self = JX.Leader;
318
self._timeout = null;
319
self.call(JX.bag);
320
},
321
322
323
/**
324
* Mark a message as seen.
325
*
326
* We keep a fixed-sized list of recent messages, and let old ones fall
327
* off the end after a while.
328
*/
329
_markSeen: function(id) {
330
var self = JX.Leader;
331
332
self._seen[id] = true;
333
self._seenList.push(id);
334
while (self._seenList.length > 128) {
335
delete self._seen[self._seenList[0]];
336
self._seenList.splice(0, 1);
337
}
338
}
339
340
}
341
});
342
343