Skip to content

Commit

Permalink
memory optimization, introduce redis
Browse files Browse the repository at this point in the history
Signed-off-by: Hoang Pham <[email protected]>
  • Loading branch information
hweihwang committed Aug 6, 2024
1 parent f79abbd commit 6f1a310
Show file tree
Hide file tree
Showing 11 changed files with 1,003 additions and 1,412 deletions.
1,380 changes: 371 additions & 1,009 deletions package-lock.json

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
"prom-client": "^14.2.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"redis": "^4.7.0",
"socket.io": "^4.7.5",
"socket.io-client": "^4.7.5",
"socket.io-prometheus": "^0.3.0",
Expand Down
69 changes: 69 additions & 0 deletions websocket_server/apiService.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
/* eslint-disable no-console */

/**
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

import fetch from 'node-fetch'
import https from 'https'
import dotenv from 'dotenv'
import Utils from './utils.js'
dotenv.config()

class ApiService {

constructor(authManager) {
this.NEXTCLOUD_URL = process.env.NEXTCLOUD_URL
this.IS_DEV = Utils.parseBooleanFromEnv(process.env.IS_DEV)
this.agent = this.IS_DEV ? new https.Agent({ rejectUnauthorized: false }) : null
this.authManager = authManager
}

fetchOptions(method, token, body = null, roomId = null, lastEditedUser = null) {
return {
method,
headers: {
'Content-Type': 'application/json',
...(method === 'GET' && { Authorization: `Bearer ${token}` }),
...(method === 'PUT' && {
'X-Whiteboard-Auth': this.authManager.generateSharedToken(roomId),
'X-Whiteboard-User': lastEditedUser || 'unknown',
}),
},
...(body && { body: JSON.stringify(body) }),
...(this.agent && { agent: this.agent }),
}
}

async fetchData(url, options) {
try {
const response = await fetch(url, options)
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}: ${await response.text()}`)
}
return response.json()
} catch (error) {
console.error(error)
return null
}
}

async getRoomDataFromServer(roomID, jwtToken) {
const url = `${this.NEXTCLOUD_URL}/index.php/apps/whiteboard/${roomID}`
const options = this.fetchOptions('GET', jwtToken)
return this.fetchData(url, options)
}

async saveRoomDataToServer(roomID, roomData, lastEditedUser) {
console.log('Saving room data to file')

const url = `${this.NEXTCLOUD_URL}/index.php/apps/whiteboard/${roomID}`
const body = { data: { elements: roomData } }
const options = this.fetchOptions('PUT', null, body, roomID, lastEditedUser)
return this.fetchData(url, options)
}

}

export default ApiService
50 changes: 33 additions & 17 deletions websocket_server/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,28 +5,44 @@

import dotenv from 'dotenv'
import express from 'express'
import { register, updatePrometheusMetrics } from './prom-metrics.js'
import { rooms } from './roomData.js'
import { PrometheusMetrics } from './prom-metrics.js'

dotenv.config()

const METRICS_TOKEN = process.env.METRICS_TOKEN
class AppManager {

const app = express()
constructor(storageManager) {
this.app = express()
this.storageManager = storageManager
this.metricsManager = new PrometheusMetrics(storageManager)
this.METRICS_TOKEN = process.env.METRICS_TOKEN
this.setupRoutes()
}

setupRoutes() {
this.app.get('/', this.homeHandler.bind(this))
this.app.get('/metrics', this.metricsHandler.bind(this))
}

app.get('/', (req, res) => {
res.send('Excalidraw collaboration server is up :)')
})
homeHandler(req, res) {
res.send('Excalidraw collaboration server is up :)')
}

app.get('/metrics', async (req, res) => {
const token = req.headers.authorization?.split(' ')[1] || req.query.token
if (!METRICS_TOKEN || token !== METRICS_TOKEN) {
return res.status(403).send('Unauthorized')
async metricsHandler(req, res) {
const token = req.headers.authorization?.split(' ')[1] || req.query.token
if (!this.METRICS_TOKEN || token !== this.METRICS_TOKEN) {
return res.status(403).send('Unauthorized')
}
this.metricsManager.updateMetrics()
const metrics = await this.metricsManager.getRegister().metrics()
res.set('Content-Type', this.metricsManager.getRegister().contentType)
res.end(metrics)
}
updatePrometheusMetrics(rooms)
const metrics = await register.metrics()
res.set('Content-Type', register.contentType)
res.end(metrics)
})

export default app
getApp() {
return this.app
}

}

export default AppManager
24 changes: 16 additions & 8 deletions websocket_server/auth.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,21 @@ import dotenv from 'dotenv'

dotenv.config()

const SHARED_SECRET = process.env.JWT_SECRET_KEY
class AuthManager {

constructor() {
this.SHARED_SECRET = process.env.JWT_SECRET_KEY
}

generateSharedToken(roomId) {
const timestamp = Date.now()
const payload = `${roomId}:${timestamp}`
const hmac = crypto.createHmac('sha256', this.SHARED_SECRET)
hmac.update(payload)
const signature = hmac.digest('hex')
return `${payload}:${signature}`
}

export function generateSharedToken(roomId) {
const timestamp = Date.now()
const payload = `${roomId}:${timestamp}`
const hmac = crypto.createHmac('sha256', SHARED_SECRET)
hmac.update(payload)
const signature = hmac.digest('hex')
return `${payload}:${signature}`
}

export default AuthManager
99 changes: 54 additions & 45 deletions websocket_server/monitoring.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,58 +3,67 @@
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

export function getSystemOverview(rooms) {
return {
memoryUsage: getMemoryUsage(),
roomStats: getRoomStats(rooms),
cacheInfo: getCacheInfo(rooms),
roomsData: getRoomsData(rooms),
export class SystemMonitor {

constructor(storageManager) {
this.storageManager = storageManager
}
}

function getMemoryUsage() {
const memUsage = process.memoryUsage()
return {
rss: formatBytes(memUsage.rss),
heapTotal: formatBytes(memUsage.heapTotal),
heapUsed: formatBytes(memUsage.heapUsed),
external: formatBytes(memUsage.external),
arrayBuffers: formatBytes(memUsage.arrayBuffers),
getSystemOverview() {
const rooms = this.storageManager.getRooms()
return {
memoryUsage: this.getMemoryUsage(),
roomStats: this.getRoomStats(rooms),
cacheInfo: this.getCacheInfo(rooms),
roomsData: this.getRoomsData(rooms),
}
}
}

function getRoomStats(rooms) {
return {
activeRooms: rooms.size,
totalUsers: Array.from(rooms.values()).reduce((sum, room) => sum + Object.keys(room.users).length, 0),
totalDataSize: formatBytes(Array.from(rooms.values()).reduce((sum, room) => sum + (room.data ? JSON.stringify(room.data).length : 0), 0)),
getMemoryUsage() {
const memUsage = process.memoryUsage()
return {
rss: this.formatBytes(memUsage.rss),
heapTotal: this.formatBytes(memUsage.heapTotal),
heapUsed: this.formatBytes(memUsage.heapUsed),
external: this.formatBytes(memUsage.external),
arrayBuffers: this.formatBytes(memUsage.arrayBuffers),
}
}
}

function getRoomsData(rooms) {
return Array.from(rooms.entries()).map(([roomId, room]) => ({
id: roomId,
users: Object.keys(room.users),
lastEditedUser: room.lastEditedUser,
lastActivity: new Date(room.lastActivity).toISOString(),
dataSize: formatBytes(JSON.stringify(room.data).length),
data: room.data, // Be cautious with this if the data is very large
}))
}
getRoomStats(rooms) {
return {
activeRooms: rooms.size,
totalUsers: Array.from(rooms.values()).reduce((sum, room) => sum + Object.keys(room.users).length, 0),
totalDataSize: this.formatBytes(Array.from(rooms.values()).reduce((sum, room) => sum + (room.data ? JSON.stringify(room.data).length : 0), 0)),
}
}

function getCacheInfo(rooms) {
return {
size: rooms.size,
maxSize: rooms.max,
keys: Array.from(rooms.keys()),
recentlyUsed: Array.from(rooms.keys()).slice(0, 10), // Show 10 most recently used
getRoomsData(rooms) {
return Array.from(rooms.entries()).map(([roomId, room]) => ({
id: roomId,
users: Object.keys(room.users),
lastEditedUser: room.lastEditedUser,
lastActivity: new Date(room.lastActivity).toISOString(),
dataSize: this.formatBytes(JSON.stringify(room.data).length),
data: room.data, // Be cautious with this if the data is very large
}))
}

getCacheInfo(rooms) {
return {
size: rooms.size,
maxSize: rooms.max,
keys: Array.from(rooms.keys()),
recentlyUsed: Array.from(rooms.keys()).slice(0, 10), // Show 10 most recently used
}
}

formatBytes(bytes) {
if (bytes === 0) return '0 Bytes'
const k = 1024
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
}
}

function formatBytes(bytes) {
if (bytes === 0) return '0 Bytes'
const k = 1024
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
}
83 changes: 48 additions & 35 deletions websocket_server/prom-metrics.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,39 +4,52 @@
*/

