Skip to content

Commit

Permalink
Merge pull request #90 from nextcloud/feat/memory-optimization
Browse files Browse the repository at this point in the history
memory optimization
  • Loading branch information
juliushaertl authored Aug 19, 2024
2 parents 4a4cdd0 + 93dac12 commit 57c0aea
Show file tree
Hide file tree
Showing 25 changed files with 3,283 additions and 1,766 deletions.
19 changes: 18 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,24 @@ TLS_CERT=
# Turn off SSL certificate validation in development mode for easier testing
IS_DEV=false

# Storage strategy for whiteboard data and socket-related temporary data
# Valid values are: 'redis' or 'lru' (Least Recently Used cache)
# This strategy is used for:
# 1. Whiteboard data storage
# 2. Socket-related temporary data (e.g., cached tokens, bound data for each socket ID)
# 3. Scaling the socket server across multiple nodes (when using 'redis')
# We strongly recommend using 'redis' for production environments
# 'lru' provides a balance of performance and memory usage for single-node setups
STORAGE_STRATEGY=lru

# Redis connection URL for data storage and socket server scaling
# Required when STORAGE_STRATEGY is set to 'redis'
# This URL is used for both persistent data and temporary socket-related data
# Format: redis://[username:password@]host[:port][/database_number]
# Example: redis://user:password@redis.example.com:6379/0
REDIS_URL=redis://localhost:6379

# Prometheus metrics endpoint
# Set this to access the monitoring endpoint at /metrics
# either providing it as Bearer token or as ?token= query parameter
# METRICS_TOKEN=
# METRICS_TOKEN=
3,544 changes: 2,185 additions & 1,359 deletions package-lock.json

Large diffs are not rendered by default.

9 changes: 6 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@
"lint:fix": "eslint --ext .js,.ts,.tsx,.vue src websocket_server --fix",
"stylelint": "stylelint 'src/**/*.{css,scss,sass}'",
"stylelint:fix": "stylelint 'src/**/*.{css,scss,sass}' --fix",
"server:start": "node websocket_server/server.js",
"server:watch": "nodemon websocket_server/server.js"
"server:start": "node websocket_server/main.js",
"server:watch": "nodemon websocket_server/main.js"
},
"dependencies": {
"@excalidraw/excalidraw": "^0.17.6",
Expand All @@ -30,14 +30,17 @@
"@nextcloud/l10n": "^3.1.0",
"@nextcloud/router": "^3.0.1",
"@nextcloud/vue": "^8.16.0",
"@socket.io/redis-streams-adapter": "^0.2.2",
"dotenv": "^16.4.5",
"express": "^4.19.2",
"jsonwebtoken": "^9.0.2",
"lodash": "^4.17.21",
"lru-cache": "^11.0.0",
"node-fetch": "^3.3.2",
"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 Expand Up @@ -79,6 +82,6 @@
},
"engines": {
"node": "^20",
"npm": "^9"
"npm": "^10"
}
}
67 changes: 67 additions & 0 deletions websocket_server/ApiService.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
/* 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()

export default class ApiService {

constructor(tokenGenerator) {
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.tokenGenerator = tokenGenerator
}

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.tokenGenerator.handle(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)
}

}
46 changes: 46 additions & 0 deletions websocket_server/AppManager.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
/**
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

import dotenv from 'dotenv'
import express from 'express'
import PrometheusDataManager from './PrometheusDataManager.js'

dotenv.config()

export default class AppManager {

constructor(storageManager) {
this.app = express()
this.storageManager = storageManager
this.metricsManager = new PrometheusDataManager(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))
}

homeHandler(req, res) {
res.send('Excalidraw collaboration server is up :)')
}

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)
}

getApp() {
return this.app
}

}
57 changes: 57 additions & 0 deletions websocket_server/LRUCacheStrategy.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
/* eslint-disable no-console */

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

import StorageStrategy from './StorageStrategy.js'
import { LRUCache } from 'lru-cache'

export default class LRUCacheStrategy extends StorageStrategy {

constructor(apiService) {
super()
this.apiService = apiService
this.cache = new LRUCache({
max: 1000,
ttl: 30 * 60 * 1000,
ttlAutopurge: true,
dispose: async (value, key) => {
console.log('Disposing room', key)
if (value?.data && value?.lastEditedUser) {
try {
await this.apiService.saveRoomDataToServer(
key,
value.data,
value.lastEditedUser,
)
} catch (error) {
console.error(`Failed to save room ${key} data:`, error)
}
}
},
})
}

async get(key) {
return this.cache.get(key)
}

async set(key, value) {
this.cache.set(key, value)
}

async delete(key) {
this.cache.delete(key)
}

async clear() {
this.cache.clear()
}

getRooms() {
return this.cache
}

}
55 changes: 55 additions & 0 deletions websocket_server/PrometheusDataManager.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
/**
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

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

export default class PrometheusDataManager {

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
}

}
Loading

0 comments on commit 57c0aea

Please sign in to comment.