Skip to content

Commit

Permalink
Fix server crashed, regular cleanups
Browse files Browse the repository at this point in the history
Signed-off-by: Hoang Pham <[email protected]>
  • Loading branch information
hweihwang committed Dec 16, 2024
1 parent ac790eb commit fb9f2f1
Show file tree
Hide file tree
Showing 16 changed files with 567 additions and 158 deletions.
9 changes: 4 additions & 5 deletions websocket_server/ApiService.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,14 @@
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
constructor(tokenGenerator, options = { nextcloudUrl: 'http://nextcloud.local', isDev: false, useTls: false }) {
this.NEXTCLOUD_URL = options.nextcloudUrl
this.agent = (options.isDev && options.useTls) ? new https.Agent({ rejectUnauthorized: false }) : null
this.tokenGenerator = tokenGenerator
}

Expand Down
8 changes: 3 additions & 5 deletions websocket_server/AppManager.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,15 @@

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

dotenv.config()

export default class AppManager {

constructor(storageManager) {
constructor(metricsManager, options = { metricsToken: '' }) {
this.app = express()
this.storageManager = storageManager
this.metricsManager = new PrometheusDataManager(storageManager)
this.METRICS_TOKEN = process.env.METRICS_TOKEN
this.metricsManager = metricsManager
this.METRICS_TOKEN = options.metricsToken
this.setupRoutes()
}

Expand Down
6 changes: 3 additions & 3 deletions websocket_server/BackupManager.js
Original file line number Diff line number Diff line change
Expand Up @@ -42,12 +42,12 @@ export default class BackupManager {
* @param {BackupOptions} [options] - Configuration options
*/
constructor(options = {}) {
const { backupDir = './backup', maxBackupsPerRoom = 5 } = options
const { backupDir = './backup', maxBackupsPerRoom = 5, lockTimeout = 5000, lockRetryInterval = 50 } = options
this.backupDir = backupDir
this.maxBackupsPerRoom = maxBackupsPerRoom
this.locks = new Map()
this.lockTimeout = options.lockTimeout || 5000 // 5 seconds
this.lockRetryInterval = options.lockRetryInterval || 50 // 50ms
this.lockTimeout = lockTimeout
this.lockRetryInterval = lockRetryInterval
this.init()
}

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

import Utils from './Utils.js'
import RoomDataManager from './RoomDataManager.js'

/**
* Manages cleanup operations for the whiteboard server
*/
export default class CleanupManager {

/**
* Creates a new CleanupManager instance
* @param {RoomDataManager} roomDataManager - Manager for room data
* @param {object} options - Cleanup options
* @param {number} options.cleanupInterval - Interval in milliseconds for room cleanup
*/
constructor(roomDataManager, { cleanupInterval = 5 * 60 * 1000 } = {}) {
this.roomDataManager = roomDataManager
this.storageManager = roomDataManager.storageManager

this.ROOM_CLEANUP_INTERVAL = cleanupInterval
this.cleanupIntervals = new Set()
}

/**
* Starts periodic cleanup tasks
*/
startPeriodicTasks() {
Utils.logOperation('SYSTEM', 'Starting periodic cleanup tasks...')

const roomCleanup = setInterval(() => {
this.cleanupRooms()
.catch(error => Utils.logError('SYSTEM', 'Room cleanup failed:', error))
}, this.ROOM_CLEANUP_INTERVAL)

this.cleanupIntervals.add(roomCleanup)
}

/**
* Performs cleanup of rooms
* @return {Promise<void>}
*/
async cleanupRooms() {
Utils.logOperation('SYSTEM', 'Running room cleanup...')
const rooms = await this.storageManager.getRooms()

for (const [roomId, room] of rooms.entries()) {
try {
await this.storageManager.delete(roomId)
Utils.logOperation(roomId, 'Auto-saved and cleaned up room data')
} catch (error) {
Utils.logError(roomId, 'Failed to cleanup room:', error)
// Try to restore room in case of error during the cleanup
try {
await this.storageManager.set(roomId, room)
} catch (restoreError) {
Utils.logError(
roomId,
'Failed to restore room after failed cleanup:',
restoreError,
)
}
}
}
}

/**
* Stops all periodic cleanup tasks
*/
stopPeriodicTasks() {
Utils.logOperation('SYSTEM', 'Stopping periodic cleanup tasks...')
for (const interval of this.cleanupIntervals) {
clearInterval(interval)
}
this.cleanupIntervals.clear()
}

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

import StorageStrategy from './StorageStrategy.js'

export default class InMemoryStrategy extends StorageStrategy {

constructor() {
super()
this.store = new Map()
}

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

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

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

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

getRooms() {
throw new Error('Method not implemented.')
}

}
6 changes: 3 additions & 3 deletions websocket_server/LRUCacheStrategy.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,12 @@ import Room from './Room.js'

export default class LRUCacheStrategy extends StorageStrategy {

constructor(apiService) {
constructor(apiService, options = { maxRooms: 1000, roomDataMaxAge: 30 * 60 * 1000 }) {
super()
this.apiService = apiService
this.cache = new LRUCache({

Check failure on line 17 in websocket_server/LRUCacheStrategy.js

View workflow job for this annotation

GitHub Actions / test

tests/integration/metrics.spec.mjs > Metrics endpoint

TypeError: At least one of max, maxSize, or ttl is required ❯ new LRUCache node_modules/lru-cache/src/index.ts:1452:13 ❯ new LRUCacheStrategy websocket_server/LRUCacheStrategy.js:17:16 ❯ Function.create websocket_server/StorageManager.js:53:15 ❯ new ServerManager websocket_server/ServerManager.js:45:37 ❯ tests/integration/metrics.spec.mjs:14:19

Check failure on line 17 in websocket_server/LRUCacheStrategy.js

View workflow job for this annotation

GitHub Actions / test

tests/integration/socket.spec.mjs > Socket handling

TypeError: At least one of max, maxSize, or ttl is required ❯ new LRUCache node_modules/lru-cache/src/index.ts:1452:13 ❯ new LRUCacheStrategy websocket_server/LRUCacheStrategy.js:17:16 ❯ Function.create websocket_server/StorageManager.js:53:15 ❯ new ServerManager websocket_server/ServerManager.js:45:37 ❯ tests/integration/socket.spec.mjs:22:19
max: 1000,
ttl: 30 * 60 * 1000,
max: options.maxRooms,
ttl: options.roomDataMaxAge,
ttlAutopurge: true,
dispose: async (value, key) => {
console.log(`[${key}] Disposing room`)
Expand Down
5 changes: 2 additions & 3 deletions websocket_server/PrometheusDataManager.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,11 @@
*/

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

export default class PrometheusDataManager {

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

Expand Down
4 changes: 2 additions & 2 deletions websocket_server/RedisStrategy.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,11 @@ import Room from './Room.js'

export default class RedisStrategy extends StorageStrategy {

constructor(apiService) {
constructor(apiService, options = { redisUrl: 'redis://localhost:6379' }) {
super()
this.apiService = apiService
this.client = createClient({
url: process.env.REDIS_URL || 'redis://localhost:6379',
url: options.redisUrl,
retry_strategy: (options) => {
if (options.error && options.error.code === 'ECONNREFUSED') {
return new Error('The server refused the connection')
Expand Down
20 changes: 0 additions & 20 deletions websocket_server/RoomDataManager.js
Original file line number Diff line number Diff line change
Expand Up @@ -299,24 +299,4 @@ export default class RoomDataManager {
return backupData
}

/**
* Handles empty room cleanup
* @param {string} roomId - Room identifier
* @return {Promise<null>}
*/
async handleEmptyRoom(roomId) {
await this.cleanupEmptyRoom(roomId)
return null
}

/**
* Removes empty room from storage
* @param {string} roomId - Room identifier
* @return {Promise<void>}
*/
async cleanupEmptyRoom(roomId) {
await this.storageManager.delete(roomId)
Utils.logOperation(roomId, 'Empty room removed from cache')
}

}
79 changes: 67 additions & 12 deletions websocket_server/ServerManager.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,20 +17,69 @@ import AppManager from './AppManager.js'
import SocketManager from './SocketManager.js'
import Utils from './Utils.js'
import BackupManager from './BackupManager.js'
import CleanupManager from './CleanupManager.js'
import PrometheusDataManager from './PrometheusDataManager.js'
import SystemMonitor from './SystemMonitor.js'
import SocketDataManager from './SocketDataManager.js'

export default class ServerManager {

constructor(config) {
this.config = config
this.closing = false
this.tokenGenerator = new SharedTokenGenerator()
this.apiService = new ApiService(this.tokenGenerator)
this.backupManager = new BackupManager({})
this.storageManager = StorageManager.create(this.config.storageStrategy, this.apiService)
this.roomDataManager = new RoomDataManager(this.storageManager, this.apiService, this.backupManager)
this.appManager = new AppManager(this.storageManager)
this.server = this.createConfiguredServer(this.appManager.getApp())
this.socketManager = new SocketManager(this.server, this.roomDataManager, this.storageManager)

this.tokenGenerator = new SharedTokenGenerator(config.sharedSecret)

this.apiService = new ApiService(this.tokenGenerator, {
nextcloudUrl: this.config.nextcloudUrl,
isDev: Utils.parseBooleanFromEnv(this.config.isDev),
})

this.backupManager = new BackupManager({
backupDir: this.config.backupDir,
maxBackupsPerRoom: this.config.maxBackupsPerRoom,
lockTimeout: this.config.lockTimeout,
lockRetryInterval: this.config.lockRetryInterval,
})

this.roomStorage = StorageManager.create(this.config.storageStrategy, this.apiService, {
maxRooms: this.config.maxRooms,
roomDataMaxAge: this.config.roomDataMaxAge,
redisUrl: this.config.redisUrl,
})

this.sessionStorage = this.config.storageStrategy === 'redis'
? StorageManager.create('redis', this.apiService, { redisUrl: this.config.redisUrl })
: StorageManager.create('in-mem')

this.roomDataManager = new RoomDataManager(this.roomStorage, this.apiService, this.backupManager, {
maxRooms: this.config.maxRooms,
roomDataMaxAge: this.config.roomDataMaxAge,
})

this.systemMonitor = new SystemMonitor(this.roomStorage)

this.metricsManager = new PrometheusDataManager(this.systemMonitor)

this.appManager = new AppManager(this.metricsManager, {
metricsToken: this.config.metricsToken,
})

this.server = this.createConfiguredServer(this.appManager.getApp(), Utils.parseBooleanFromEnv(this.config.tls))

this.socketDataManager = new SocketDataManager(this.sessionStorage)

this.socketManager = new SocketManager(this.server, this.roomDataManager, this.sessionStorage, this.socketDataManager, {
jwtSecretKey: this.config.jwtSecretKey,
nextcloudUrl: this.config.nextcloudUrl,
})

this.cleanupManager = new CleanupManager(
this.roomDataManager,
{ cleanupInterval: this.config.cleanupInterval },
)

this.cleanupManager.startPeriodicTasks()
}

readTlsCredentials(keyPath, certPath) {
Expand All @@ -40,8 +89,7 @@ export default class ServerManager {
}
}

createConfiguredServer(app) {
const useTls = Utils.parseBooleanFromEnv(this.config.tls)
createConfiguredServer(app, useTls = false) {
const serverType = useTls ? https : http
const serverOptions = useTls ? this.readTlsCredentials(this.config.keyPath, this.config.certPath) : {}

Expand All @@ -68,9 +116,16 @@ export default class ServerManager {
async gracefulShutdown() {
if (this.closing) return
this.closing = true
console.log('Received shutdown signal, saving all data...')
console.log('Received shutdown signal, performing cleanup...')

try {
await this.roomDataManager.removeAllRoomData()
// Stop periodic cleanup tasks
this.cleanupManager.stopPeriodicTasks()

// Run one final cleanup
await this.cleanupManager.cleanupRooms()

// Continue with existing shutdown logic
this.socketManager.io.close()
console.log('Closing server...')
this.server.close(() => {
Expand Down
4 changes: 2 additions & 2 deletions websocket_server/SharedTokenGenerator.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ dotenv.config()

export default class SharedTokenGenerator {

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

handle(roomId) {
Expand Down
Loading

0 comments on commit fb9f2f1

Please sign in to comment.