import { register, Gauge } from 'prom-client'
import { getSystemOverview } from './monitoring.js'

const memoryUsageGauge = new Gauge({
name: 'whiteboard_memory_usage',
help: 'Memory usage of the server',
labelNames: ['type'],
})

const roomStatsGauge = new Gauge({
name: 'whiteboard_room_stats',
help: 'Room statistics',
labelNames: ['stat'],
})

const cacheInfoGauge = new Gauge({
name: 'whiteboard_cache_info',
help: 'Cache information',
labelNames: ['info'],
})

export function updatePrometheusMetrics(rooms) {
const overview = getSystemOverview(rooms)

Object.entries(overview.memoryUsage).forEach(([key, value]) => {
memoryUsageGauge.set({ type: key }, parseFloat(value) || 0)
})

roomStatsGauge.set({ stat: 'activeRooms' }, Number(overview.roomStats.activeRooms) || 0)
roomStatsGauge.set({ stat: 'totalUsers' }, Number(overview.roomStats.totalUsers) || 0)
roomStatsGauge.set({ stat: 'totalDataSize' }, parseFloat(overview.roomStats.totalDataSize) || 0)

cacheInfoGauge.set({ info: 'size' }, Number(overview.cacheInfo.size) || 0)
cacheInfoGauge.set({ info: 'maxSize' }, Number(overview.cacheInfo.maxSize) || 0)
}
import { SystemMonitor } from './monitoring.js'

