dashboard/shell/plugins/steve/worker/web-worker.advanced.js

325 lines
9.5 KiB
JavaScript

/**
* Advanced Worker is enabled by performance setting
* relocates cluster resource sockets off the UI thread and into a webworker
*/
// Status of this is TBD - https://github.com/rancher/dashboard/issues/15111
import { SCHEMA, COUNT } from '@shell/config/types';
import ResourceWatcher, { watchKeyFromMessage } from '@shell/plugins/steve/resourceWatcher';
import ResourceCache from '@shell/plugins/steve/caches/resourceCache';
import { EVENT_MESSAGE, EVENT_CONNECT_ERROR, EVENT_DISCONNECT_ERROR } from '@shell/utils/socket';
import { normalizeType, keyFieldFor } from '@shell/plugins/dashboard-store/normalize';
import { addSchemaIndexFields } from '@shell/plugins/steve/schema.utils';
const caches = {};
const state = {
watcher: undefined,
store: '', // Store name
/**
* Store `watch`/`unwatch` events to process when the socket is created
*/
workerQueue: [],
batchChanges: {},
debugWorker: false
};
const trace = (...args) => {
state.debugWorker && console.info('Advanced Worker:', ...args); // eslint-disable-line no-console
};
trace('created');
const maintenanceInterval = setInterval(() => {
if (Object.keys(state.batchChanges).length) {
self.postMessage({ batchChanges: state.batchChanges });
state.batchChanges = {};
}
}, 5000); // 5 seconds
const makeResourceProps = (msg) => {
const { resourceType, data: { type }, data } = msg;
const rawType = resourceType || type;
const normalizedType = normalizeType(rawType === 'counts' ? COUNT : rawType);
const keyField = keyFieldFor(normalizedType);
if ( normalizedType === SCHEMA ) {
addSchemaIndexFields(data);
}
return {
type: normalizedType,
id: data[keyField],
data
};
};
/**
* Pass the EVENT_CONNECT_ERROR / EVENT_DISCONNECT_ERROR back to the UI thread
*/
const handleConnectionError = (eventType, event, watcher) => {
trace('createWatcher', eventType, event);
self.postMessage({
[eventType]: {
type: event.type,
detail: event.detail,
srcElement: {
disconnectedAt: watcher.disconnectedAt,
url: watcher.url,
}
}
});
};
/**
* Remove any pending messages related to this resource from the queue
*/
const removeFromWorkerQueue = (watchKey) => {
state.workerQueue = state.workerQueue.filter((workerMessage) => {
const [, msg] = Object.entries(workerMessage)[0];
const workerMessageWatchKey = watchKeyFromMessage(msg);
return watchKey !== workerMessageWatchKey;
});
};
/**
* These are things that we do when we get a message from the UI thread
*/
const workerActions = {
// ToDo: SM we'll make a generic loader for all resource types when we need it but it'll be pretty similar to this
loadSchemas: (collection) => {
if (!caches[SCHEMA]) {
caches[SCHEMA] = new ResourceCache(SCHEMA);
}
caches[SCHEMA].load(collection);
},
createWatcher: (opt) => {
trace('createWatcher', opt);
const {
metadata, maxTries, url, csrf
} = opt;
if (!state.watcher) {
state.watcher = new ResourceWatcher(url, true, null, null, maxTries, csrf);
state.watcher.addEventListener(EVENT_MESSAGE, (e) => {
const event = e.detail;
if (event.data) {
const msg = JSON.parse(event.data);
if (msg.name) {
if (resourceWatcherActions[msg.name]) {
resourceWatcherActions[msg.name](msg);
} else {
resourceWatcherActions.dispatch(msg);
}
}
}
});
state.watcher.addEventListener('resync', (e) => {
self.postMessage({ redispatch: { resyncWatch: e.detail.data } });
});
state.watcher.addEventListener(EVENT_CONNECT_ERROR, (e) => {
handleConnectionError(EVENT_CONNECT_ERROR, e, state.watcher);
});
state.watcher.addEventListener(EVENT_DISCONNECT_ERROR, (e) => {
handleConnectionError(EVENT_DISCONNECT_ERROR, e, state.watcher);
});
state.watcher.setDebug(state.debugWorker);
state.watcher.connect(metadata);
// Flush the workerQueue
while (state.workerQueue.length > 0) {
trace('createWatcher', 'flushing workerQueue', state.workerQueue);
const workerMessage = state.workerQueue.shift();
const [action, msg] = Object.entries(workerMessage)[0];
if (workerActions[action]) {
workerActions[action](msg);
} else {
console.warn('no associated action for:', action); // eslint-disable-line no-console
}
}
}
},
watch: (msg) => {
trace('watch', msg);
const watchKey = watchKeyFromMessage(msg);
if (msg.stop) {
workerActions.unwatch(watchKey);
return;
}
// If socket is in error don't try to watch.... unless we `force` it
if (!msg.force && !!state.watcher?.watches[watchKey]?.error) {
return;
}
if (!state.watcher) {
state.workerQueue.push({ watch: msg });
return;
}
const {
resourceType,
namespace,
id,
selector,
resourceVersion
} = msg;
const resourceVersionTime = resourceVersion ? Date.now() : undefined;
const skipResourceVersion = [SCHEMA, COUNT].includes(resourceType);
const watchObject = {
resourceType,
id,
namespace,
selector,
force: msg.force,
};
state.watcher.watch(watchKey, resourceVersion, resourceVersionTime, watchObject, skipResourceVersion);
},
unwatch: (watchKey) => {
trace('unwatch', watchKey);
removeFromWorkerQueue(watchKey);
if (!state.watcher) {
return;
}
state.watcher.unwatch(watchKey);
},
initWorker: ({ storeName }) => {
trace('initWorker', storeName);
state.store = storeName;
},
destroyWorker: () => {
trace('destroyWorker');
clearInterval(maintenanceInterval);
function destroyWorkerComplete() {
delete self.onmessage;
self.postMessage({ destroyWorker: true }); // we're only passing the boolean here because the key needs to be something truthy to ensure it's passed on the object.
}
// disconnect takes a callback which we'll use to close the webworker
if (state.watcher) {
state.watcher?.disconnect().then(destroyWorkerComplete);
} else {
destroyWorkerComplete();
}
},
toggleDebug: ({ on }) => {
state.debugWorker = !!on;
state.watcher.setDebug(!!on);
},
updateBatch(type, id, change) {
if (!state.batchChanges[type]) {
state.batchChanges[type] = {};
}
state.batchChanges[type][id] = change;
}
};
/**
* These are things that we do when we get a message from the resourceWatcher
*/
const resourceWatcherActions = {
'resource.change': (msg) => {
const { type, id, data } = makeResourceProps(msg);
if (caches[type]) {
caches[type].change(data, () => workerActions.updateBatch(type, id, data));
} else {
workerActions.updateBatch(type, id, data);
}
},
// ToDo: SM create is functionally identical to change in the cache but the worker isn't supposed to know that hence the near-duplicate code
'resource.create': (msg) => {
const { type, id, data } = makeResourceProps(msg);
if (caches[type]) {
caches[type].create(data, () => workerActions.updateBatch(type, id, data));
} else {
workerActions.updateBatch(type, id, data);
}
},
'resource.start': (msg) => {
// State is handled in the resourceWatcher, no need to bubble out to UI thread
},
'resource.remove': (msg) => {
const { type, id } = makeResourceProps(msg);
if (caches[type]) {
caches[type].remove(id, () => workerActions.updateBatch(type, id, {}));
} else {
workerActions.updateBatch(type, id, {});
}
},
'resource.stop': (msg) => {
trace('resource.stop', msg);
// State is handled in the resourceWatcher....
const watchKey = watchKeyFromMessage(msg);
removeFromWorkerQueue(watchKey);
// ... however we still want to bubble out to UI thread
// We'll save some hassle and ignore any resource.stop bubble if we're in error. the only thing that will clear that is a resync
if (!state.watcher?.watches[watchKey]?.error) {
// See comment in resourceWatcher 'resource.stop' handler, until we can resolve the resourceVersion within the resourceWatcher
// internally, we'll want to bubble this out to the UI thread. When that's resolved this won't be needed
resourceWatcherActions.dispatch({
...msg,
advancedWorker: true,
});
}
},
'resource.error': (msg) => {
// State is handled in the resourceWatcher, no need to bubble out to UI thread
console.warn(`Resource error [${ state.store }]`, msg.resourceType, ':', msg.data.error); // eslint-disable-line no-console
},
dispatch: (msg) => {
self.postMessage({ dispatch: msg });
}
};
/**
* Covers message from UI Thread to Worker
*/
self.onmessage = (e) => {
/* on the off chance there's more than key in the message, we handle them in the order that they "keys" method provides which is
// good enough for now considering that we never send more than one message action at a time right now */
const messageActions = Object.keys(e?.data);
messageActions.forEach((action) => {
if (workerActions[action]) {
workerActions[action](e?.data[action]);
} else {
// This catches any window sendMessage event. We're hitting this on hot-reload of code where somehow this file is loaded
// Could be related to extensions, which have their own version of this
console.debug('no associated action for:', action); // eslint-disable-line no-console
}
});
}; // bind everything to the worker's onmessage handler via the workerActions