[IMP] bus, *: introduce multi tab service

*: calendar, survey.

In order to ease the PR introducing websockets in Odoo, introduce
the multi service. Indeed, the cross tab bus won't be necessary
anymore but there will still be a need to elect a main tab: some actions
should only be triggered once. To achieve this, the code electing the main
tab has been split with the one handling the longpolling. The crosstab
bus now relies on the multi tab service to known whether or not the
current tab is the main tab.

task-2053917

closes odoo/odoo#96174

Related: odoo/enterprise#29573
Signed-off-by: Sébastien Theys (seb) <seb@odoo.com>
This commit is contained in:
tsm-odoo
2022-07-18 19:55:13 +02:00
parent 9b1b36e2df
commit e2aeb5fc23
11 changed files with 268 additions and 163 deletions
+11 -146
View File
@@ -9,34 +9,24 @@ import { Longpolling } from '@bus/longpolling_bus';
* CrossTab
*
* This is an extension of the longpolling bus with browser cross-tab synchronization.
* It uses a Master/Slaves with Leader Election architecture:
* - a single tab handles longpolling.
* It uses the multiTab service.
* - only the main tab handles longpolling.
* - tabs are synchronized by means of the local storage.
*
* localStorage used keys are:
* - {LOCAL_STORAGE_PREFIX}.{sanitizedOrigin}.channels : shared public channel list to listen during the poll
* - {LOCAL_STORAGE_PREFIX}.{sanitizedOrigin}.options : shared options
* - {LOCAL_STORAGE_PREFIX}.{sanitizedOrigin}.notification : the received notifications from the last poll
* - {LOCAL_STORAGE_PREFIX}.{sanitizedOrigin}.tab_list : list of opened tab ids
* - {LOCAL_STORAGE_PREFIX}.{sanitizedOrigin}.tab_master : generated id of the master tab
*
* trigger:
* - notification : when a notification is receive from the long polling
* - become_master : when this tab became the master
* - no_longer_master : when this tab is not longer the master (the user swith tab)
*/
export class CrossTab extends Longpolling {
constructor(env, services) {
super(env, services);
// constants
this.TAB_HEARTBEAT_PERIOD = 10000; // 10 seconds
this.MASTER_TAB_HEARTBEAT_PERIOD = 1500; // 1.5 seconds
this.HEARTBEAT_OUT_OF_DATE_PERIOD = 5000; // 5 seconds
this.HEARTBEAT_KILL_OLD_PERIOD = 15000; // 15 seconds
this.LOCAL_STORAGE_PREFIX = 'bus';
// properties
this._isMasterTab = false;
this._isRegistered = false;
var now = new Date().getTime();
@@ -49,7 +39,11 @@ export class CrossTab extends Longpolling {
this._callLocalStorage('removeItem', 'last');
}
this._lastNotificationID = this._callLocalStorage('getItem', 'last', 0);
this._registerWindowUnload();
browser.addEventListener('storage', this._onStorage.bind(this));
env.bus.addEventListener('no_longer_main_tab', () => this.stopPolling());
env.bus.addEventListener('become_main_tab', () => this.startPolling());
}
//--------------------------------------------------------------------------
@@ -85,49 +79,24 @@ export class CrossTab extends Longpolling {
return this._id;
}
/**
* Tells whether this bus is related to the master tab.
*
* @returns {boolean}
*/
isMasterTab() {
return this._isMasterTab;
}
/**
* Use the local storage to share the long polling from the master tab.
*
* @override
*/
startPolling() {
if (this._isActive === null) {
this._heartbeat = this._heartbeat.bind(this);
}
if (!this._isRegistered) {
this._isRegistered = true;
var peers = this._callLocalStorage('getItem', 'peers', {});
peers[this._id] = new Date().getTime();
this._callLocalStorage('setItem', 'peers', peers);
this._registerWindowUnload();
if (!this._callLocalStorage('getItem', 'master')) {
this._startElection();
}
this._heartbeat();
if (this._isMasterTab) {
if (this.env.services['multiTab'].isOnMainTab()) {
this._callLocalStorage('setItem', 'options', this._options);
} else {
this._options = this._callLocalStorage('getItem', 'options', this._options);
}
this._updateChannels();
return; // startPolling will be called again on tab registration
}
if (this._isMasterTab) {
if (this.env.services['multiTab'].isOnMainTab()) {
super.startPolling();
}
}
@@ -180,57 +149,6 @@ export class CrossTab extends Longpolling {
return this.LOCAL_STORAGE_PREFIX + '.' + this._sanitizedOrigin + '.' + key;
}
/**
* Check all the time (according to the constants) if the tab is the master tab and
* check if it is active. Use the local storage for this checks.
*
* @private
* @see _startElection method
*/
_heartbeat() {
var now = new Date().getTime();
var heartbeatValue = parseInt(this._callLocalStorage('getItem', 'heartbeat', 0));
var peers = this._callLocalStorage('getItem', 'peers', {});
if ((heartbeatValue + this.HEARTBEAT_OUT_OF_DATE_PERIOD) < now) {
// Heartbeat is out of date. Electing new master
this._startElection();
heartbeatValue = parseInt(this._callLocalStorage('getItem', 'heartbeat', 0));
}
if (this._isMasterTab) {
//walk through all peers and kill old
var cleanedPeers = {};
for (var peerName in peers) {
if (peers[peerName] + this.HEARTBEAT_KILL_OLD_PERIOD > now) {
cleanedPeers[peerName] = peers[peerName];
}
}
if (heartbeatValue !== this.lastHeartbeat) {
// someone else is also master...
// it should not happen, except in some race condition situation.
this._isMasterTab = false;
this.lastHeartbeat = 0;
peers[this._id] = now;
this._callLocalStorage('setItem', 'peers', peers);
this.stopPolling();
this.trigger('no_longer_master');
} else {
this.lastHeartbeat = now;
this._callLocalStorage('setItem', 'heartbeat', now);
this._callLocalStorage('setItem', 'peers', cleanedPeers);
}
} else {
//update own heartbeat
peers[this._id] = now;
this._callLocalStorage('setItem', 'peers', peers);
}
var hbPeriod = this._isMasterTab ? this.MASTER_TAB_HEARTBEAT_PERIOD : this.TAB_HEARTBEAT_PERIOD;
this._heartbeatTimeout = browser.setTimeout(this._heartbeat.bind(this), hbPeriod);
}
/**
* @private
*/
@@ -238,45 +156,6 @@ export class CrossTab extends Longpolling {
browser.addEventListener('unload', this._onUnload.bind(this));
}
/**
* Check with the local storage if the current tab is the master tab.
* If this tab became the master, trigger 'become_master' event
*
* @private
*/
_startElection() {
if (this._isMasterTab) {
return;
}
//check who's next
var now = new Date().getTime();
var peers = this._callLocalStorage('getItem', 'peers', {});
var heartbeatKillOld = now - this.HEARTBEAT_KILL_OLD_PERIOD;
var newMaster;
for (var peerName in peers) {
//check for dead peers
if (peers[peerName] < heartbeatKillOld) {
continue;
}
newMaster = peerName;
break;
}
if (newMaster === this._id) {
//we're next in queue. Electing as master
this.lastHeartbeat = now;
this._callLocalStorage('setItem', 'heartbeat', this.lastHeartbeat);
this._callLocalStorage('setItem', 'master', true);
this._isMasterTab = true;
this.startPolling();
this.trigger('become_master');
//removing master peer from queue
delete peers[newMaster];
this._callLocalStorage('setItem', 'peers', peers);
}
}
/**
* Update localstorage channels of with the channels of this tab.
*
@@ -284,7 +163,7 @@ export class CrossTab extends Longpolling {
* @return {boolean} true if the aggregated channels has changed.
*/
_updateChannels() {
const currentPeerIds = new Set(Object.keys(this._callLocalStorage('getItem', 'peers') || {}));
const currentPeerIds = new Set(this.env.services['multiTab'].getAllTabIds());
const peerChannels = this._callLocalStorage('getItem', 'channels') || {};
const peerChannelsBefore = JSON.stringify(peerChannels);
peerChannels[this._id] = Array.from(this._currentTabChannels);
@@ -329,7 +208,7 @@ export class CrossTab extends Longpolling {
*/
_onPoll(notifications) {
var notifs = super._onPoll(notifications);
if (this._isMasterTab && notifs.length) {
if (this.env.services['multiTab'].isOnMainTab() && notifs.length) {
this._callLocalStorage('setItem', 'last', this._lastNotificationID);
this._callLocalStorage('setItem', 'last_ts', new Date().getTime());
this._callLocalStorage('setItem', 'notification', notifs);
@@ -351,18 +230,13 @@ export class CrossTab extends Longpolling {
var value = JSON.parse(e.newValue);
var key = e.key;
if (this._isRegistered && key === this._generateKey('master') && !value) {
//master was unloaded
this._startElection();
}
// last notification id changed
if (key === this._generateKey('last')) {
this._lastNotificationID = value || 0;
}
// notifications changed
else if (key === this._generateKey('notification')) {
if (!this._isMasterTab) {
if (!this.env.services['multiTab'].isOnMainTab()) {
this.trigger("notification", value);
}
}
@@ -384,16 +258,7 @@ export class CrossTab extends Longpolling {
* @private
*/
_onUnload() {
// unload peer
var peers = this._callLocalStorage('getItem', 'peers') || {};
delete peers[this._id];
this._callLocalStorage('setItem', 'peers', peers);
this._currentTabChannels.clear();
this._updateChannels();
// unload master
if (this._isMasterTab) {
this._callLocalStorage('removeItem', 'master');
}
}
}
@@ -28,7 +28,7 @@ export class BusService extends CrossTab {
*/
sendNotification(options, callback) {
if (window.Notification && Notification.permission === "granted") {
if (this.isMasterTab()) {
if (this.env.services['multiTab'].isOnMainTab()) {
try {
this._sendNativeNotification(options.title, options.message, callback);
} catch (error) {
@@ -45,7 +45,7 @@ export class BusService extends CrossTab {
}
} else {
this.env.services['notification'].add(options.message, options);
if (this.isMasterTab()) {
if (this.env.services['multiTab'].isOnMainTab()) {
this._beep();
}
}
@@ -121,7 +121,7 @@ export class BusService extends CrossTab {
}
export const busService = {
dependencies: ['notification', 'presence', 'rpc'],
dependencies: ['notification', 'presence', 'rpc', 'multiTab'],
start(env, services) {
return new BusService(env, services);
},
@@ -0,0 +1,14 @@
/** @odoo-module **/
import { registry } from '@web/core/registry';
export function makeMultiTabToLegacyEnv(legacyEnv) {
return {
dependencies: ['multiTab'],
start(_, { multiTab }) {
legacyEnv.services['multiTab'] = multiTab;
},
};
}
registry.category('wowlToLegacyServiceMappers').add('multi_tab_to_legacy_env', makeMultiTabToLegacyEnv);
@@ -0,0 +1,213 @@
/** @odoo-module **/
import { registry } from '@web/core/registry';
import { browser } from '@web/core/browser/browser';
import session from 'web.session';
/**
* This class uses a Master/Slaves with Leader Election architecture in
* order to keep track of the main tab. Tabs are synchronized thanks to the
* localStorage.
*
* localStorage used keys are:
* - {LOCAL_STORAGE_PREFIX}.{sanitizedOrigin}.lastPresenceByTab:
* mapping of tab ids to their last recorded presence.
* - {LOCAL_STORAGE_PREFIX}.{sanitizedOrigin}.main : id of the current
* main tab.
* - {LOCAL_STORAGE_PREFIX}.{sanitizedOrigin}.heartbeat : last main tab
* heartbeat time.
*
* trigger on env.bus:
* - become_main_tab : when this tab became the main.
* - no_longer_main_tab : when this tab is no longer the main.
*/
export class MultiTab {
constructor(env) {
this.env = env;
// CONSTANTS
this.TAB_HEARTBEAT_PERIOD = 10000; // 10 seconds
this.MAIN_TAB_HEARTBEAT_PERIOD = 1500; // 1.5 seconds
this.HEARTBEAT_OUT_OF_DATE_PERIOD = 5000; // 5 seconds
this.HEARTBEAT_KILL_OLD_PERIOD = 15000; // 15 seconds
this.LOCAL_STORAGE_PREFIX = 'multiTabService';
// PROPERTIES
this._isOnMainTab = false;
this._sanitizedOrigin = session.origin.replace(/:\/{0,2}/g, '_');
const now = new Date().getTime();
this._id = _.uniqueId(this.LOCAL_STORAGE_PREFIX) + ':' + now;
browser.addEventListener('unload', this._onUnload.bind(this));
browser.addEventListener('storage', this._onStorage.bind(this));
// REGISTER THIS TAB
const lastPresenceByTab = this._callLocalStorage('getItem', 'lastPresenceByTab', {});
lastPresenceByTab[this._id] = now;
this._callLocalStorage('setItem', 'lastPresenceByTab', lastPresenceByTab);
if (!this._callLocalStorage('getItem', 'main')) {
this._startElection();
}
this._heartbeat();
}
//--------------------------------------------------------------------------
// Public
//--------------------------------------------------------------------------
isOnMainTab() {
return this._isOnMainTab;
}
getAllTabIds() {
return Object.keys(this._callLocalStorage('getItem', 'lastPresenceByTab', {}));
}
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
/**
* Call browser localStorage.
*
* @private
* @param {string} method (getItem, setItem, removeItem)
* @param {string} key
* @param {any} param
* @returns Result of the called method, parsed.
*/
_callLocalStorage(method, key, param) {
if (method === 'setItem') {
param = JSON.stringify(param);
}
const result = browser.localStorage[method](this._generateKey(key), param);
if (method === 'getItem') {
return result ? JSON.parse(result) : param;
}
}
/**
* Generates localStorage keys prefixed by LOCAL_STORAGE_PREFIX and
* the sanitized origin, to prevent keys from conflicting when
* several multi tab services co-exist.
*
* @private
* @param {string} key
* @returns Key prefixed with the origin.
*/
_generateKey(key) {
return this.LOCAL_STORAGE_PREFIX + '.' + this._sanitizedOrigin + '.' + key;
}
/**
* Check all the time (according to the constants) if the tab is the main tab and
* check if it is active. Use the local storage for this checks.
*
* @private
* @see _startElection method
*/
_heartbeat() {
const now = new Date().getTime();
let heartbeatValue = this._callLocalStorage('getItem', 'heartbeat', 0);
const lastPresenceByTab = this._callLocalStorage('getItem', 'lastPresenceByTab', {});
if (heartbeatValue + this.HEARTBEAT_OUT_OF_DATE_PERIOD < now) {
// Heartbeat is out of date. Electing new main.
this._startElection();
heartbeatValue = this._callLocalStorage('getItem', 'heartbeat', 0);
}
if (this._isOnMainTab) {
// Walk through all tabs and kill old ones.
const cleanedTabs = {};
for (const [tabId, lastPresence] of Object.entries(lastPresenceByTab)) {
if (lastPresence + this.HEARTBEAT_KILL_OLD_PERIOD > now) {
cleanedTabs[tabId] = lastPresence;
}
}
if (heartbeatValue !== this.lastHeartbeat) {
// Someone else is also main...
// It should not happen, except in some race condition situation.
this._isOnMainTab = false;
this.lastHeartbeat = 0;
lastPresenceByTab[this._id] = now;
this._callLocalStorage('setItem', 'lastPresenceByTab', lastPresenceByTab);
this.env.bus.trigger('no_longer_main_tab');
} else {
this.lastHeartbeat = now;
this._callLocalStorage('setItem', 'heartbeat', now);
this._callLocalStorage('setItem', 'lastPresenceByTab', cleanedTabs);
}
} else {
// Update own heartbeat.
lastPresenceByTab[this._id] = now;
this._callLocalStorage('setItem', 'lastPresenceByTab', lastPresenceByTab);
}
const hbPeriod = this._isOnMainTab ? this.MAIN_TAB_HEARTBEAT_PERIOD : this.TAB_HEARTBEAT_PERIOD;
this._heartbeatTimeout = browser.setTimeout(this._heartbeat.bind(this), hbPeriod);
}
/**
* Check with the local storage if the current tab is the main tab.
* If this tab became the main, trigger 'become_main_tab' event.
*
* @private
*/
_startElection() {
if (this._isOnMainTab) {
return;
}
// Check who's next.
const now = new Date().getTime();
const lastPresenceByTab = this._callLocalStorage('getItem', 'lastPresenceByTab', {});
const heartbeatKillOld = now - this.HEARTBEAT_KILL_OLD_PERIOD;
let newMain;
for (const [tab, lastPresence] of Object.entries(lastPresenceByTab)) {
// Check for dead tabs.
if (lastPresence < heartbeatKillOld) {
continue;
}
newMain = tab;
break;
}
if (newMain === this._id) {
// We're next in queue. Electing as main.
this.lastHeartbeat = now;
this._callLocalStorage('setItem', 'heartbeat', this.lastHeartbeat);
this._callLocalStorage('setItem', 'main', true);
this._isOnMainTab = true;
this.env.bus.trigger('become_main_tab');
// Removing main peer from queue.
delete lastPresenceByTab[newMain];
this._callLocalStorage('setItem', 'lastPresenceByTab', lastPresenceByTab);
}
}
//--------------------------------------------------------------------------
// Handlers
//--------------------------------------------------------------------------
_onStorage({ key, newValue }) {
if (key === this._generateKey('main') && !newValue) {
// Main was unloaded.
this._startElection();
}
}
_onUnload() {
const lastPresenceByTab = this._callLocalStorage('getItem', 'lastPresenceByTab', {});
delete lastPresenceByTab[this._id];
this._callLocalStorage('setItem', 'lastPresenceByTab', lastPresenceByTab);
// Unload main.
if (this._isOnMainTab) {
this._callLocalStorage('removeItem', 'main');
}
}
}
export const multiTabService = {
start(env) {
return new MultiTab(env);
},
};
registry.category('services').add('multiTab', multiTabService);
@@ -2,6 +2,7 @@
import { busService } from "@bus/services/bus_service";
import { presenceService } from "@bus/services/presence_service";
import { multiTabService } from "@bus/services/multi_tab_service";
import { createWebClient } from "@web/../tests/webclient/helpers";
import { assetsWatchdogService } from "@bus/services/assets_watchdog_service";
@@ -17,6 +18,7 @@ QUnit.module("Bus Assets WatchDog", (hooks) => {
serviceRegistry.add("assetsWatchdog", assetsWatchdogService);
serviceRegistry.add("bus_service", busService);
serviceRegistry.add("presence", presenceService);
serviceRegistry.add("multiTab", multiTabService);
patchWithCleanup(browser, {
setTimeout(fn) {
return this._super(fn, 0);
+11 -9
View File
@@ -3,6 +3,7 @@ odoo.define('web.bus_tests', function (require) {
var { busService } = require('@bus/services/bus_service');
const { presenceService } = require('@bus/services/presence_service');
const { multiTabService } = require('@bus/services/multi_tab_service');
var { CrossTab } = require('@bus/crosstab_bus');
var testUtils = require('web.test_utils');
@@ -14,17 +15,18 @@ const { makeTestEnv } = require('@web/../tests/helpers/mock_env');
QUnit.module('Bus', {
beforeEach: function () {
const customBusService = {
...busService,
const customMultiTabService = {
...multiTabService,
start() {
const originalBusService = busService.start(...arguments);
originalBusService.TAB_HEARTBEAT_PERIOD = 10;
originalBusService.MASTER_TAB_HEARTBEAT_PERIOD = 1;
return originalBusService;
const originalMultiTabService = multiTabService.start(...arguments);
originalMultiTabService.TAB_HEARTBEAT_PERIOD = 10;
originalMultiTabService.MAIN_TAB_HEARTBEAT_PERIOD = 1;
return originalMultiTabService;
},
};
registry.category('services').add('bus_service', customBusService);
registry.category('services').add('bus_service', busService);
registry.category('services').add('presence', presenceService);
registry.category('services').add('multiTab', customMultiTabService);
},
}, function () {
QUnit.test('notifications received from the longpolling channel', async function (assert) {
@@ -194,7 +196,7 @@ QUnit.module('Bus', {
]);
});
QUnit.test('cross tab bus elect new master on master unload', async function (assert) {
QUnit.test('multi tab service elects new master on master unload', async function (assert) {
assert.expect(8);
// master
@@ -240,7 +242,7 @@ QUnit.module('Bus', {
await testUtils.nextTick();
// simulate unloading master
masterEnv.services['bus_service']._onUnload();
masterEnv.services['multiTab']._onUnload();
pollPromiseSlave.resolve([{
id: 2,
@@ -2,6 +2,7 @@
import { busService } from "@bus/services/bus_service";
import { presenceService } from "@bus/services/presence_service";
import { multiTabService } from "@bus/services/multi_tab_service";
import { createWebClient } from "@web/../tests/webclient/helpers";
import { calendarNotificationService } from "@calendar/js/services/calendar_notification_service";
@@ -20,6 +21,7 @@ QUnit.module("Calendar Notification", (hooks) => {
serviceRegistry.add("calendarNotification", calendarNotificationService);
serviceRegistry.add("bus_service", busService);
serviceRegistry.add("presence", presenceService);
serviceRegistry.add("multiTab", multiTabService);
patchWithCleanup(browser, {
setTimeout(fn) {
this._super(fn, 0);
+1
View File
@@ -185,6 +185,7 @@ Help your customers with this chat, and analyse their feedback.
'bus/static/src/longpolling_bus.js',
'bus/static/src/crosstab_bus.js',
'bus/static/src/services/bus_service.js',
'bus/static/src/services/multi_tab_service.js',
'mail/static/src/js/utils.js',
'im_livechat/static/src/legacy/public_livechat_history_tracking.js',
'im_livechat/static/src/legacy/public_livechat_chatbot.js',
+1
View File
@@ -98,6 +98,7 @@
'bus/static/src/*.js',
'bus/static/src/services/bus_service.js',
'bus/static/src/services/presence_service.js',
'bus/static/src/services/multi_tab_service.js',
'bus/static/src/services/legacy/make_bus_service_to_legacy_env.js',
'web/static/lib/luxon/luxon.js',
'web/static/src/core/**/*',
@@ -1,7 +1,9 @@
/** @odoo-module **/
import { busService } from '@bus/services/bus_service';
import { multiTabService } from '@bus/services/multi_tab_service';
import { makeBusServiceToLegacyEnv } from '@bus/services/legacy/make_bus_service_to_legacy_env';
import { makeMultiTabToLegacyEnv } from '@bus/services/legacy/make_multi_tab_to_legacy_env';
import { makeFakePresenceService } from '@bus/../tests/helpers/mock_services';
import { ChatWindowManagerContainer } from '@mail/components/chat_window_manager_container/chat_window_manager_container';
@@ -86,6 +88,7 @@ function setupMessagingServiceRegistries({
};
const customBusService = {
...busService,
start() {
const originalService = busService.start(...arguments);
Object.assign(originalService, {
@@ -106,6 +109,7 @@ function setupMessagingServiceRegistries({
isOdooFocused: () => true,
}),
systrayService,
multiTab: multiTabService,
...services,
};
@@ -114,6 +118,7 @@ function setupMessagingServiceRegistries({
});
registry.category('wowlToLegacyServiceMappers').add('bus_service_to_legacy_env', makeBusServiceToLegacyEnv);
registry.category('wowlToLegacyServiceMappers').add('multi_tab_to_legacy_env', makeMultiTabToLegacyEnv);
registry.category('wowlToLegacyServiceMappers').add('messaging_service_to_legacy_env', makeMessagingToLegacyEnv);
}
+5 -5
View File
@@ -934,10 +934,10 @@ publicWidget.registry.SurveyFormWidget = publicWidget.Widget.extend(SurveyPreloa
this.call('bus_service', 'addChannel', this.options.surveyToken);
this.call('bus_service', 'startPolling');
if (!this._checkIsMasterTab()) {
if (!this._checkisOnMainTab()) {
this.shouldReloadMasterTab = true;
this.masterTabCheckInterval = setInterval(function () {
if (self._checkIsMasterTab()) {
if (self._checkisOnMainTab()) {
clearInterval(self.masterTabCheckInterval);
}
}, 2000);
@@ -1077,10 +1077,10 @@ publicWidget.registry.SurveyFormWidget = publicWidget.Widget.extend(SurveyPreloa
*
* @private
*/
_checkIsMasterTab: function () {
var isMasterTab = this.call('bus_service', 'isMasterTab');
_checkisOnMainTab: function () {
var isOnMainTab = this.call('multiTab', 'isOnMainTab');
var $errorModal = this.$('#MasterTabErrorModal');
if (isMasterTab) {
if (isOnMainTab) {
// Force reload the page when survey is ready to be followed, to force restart long polling
if (this.shouldReloadMasterTab) {
window.location.reload();