export class PrometheusMetrics {

constructor(storageManager) {
this.systemMonitor = new SystemMonitor(storageManager)
this.initializeGauges()
}

initializeGauges() {
this.memoryUsageGauge = new Gauge({
name: 'whiteboard_memory_usage',
help: 'Memory usage of the server',
labelNames: ['type'],
})

this.roomStatsGauge = new Gauge({
name: 'whiteboard_room_stats',
help: 'Room statistics',
labelNames: ['stat'],
})

this.cacheInfoGauge = new Gauge({
name: 'whiteboard_cache_info',
help: 'Cache information',
labelNames: ['info'],
})
}

updateMetrics() {
const overview = this.systemMonitor.getSystemOverview()

Object.entries(overview.memoryUsage).forEach(([key, value]) => {
this.memoryUsageGauge.set({ type: key }, parseFloat(value) || 0)
})

this.roomStatsGauge.set({ stat: 'activeRooms' }, Number(overview.roomStats.activeRooms) || 0)
this.roomStatsGauge.set({ stat: 'totalUsers' }, Number(overview.roomStats.totalUsers) || 0)
this.roomStatsGauge.set({ stat: 'totalDataSize' }, parseFloat(overview.roomStats.totalDataSize) || 0)

this.cacheInfoGauge.set({ info: 'size' }, Number(overview.cacheInfo.size) || 0)
this.cacheInfoGauge.set({ info: 'maxSize' }, Number(overview.cacheInfo.maxSize) || 0)
}

getRegister() {
return register
}

export { register }
}
Loading

0 comments on commit 6f1a310

Please sign in to comment.