Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Resolve socket server overload #536

Merged
merged 1 commit into from
Feb 17, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 43 additions & 8 deletions server/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -580,24 +580,40 @@ function logger(type, message) {
console.log(`${currentDate} [${type}] ${message}`);
}

const clients = new Set();

// Used to broadcast messages to all connected clients
function broadcastToClients(message){
const payload = JSON.stringify(message);
clients.forEach((client) => {
if(client.readyState === WebSocket.OPEN){
logger('INFO', `Payload sent to the client`);
client.send(payload);
}
});
}

wss.on('connection', (ws) => {
logger('INFO', `A client connected to the web socket server.`);
clients.add(ws);

// The following messages can be received in stringified JSON format:
// { uuid: <uuid>, lock: true }
// { uuid: <uuid>, lock: false }
ws.on('message', function (msg) {
const message = JSON.parse(msg);
const uuid = message.uuid;
const {uuid, lock} = message;

if (!uuid) {
ws.send(JSON.stringify({ status: 'fail', message: 'UUID not provided.' }));
}
logger('INFO', `${msg}`);
// User wants to lock storyline since they are about to load/edit it.
if (message.lock) {
// Unlock any storyline that the user was previously locking.
delete lockedUuids[ws.uuid];
if (lock) {
const currentLock = lockedUuids[uuid];

// Someone else is currently accessing this storyline, do not allow the user to lock!
if (!!lockedUuids[uuid] && ws.uuid !== uuid) {
if (currentLock && ws.uuid !== uuid) {
logger('INFO', `A client failed to lock the storyline ${uuid}.`);
ws.send(JSON.stringify({ status: 'fail', message: 'Another user has locked this storyline.' }));
}
Expand All @@ -610,6 +626,11 @@ wss.on('connection', (ws) => {
lockedUuids[uuid] = secret;
ws.uuid = uuid;
ws.send(JSON.stringify({ status: 'success', secret }));

broadcastToClients({
type:'lock',
uuid,
});
}
} else {
// Attempting to unlock a different storyline, other than the one this connection has locked, so do not allow.
Expand All @@ -625,9 +646,14 @@ wss.on('connection', (ws) => {
// Unlock the storyline for any other user/connection to use.
else {
logger('INFO', `A client successfully unlocked the storyline ${uuid}.`);
delete ws.uuid;
delete lockedUuids[uuid];
delete ws.uuid;
ws.send(JSON.stringify({ status: 'success' }));

broadcastToClients({
type:'unlock',
uuid,
});
}
}
});
Expand All @@ -636,9 +662,18 @@ wss.on('connection', (ws) => {
logger('INFO', `Client connection with web socket server has closed.`);
// Connection was closed, unlock this user's locked storyline
if (ws.uuid) {
delete lockedUuids[ws.uuid];
delete ws.uuid;
const currentLock = lockedUuids[ws.uuid];
if (currentLock) {
logger('INFO', `Releasing lock on storyline ${ws.uuid} after connection closed`);
delete lockedUuids[ws.uuid];
broadcastToClients({
type: 'unlock',
uuid: ws.uuid,
});
}
}

clients.delete(ws);
});
});

Expand Down
26 changes: 16 additions & 10 deletions src/components/metadata-editor.vue
Original file line number Diff line number Diff line change
Expand Up @@ -594,6 +594,7 @@ import {
import { VueSpinnerOval } from 'vue3-spinners';
import { VueFinalModal } from 'vue-final-modal';
import { useUserStore } from '../stores/userStore';
import { computed } from "vue";
import JSZip from 'jszip';
import axios from 'axios';
Expand Down Expand Up @@ -646,7 +647,7 @@ export default class MetadataEditorV extends Vue {
@Prop({ default: true }) editExisting!: boolean; // true if editing existing storylines product, false if new product
currentRoute = window.location.href;
user = computed(() => useUserStore().userProfile.userName || 'Guest');
configs: {
[key: string]: StoryRampConfig | undefined;
} = { en: undefined, fr: undefined };
Expand Down Expand Up @@ -751,7 +752,7 @@ export default class MetadataEditorV extends Vue {
// Initialize Storylines config and the configuration structure.
this.configs = { en: undefined, fr: undefined };
this.configFileStructure = undefined;
// set any metadata default values for creating new product
if (!this.loadExisting) {
// set current date as default
Expand Down Expand Up @@ -849,7 +850,15 @@ export default class MetadataEditorV extends Vue {
// If a product UUID is provided, fetch the contents from the server.
if (this.$route.params.uid) {
this.generateRemoteConfig();
this.generateRemoteConfig().catch(() => {
// Handle any connection/lock errors here
Message.error(this.$t('editor.editMetadata.message.error.unauthorized'));
if (this.$route.name === 'editor') {
setTimeout(() => {
this.$router.push({ name: 'home' });
}, 2000);
}
});
}
}
Expand Down Expand Up @@ -1017,10 +1026,9 @@ export default class MetadataEditorV extends Vue {
this.controller = new AbortController();
this.loadStatus = 'loading';
const user = useUserStore().userProfile.userName || 'Guest';
const secret = this.lockStore.secret;
fetch(this.apiUrl + `/retrieve/${this.uuid}/${version}`, {
headers: { user, secret: secret },
headers: { user: this.user, secret: secret },
signal: this.controller.signal
})
.then((res: Response) => {
Expand Down Expand Up @@ -1143,9 +1151,8 @@ export default class MetadataEditorV extends Vue {
return;
}
this.loadStatus = 'loading';
const user = useUserStore().userProfile.userName || 'Guest';
const secret = this.lockStore.secret;
fetch(this.apiUrl + `/history/${this.uuid}`, { headers: { user, secret } }).then((res: Response) => {
fetch(this.apiUrl + `/history/${this.uuid}`, { headers: { user: this.user, secret } }).then((res: Response) => {
if (res.status === 404) {
// Product not found.
this.loadStatus = 'waiting';
Expand Down Expand Up @@ -1210,7 +1217,7 @@ export default class MetadataEditorV extends Vue {
// First, hit the Express server `rename` endpoint to perform the `rename` syscall on the file system.
await axios
.post(this.apiUrl + `/rename`, {
user: userStore.userProfile.userName || 'Guest',
user: this.user,
previousUuid: prevUuid,
newUuid: this.changeUuid,
configs: { en: convertedEnglish, fr: convertedFrench }
Expand Down Expand Up @@ -1631,10 +1638,9 @@ export default class MetadataEditorV extends Vue {
this.configFileStructure?.zip.generateAsync({ type: 'blob' }).then((content: Blob) => {
const formData = new FormData();
formData.append('data', content, `${this.uuid}.zip`);
const userStore = useUserStore();
const headers = {
'Content-Type': 'multipart/form-data',
user: userStore.userProfile.userName || 'Guest',
user: this.user,
secret: this.lockStore.secret
};
Message.warning(this.$t('editor.editMetadata.message.wait'));
Expand Down
92 changes: 50 additions & 42 deletions src/stores/lockStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,59 +12,67 @@ export const useLockStore = defineStore('lock', {
result: {} as any,
broadcast: undefined as BroadcastChannel | undefined,
confirmationTimeout: undefined as NodeJS.Timeout | undefined, // the timer to show the session extension confirmation modal
endTimeout: undefined as NodeJS.Timeout | undefined // the timer to kill the session due to timeout
endTimeout: undefined as NodeJS.Timeout | undefined, // the timer to kill the session due to timeout
}),
actions: {
// Opens a connection with the web socket
initConnection() {
const socketUrl = `${
import.meta.env.VITE_APP_CURR_ENV ? import.meta.env.VITE_APP_API_URL : 'http://localhost:6040'
}`;
this.socket = new WebSocket(socketUrl);
return new Promise((resolve) => {
const socketUrl = `${
import.meta.env.VITE_APP_CURR_ENV ? import.meta.env.VITE_APP_API_URL : 'http://localhost:6040'
}`;
this.socket = new WebSocket(socketUrl);

// Connection opened
this.socket.onopen = () => {
this.connected = true;
return false;
};
// Connection opened
this.socket.onopen = () => {
this.connected = true;
resolve();
};

// Listen for messages
this.socket.onmessage = (event) => {
const res = JSON.parse(event.data);
this.received = true;
this.result = res;
};
// Listen for messages
this.socket.onmessage = (event) => {
const res = JSON.parse(event.data);
this.received = true;
this.result = res;
};
});
},
// Attempts to lock a storyline for this user.
// Attempts to lock a storyline for this user.
// Returns a promise that resolves if the lock was successfully fetched and rejects if it was not.
lockStoryline(uuid: string): Promise<void> {
// Stop the previous storyline's timer.
async lockStoryline(uuid: string): Promise<void> {
// Stop the previous storyline's timer
clearInterval(this.timeInterval);

// If not connected or socket isn't open, try to connect first
if (!this.connected || !this.socket || this.socket.readyState !== WebSocket.OPEN) {
await this.initConnection();
}

return new Promise((resolve, reject) => {
// First we need to keep polling for the connection to be established.
// Is there a better way to do this? :(
const connectionPoll = setInterval(() => {
if (this.connected) {
// Now that we are connected, we need to poll for the message to be received back from the
// web socket server.
clearInterval(connectionPoll);
this.received = false;
this.socket?.send(JSON.stringify({ uuid, lock: true }));
const receiptPoll = setInterval(() => {
if (this.received) {
clearInterval(receiptPoll);
if (this.result.status === 'fail') {
reject();
} else {
this.uuid = uuid;
this.secret = this.result.secret;
this.broadcast = new BroadcastChannel(this.result.secret);
resolve();
}
}
});
this.received = false;
this.socket?.send(JSON.stringify({ uuid, lock: true }));

const handleMessage = (event: MessageEvent) => {
const data = JSON.parse(event.data);

if(data !== undefined){
if(data.status === 'fail'){
this.socket!.removeEventListener('message', handleMessage);
reject(new Error(data.message || 'Failed to lock storyline.'));
}
else if (data.status === 'success') {
this.socket!.removeEventListener('message', handleMessage);

this.uuid = uuid;
this.secret = data.secret;
this.broadcast = new BroadcastChannel(data.secret);

resolve();
}
}
}, 100);
};

this.socket!.addEventListener('message', handleMessage);
});
},
// Unlocks the curent storyline for this user. Only to be called on session end.
Expand Down
Loading