diff --git a/back/src/Controller/IoSocketController.ts b/back/src/Controller/IoSocketController.ts index 5f1bc47f..34e0dbc8 100644 --- a/back/src/Controller/IoSocketController.ts +++ b/back/src/Controller/IoSocketController.ts @@ -4,7 +4,7 @@ import * as http from "http"; import {MessageUserPosition, Point} from "../Model/Websocket/MessageUserPosition"; //TODO fix import by "_Model/.." import {ExSocketInterface} from "../Model/Websocket/ExSocketInterface"; //TODO fix import by "_Model/.." import Jwt, {JsonWebTokenError} from "jsonwebtoken"; -import {SECRET_KEY, MINIMUM_DISTANCE, GROUP_RADIUS} from "../Enum/EnvironmentVariable"; //TODO fix import by "_Enum/..." +import {SECRET_KEY, MINIMUM_DISTANCE, GROUP_RADIUS, ALLOW_ARTILLERY} from "../Enum/EnvironmentVariable"; //TODO fix import by "_Enum/..." import {World} from "../Model/World"; import {Group} from "_Model/Group"; import {UserInterface} from "_Model/UserInterface"; @@ -18,6 +18,7 @@ import {isJoinRoomMessageInterface} from "../Model/Websocket/JoinRoomMessage"; import {isPointInterface, PointInterface} from "../Model/Websocket/PointInterface"; import {isWebRtcSignalMessageInterface} from "../Model/Websocket/WebRtcSignalMessage"; import {UserInGroupInterface} from "../Model/Websocket/UserInGroupInterface"; +import {uuid} from 'uuidv4'; enum SockerIoEvent { CONNECTION = "connection", @@ -60,10 +61,25 @@ export class IoSocketController { // Authentication with token. it will be decoded and stored in the socket. // Completely commented for now, as we do not use the "/login" route at all. this.Io.use((socket: Socket, next) => { + console.log(socket.handshake.query.token); if (!socket.handshake.query || !socket.handshake.query.token) { console.error('An authentication error happened, a user tried to connect without a token.'); return next(new Error('Authentication error')); } + if(socket.handshake.query.token === 'test'){ + if (ALLOW_ARTILLERY) { + (socket as ExSocketInterface).token = socket.handshake.query.token; + (socket as ExSocketInterface).userId = uuid(); + (socket as ExSocketInterface).isArtillery = true; + console.log((socket as ExSocketInterface).userId); + next(); + return; + } else { + console.warn("In order to perform a load-testing test on this environment, you must set the ALLOW_ARTILLERY environment variable to 'true'"); + next(); + } + } + (socket as ExSocketInterface).isArtillery = false; if(this.searchClientByToken(socket.handshake.query.token)){ console.error('An authentication error happened, a user tried to connect while its token is already connected.'); return next(new Error('Authentication error')); @@ -155,6 +171,7 @@ export class IoSocketController { y: user y position on map */ socket.on(SockerIoEvent.JOIN_ROOM, (message: unknown, answerFn): void => { + console.log(SockerIoEvent.JOIN_ROOM, message); try { if (!isJoinRoomMessageInterface(message)) { socket.emit(SockerIoEvent.MESSAGE_ERROR, {message: 'Invalid JOIN_ROOM message.'}); @@ -199,6 +216,7 @@ export class IoSocketController { }); socket.on(SockerIoEvent.USER_POSITION, (position: unknown): void => { + console.log(SockerIoEvent.USER_POSITION, position); try { if (!isPointInterface(position)) { socket.emit(SockerIoEvent.MESSAGE_ERROR, {message: 'Invalid USER_POSITION message.'}); @@ -265,6 +283,7 @@ export class IoSocketController { // Let's send the user id to the user socket.on(SockerIoEvent.SET_PLAYER_DETAILS, (playerDetails: unknown, answerFn) => { + console.log(SockerIoEvent.SET_PLAYER_DETAILS, playerDetails); if (!isSetPlayerDetailsMessage(playerDetails)) { socket.emit(SockerIoEvent.MESSAGE_ERROR, {message: 'Invalid SET_PLAYER_DETAILS message.'}); console.warn('Invalid SET_PLAYER_DETAILS message received: ', playerDetails); @@ -273,10 +292,14 @@ export class IoSocketController { const Client = (socket as ExSocketInterface); Client.name = playerDetails.name; Client.characterLayers = playerDetails.characterLayers; - answerFn(Client.userId); + // Artillery fails when receiving an acknowledgement that is not a JSON object + if (!Client.isArtillery) { + answerFn(Client.userId); + } }); socket.on(SockerIoEvent.SET_SILENT, (silent: unknown) => { + console.log(SockerIoEvent.SET_SILENT, silent); if (typeof silent !== "boolean") { socket.emit(SockerIoEvent.MESSAGE_ERROR, {message: 'Invalid SET_SILENT message.'}); console.warn('Invalid SET_SILENT message received: ', silent); diff --git a/back/src/Enum/EnvironmentVariable.ts b/back/src/Enum/EnvironmentVariable.ts index 6bb507cd..d8baaf89 100644 --- a/back/src/Enum/EnvironmentVariable.ts +++ b/back/src/Enum/EnvironmentVariable.ts @@ -2,10 +2,12 @@ const SECRET_KEY = process.env.SECRET_KEY || "THECODINGMACHINE_SECRET_KEY"; const URL_ROOM_STARTED = "/Floor0/floor0.json"; const MINIMUM_DISTANCE = process.env.MINIMUM_DISTANCE ? Number(process.env.MINIMUM_DISTANCE) : 64; const GROUP_RADIUS = process.env.GROUP_RADIUS ? Number(process.env.GROUP_RADIUS) : 48; +const ALLOW_ARTILLERY = process.env.ALLOW_ARTILLERY ? process.env.ALLOW_ARTILLERY == 'true' : false; export { SECRET_KEY, URL_ROOM_STARTED, MINIMUM_DISTANCE, - GROUP_RADIUS + GROUP_RADIUS, + ALLOW_ARTILLERY } diff --git a/back/src/Model/Websocket/ExSocketInterface.ts b/back/src/Model/Websocket/ExSocketInterface.ts index 108c61cb..974fe63d 100644 --- a/back/src/Model/Websocket/ExSocketInterface.ts +++ b/back/src/Model/Websocket/ExSocketInterface.ts @@ -11,4 +11,5 @@ export interface ExSocketInterface extends Socket, Identificable { name: string; characterLayers: string[]; position: PointInterface; + isArtillery: boolean; // Whether this socket is opened by Artillery for load testing (hack) } diff --git a/benchmark/README.md b/benchmark/README.md new file mode 100644 index 00000000..41454d4c --- /dev/null +++ b/benchmark/README.md @@ -0,0 +1,69 @@ +# Load testing + +Load testing is performed with Artillery. + +Install: + +```bash +cd benchmark +npm install +``` + +Running the tests (on one core): + +```bash +cd benchmark +npm run start +``` + +You can adapt the `socketio-load-test.yaml` file to increase/decrease load. + +Default settings are: + +```yaml + phases: + - duration: 20 + arrivalRate: 2 +``` + +which means: during 20 seconds, 2 users will be added every second (peaking at 40 simultaneous users). + +Important: don't go above 40 simultaneous users for Artillery, otherwise, it is Artillery that will fail to run the tests properly. +To know, simply run "top". The "node" process for Artillery should never reach 100%. + +Reports are generated in `artillery_output.html`. + +# Multicore tests + +You will want to test with Artillery running on multiple cores. + +You can use + +```bash +./artillery_multi_core.sh +``` + +This will trigger 4 Artillery instances in parallel. + +Beware, the report generated is generated for only one instance. + +# How to test, what to track? + +While testing, you can check: + +- CPU load of WorkAdventure API node process (it should not reach 100%) +- Get metrics at the end of the run: `http://api.workadventure.localhost/metrics` + In particular, look for: + ``` + # HELP nodejs_eventloop_lag_max_seconds The maximum recorded event loop delay. + # TYPE nodejs_eventloop_lag_max_seconds gauge + nodejs_eventloop_lag_max_seconds 23.991418879 + ``` + This is the maximum time it took Node to process an event (you need to restart node after each test to reset this counter) +- Generate a profiling using "node --prof" by switching the command in docker-compose.yaml: + ``` + #command: yarn dev + command: yarn run profile + ``` + Read https://nodejs.org/en/docs/guides/simple-profiling/ on how to generate a profile. + diff --git a/benchmark/artillery_multi_core.sh b/benchmark/artillery_multi_core.sh new file mode 100755 index 00000000..5eeb4d60 --- /dev/null +++ b/benchmark/artillery_multi_core.sh @@ -0,0 +1,17 @@ +#!/bin/bash + +npm run start & +pid1=$! +npm run start:nooutput & +pid2=$! +npm run start:nooutput & +pid3=$! +npm run start:nooutput & +pid4=$! + +wait $pid1 +wait $pid2 +wait $pid3 +wait $pid4 + + diff --git a/benchmark/package.json b/benchmark/package.json new file mode 100644 index 00000000..59b182bc --- /dev/null +++ b/benchmark/package.json @@ -0,0 +1,27 @@ +{ + "name": "workadventure-artillery", + "version": "1.0.0", + "description": "Load testing for WorkAdventure", + "scripts": { + "start": "artillery run socketio-load-test.yaml -o artillery_output.json && artillery report --output artillery_output.html artillery_output.json", + "start:nooutput": "artillery run socketio-load-test.yaml" + }, + "contributors": [ + { + "name": "Grégoire Parant", + "email": "g.parant@thecodingmachine.com" + }, + { + "name": "David Négrier", + "email": "d.negrier@thecodingmachine.com" + }, + { + "name": "Arthmaël Poly", + "email": "a.poly@thecodingmachine.com" + } + ], + "license": "SEE LICENSE IN LICENSE.txt", + "dependencies": { + "artillery": "^1.6.1" + } +} diff --git a/benchmark/socketio-load-test.yaml b/benchmark/socketio-load-test.yaml new file mode 100644 index 00000000..2f9f689d --- /dev/null +++ b/benchmark/socketio-load-test.yaml @@ -0,0 +1,43 @@ +config: + target: "http://api.workadventure.localhost/" + socketio: + transports: ["websocket"] + query: + token: "test" + phases: + - duration: 20 + arrivalRate: 2 + processor: "./socketioLoadTest.js" +scenarios: + - name: "Connects and moves player for 20 seconds" + weight: 90 + engine: "socketio" + flow: + - emit: + channel: "set-player-details" + data: + name: 'TEST' + characterLayers: ['male3'] + - think: 1 + - emit: + channel: "join-room" + data: + roomId: 'global__api.workadventure.localhost/map/files/Floor0/floor0' + position: + x: 783 + y: 170 + direction: 'down' + moving: false + - think: 1 + - loop: + - function: "setYRandom" + - emit: + channel: "user-position" + data: + x: "{{ x }}" + y: "{{ y }}" + direction: 'down' + moving: false + - think: 0.2 + count: 100 + - think: 10 diff --git a/benchmark/socketioLoadTest.js b/benchmark/socketioLoadTest.js new file mode 100644 index 00000000..907982b2 --- /dev/null +++ b/benchmark/socketioLoadTest.js @@ -0,0 +1,11 @@ +'use strict'; + +module.exports = { + setYRandom +}; + +function setYRandom(context, events, done) { + context.vars.x = (883 + Math.round(Math.random() * 300)); + context.vars.y = (270 + Math.round(Math.random() * 300)); + return done(); +} diff --git a/docker-compose.yaml b/docker-compose.yaml index e731bbed..472426e4 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -50,6 +50,7 @@ services: environment: STARTUP_COMMAND_1: yarn install SECRET_KEY: yourSecretKey + ALLOW_ARTILLERY: "true" volumes: - ./back:/usr/src/app labels: