Skip to content

Commit dce7c15

Browse files
committed
redis scaling
Signed-off-by: Hoang Pham <[email protected]>
1 parent 0237e47 commit dce7c15

22 files changed

+3668
-1949
lines changed

.env.example

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,24 @@ TLS_CERT=
2121
# Turn off SSL certificate validation in development mode for easier testing
2222
IS_DEV=false
2323

24+
# Storage strategy for whiteboard data and socket-related temporary data
25+
# Valid values are: 'redis' or 'lru' (Least Recently Used cache)
26+
# This strategy is used for:
27+
# 1. Whiteboard data storage
28+
# 2. Socket-related temporary data (e.g., cached tokens, bound data for each socket ID)
29+
# 3. Scaling the socket server across multiple nodes (when using 'redis')
30+
# We strongly recommend using 'redis' for production environments
31+
# 'lru' provides a balance of performance and memory usage for single-node setups
32+
STORAGE_STRATEGY=lru
33+
34+
# Redis connection URL for data storage and socket server scaling
35+
# Required when STORAGE_STRATEGY is set to 'redis'
36+
# This URL is used for both persistent data and temporary socket-related data
37+
# Format: redis://[username:password@]host[:port][/database_number]
38+
# Example: redis://user:[email protected]:6379/0
39+
REDIS_URL=redis://localhost:6379
40+
2441
# Prometheus metrics endpoint
2542
# Set this to access the monitoring endpoint at /metrics
2643
# either providing it as Bearer token or as ?token= query parameter
27-
# METRICS_TOKEN=
44+
# METRICS_TOKEN=

package-lock.json

Lines changed: 2841 additions & 1391 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,8 @@
1313
"lint:fix": "eslint --ext .js,.ts,.tsx,.vue src websocket_server --fix",
1414
"stylelint": "stylelint 'src/**/*.{css,scss,sass}'",
1515
"stylelint:fix": "stylelint 'src/**/*.{css,scss,sass}' --fix",
16-
"server:start": "node websocket_server/server.js",
17-
"server:watch": "nodemon websocket_server/server.js"
16+
"server:start": "node websocket_server/main.js",
17+
"server:watch": "nodemon websocket_server/main.js"
1818
},
1919
"dependencies": {
2020
"@excalidraw/excalidraw": "^0.17.6",
@@ -27,6 +27,7 @@
2727
"@nextcloud/l10n": "^3.1.0",
2828
"@nextcloud/router": "^3.0.1",
2929
"@nextcloud/vue": "^8.16.0",
30+
"@socket.io/redis-streams-adapter": "^0.2.2",
3031
"dotenv": "^16.4.5",
3132
"express": "^4.19.2",
3233
"jsonwebtoken": "^9.0.2",
@@ -78,6 +79,6 @@
7879
},
7980
"engines": {
8081
"node": "^20",
81-
"npm": "^9"
82+
"npm": "^10"
8283
}
8384
}

websocket_server/apiService.js renamed to websocket_server/ApiService.js

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,16 +8,16 @@
88
import fetch from 'node-fetch'
99
import https from 'https'
1010
import dotenv from 'dotenv'
11-
import Utils from './utils.js'
11+
import Utils from './Utils.js'
1212
dotenv.config()
1313

14-
class ApiService {
14+
export default class ApiService {
1515

16-
constructor(authManager) {
16+
constructor(tokenGenerator) {
1717
this.NEXTCLOUD_URL = process.env.NEXTCLOUD_URL
1818
this.IS_DEV = Utils.parseBooleanFromEnv(process.env.IS_DEV)
1919
this.agent = this.IS_DEV ? new https.Agent({ rejectUnauthorized: false }) : null
20-
this.authManager = authManager
20+
this.tokenGenerator = tokenGenerator
2121
}
2222

2323
fetchOptions(method, token, body = null, roomId = null, lastEditedUser = null) {
@@ -27,7 +27,7 @@ class ApiService {
2727
'Content-Type': 'application/json',
2828
...(method === 'GET' && { Authorization: `Bearer ${token}` }),
2929
...(method === 'PUT' && {
30-
'X-Whiteboard-Auth': this.authManager.generateSharedToken(roomId),
30+
'X-Whiteboard-Auth': this.tokenGenerator.handle(roomId),
3131
'X-Whiteboard-User': lastEditedUser || 'unknown',
3232
}),
3333
},
@@ -65,5 +65,3 @@ class ApiService {
6565
}
6666

6767
}
68-
69-
export default ApiService

websocket_server/app.js renamed to websocket_server/AppManager.js

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,16 +5,16 @@
55

66
import dotenv from 'dotenv'
77
import express from 'express'
8-
import { PrometheusMetrics } from './prom-metrics.js'
8+
import PrometheusDataManager from './PrometheusDataManager.js'
99

1010
dotenv.config()
1111

12-
class AppManager {
12+
export default class AppManager {
1313

1414
constructor(storageManager) {
1515
this.app = express()
1616
this.storageManager = storageManager
17-
this.metricsManager = new PrometheusMetrics(storageManager)
17+
this.metricsManager = new PrometheusDataManager(storageManager)
1818
this.METRICS_TOKEN = process.env.METRICS_TOKEN
1919
this.setupRoutes()
2020
}
@@ -44,5 +44,3 @@ class AppManager {
4444
}
4545

4646
}
47-
48-
export default AppManager

websocket_server/LRUCacheStrategy.js

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
/* eslint-disable no-console */
2+
3+
/**
4+
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
5+
* SPDX-License-Identifier: AGPL-3.0-or-later
6+
*/
7+
8+
import StorageStrategy from './StorageStrategy.js'
9+
import { LRUCache } from 'lru-cache'
10+
11+
export default class LRUCacheStrategy extends StorageStrategy {
12+
13+
constructor(apiService) {
14+
super()
15+
this.apiService = apiService
16+
this.cache = new LRUCache({
17+
max: 1000,
18+
ttl: 30 * 60 * 1000,
19+
ttlAutopurge: true,
20+
dispose: async (value, key) => {
21+
console.log('Disposing room', key)
22+
if (value?.data && value?.lastEditedUser) {
23+
try {
24+
await this.apiService.saveRoomDataToServer(
25+
key,
26+
value.data,
27+
value.lastEditedUser,
28+
)
29+
} catch (error) {
30+
console.error(`Failed to save room ${key} data:`, error)
31+
}
32+
}
33+
},
34+
})
35+
}
36+
37+
async get(key) {
38+
return this.cache.get(key)
39+
}
40+
41+
async set(key, value) {
42+
this.cache.set(key, value)
43+
}
44+
45+
async delete(key) {
46+
this.cache.delete(key)
47+
}
48+
49+
async clear() {
50+
this.cache.clear()
51+
}
52+
53+
getRooms() {
54+
return this.cache
55+
}
56+
57+
}

websocket_server/prom-metrics.js renamed to websocket_server/PrometheusDataManager.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,9 @@
44
*/
55

66
import { register, Gauge } from 'prom-client'
7-
import { SystemMonitor } from './monitoring.js'
7+
import SystemMonitor from './SystemMonitor.js'
88

9-
export class PrometheusMetrics {
9+
export default class PrometheusDataManager {
1010

1111
constructor(storageManager) {
1212
this.systemMonitor = new SystemMonitor(storageManager)

websocket_server/RedisStrategy.js

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
/* eslint-disable no-console */
2+
3+
/**
4+
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
5+
* SPDX-License-Identifier: AGPL-3.0-or-later
6+
*/
7+
8+
import StorageStrategy from './StorageStrategy.js'
9+
import { createClient } from 'redis'
10+
import Room from './Room.js'
11+
12+
export default class RedisStrategy extends StorageStrategy {
13+
14+
constructor(apiService) {
15+
super()
16+
this.apiService = apiService
17+
this.client = createClient({
18+
url: process.env.REDIS_URL || 'redis://localhost:6379',
19+
retry_strategy: (options) => {
20+
if (options.error && options.error.code === 'ECONNREFUSED') {
21+
return new Error('The server refused the connection')
22+
}
23+
if (options.total_retry_time > 1000 * 60 * 60) {
24+
return new Error('Retry time exhausted')
25+
}
26+
if (options.attempt > 10) {
27+
return undefined
28+
}
29+
return Math.min(options.attempt * 100, 3000)
30+
},
31+
})
32+
this.client.on('error', (err) =>
33+
console.error('Redis Client Error', err),
34+
)
35+
this.connect()
36+
}
37+
38+
async connect() {
39+
try {
40+
await this.client.connect()
41+
} catch (error) {
42+
console.error('Failed to connect to Redis:', error)
43+
throw error
44+
}
45+
}
46+
47+
async get(key) {
48+
try {
49+
const data = await this.client.get(key)
50+
if (!data) return null
51+
return this.deserialize(data)
52+
} catch (error) {
53+
console.error(`Error getting data for key ${key}:`, error)
54+
return null
55+
}
56+
}
57+
58+
async set(key, value) {
59+
try {
60+
const serializedData = this.serialize(value)
61+
await this.client.set(key, serializedData, { EX: 30 * 60 })
62+
} catch (error) {
63+
console.error(`Error setting data for key ${key}:`, error)
64+
}
65+
}
66+
67+
async delete(key) {
68+
try {
69+
const room = await this.get(key)
70+
if (room?.data && room?.lastEditedUser) {
71+
await this.apiService.saveRoomDataToServer(
72+
key,
73+
room.data,
74+
room.lastEditedUser,
75+
)
76+
}
77+
await this.client.del(key)
78+
} catch (error) {
79+
console.error(`Error deleting key ${key}:`, error)
80+
}
81+
}
82+
83+
async clear() {
84+
try {
85+
await this.client.flushDb()
86+
} catch (error) {
87+
console.error('Error clearing Redis database:', error)
88+
}
89+
}
90+
91+
async getRooms() {
92+
try {
93+
const keys = await this.client.keys('*')
94+
const rooms = new Map()
95+
for (const key of keys) {
96+
const room = await this.get(key)
97+
if (room) rooms.set(key, room)
98+
}
99+
return rooms
100+
} catch (error) {
101+
console.error('Error getting rooms:', error)
102+
return new Map()
103+
}
104+
}
105+
106+
serialize(value) {
107+
return value instanceof Room
108+
? JSON.stringify(value.toJSON())
109+
: JSON.stringify(value)
110+
}
111+
112+
deserialize(data) {
113+
const parsedData = JSON.parse(data)
114+
return parsedData.id && parsedData.users
115+
? Room.fromJSON(parsedData)
116+
: parsedData
117+
}
118+
119+
}

websocket_server/Room.js

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
/**
2+
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
3+
* SPDX-License-Identifier: AGPL-3.0-or-later
4+
*/
5+
6+
export default class Room {
7+
8+
constructor(id, data = null, users = new Set(), lastEditedUser = null) {
9+
this.id = id
10+
this.data = data
11+
this.users = new Set(users)
12+
this.lastEditedUser = lastEditedUser
13+
}
14+
15+
setUsers(users) {
16+
this.users = new Set(users)
17+
}
18+
19+
updateLastEditedUser(userId) {
20+
this.lastEditedUser = userId
21+
}
22+
23+
setData(data) {
24+
this.data = data
25+
}
26+
27+
isEmpty() {
28+
return this.users.size === 0
29+
}
30+
31+
toJSON() {
32+
return {
33+
id: this.id,
34+
data: this.data,
35+
users: Array.from(this.users),
36+
lastEditedUser: this.lastEditedUser,
37+
}
38+
}
39+
40+
static fromJSON(json) {
41+
return new Room(
42+
json.id,
43+
json.data,
44+
new Set(json.users),
45+
json.lastEditedUser,
46+
)
47+
}
48+
49+
}

0 commit comments

Comments
 (0)