Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Data management improvements #259

Merged
merged 1 commit into from
Dec 9, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
/css/
/vendor/
/node_modules/
/backup/

.php-cs-fixer.cache
.phpunit.result.cache
Expand Down
12 changes: 11 additions & 1 deletion websocket_server/ApiService.js
Original file line number Diff line number Diff line change
Expand Up @@ -59,8 +59,18 @@ export default class ApiService {
console.log(`[${roomID}] Saving room data to server: ${roomData.length} elements, ${Object.keys(files).length} files`)

const url = `${this.NEXTCLOUD_URL}/index.php/apps/whiteboard/${roomID}`
const body = { data: { type: 'excalidraw', elements: roomData, files: this.cleanupFiles(roomData, files) } }

const body = {
data: {
type: 'excalidraw',
elements: roomData,
files: this.cleanupFiles(roomData, files),
savedAt: Date.now(),
},
}

const options = this.fetchOptions('PUT', null, body, roomID, lastEditedUser)

return this.fetchData(url, options)
}

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

/* eslint-disable no-console */

import fs from 'fs/promises'
import path from 'path'
import crypto from 'crypto'
import zlib from 'zlib'
import { promisify } from 'util'

const gzip = promisify(zlib.gzip)
const gunzip = promisify(zlib.gunzip)

/**
* @typedef {object} BackupOptions
* @property {string} [backupDir='./backup'] - Directory to store backups
* @property {number} [maxBackupsPerRoom=5] - Maximum number of backups to keep per room
* @property {number} [lockTimeout=5000] - Maximum time in ms to wait for a lock
* @property {number} [lockRetryInterval=50] - Time in ms between lock retry attempts
*/

/**
* @typedef {object} BackupData
* @property {string} id - Unique identifier for the backup
* @property {number} timestamp - Timestamp when backup was created
* @property {number} roomId - ID of the room
* @property {string} checksum - SHA-256 hash of the data
* @property {object} data - The actual backup data
* @property {number} savedAt - Timestamp when the data was last saved
*/

/**
* Manages backup operations for whiteboard rooms
*/
export default class BackupManager {

/**
* Creates a new BackupManager instance
* @param {BackupOptions} [options] - Configuration options
*/
constructor(options = {}) {
const { backupDir = './backup', maxBackupsPerRoom = 5 } = 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.init()
}

/**
* Initializes the backup directory and cleans up temporary files
* @throws {Error} If initialization fails
*/
async init() {
try {
await fs.mkdir(this.backupDir, { recursive: true })
await this.cleanupTemporaryFiles()
} catch (error) {
console.error('Failed to initialize BackupManager:', error)
throw error
}
}

/**
* Removes temporary files from the backup directory
*/
async cleanupTemporaryFiles() {
try {
const files = await fs.readdir(this.backupDir)
const tmpFiles = files.filter((f) => f.endsWith('.tmp'))
await Promise.all(
tmpFiles.map((file) =>
fs
.unlink(path.join(this.backupDir, file))
.catch(console.error),
),
)
} catch (error) {
console.error('Failed to cleanup temporary files:', error)
}
}

/**
* Acquires a lock for a specific room
* @param {number} roomId - The room ID to lock
* @throws {Error} If lock cannot be acquired within timeout period
*/
async acquireLock(roomId) {
const startTime = Date.now()
while (this.locks.get(roomId)) {
if (Date.now() - startTime > this.lockTimeout) {
throw new Error(`Lock acquisition timeout for room ${roomId}`)
}
await new Promise((resolve) =>
setTimeout(resolve, this.lockRetryInterval),
)
}
this.locks.set(roomId, Date.now())
}

/**
* Releases a lock for a specific room
* @param {number} roomId - The room ID to unlock
*/
async releaseLock(roomId) {
this.locks.delete(roomId)
}

/**
* Ensures roomId is a valid number
* @param {number|string} roomId - The room ID to validate
* @return {number} The validated room ID
* @throws {Error} If roomId is invalid
*/
sanitizeRoomId(roomId) {
const numericRoomId = Number(roomId)
if (isNaN(numericRoomId) || numericRoomId <= 0) {
throw new Error('Invalid room ID: must be a positive number')
}
return numericRoomId
}

/**
* Calculates SHA-256 checksum of data
* @param {string | object} data - Data to calculate checksum for
* @return {string} Hex string of SHA-256 hash
*/
calculateChecksum(data) {
return crypto
.createHash('sha256')
.update(typeof data === 'string' ? data : JSON.stringify(data))
.digest('hex')
}

/**
* Creates a new backup for a room
* @param {number} roomId - The room ID
* @param {object} data - The data to backup
* @return {Promise<string>} The backup ID
* @throws {Error} If backup creation fails
*/
async createBackup(roomId, data) {
if (!roomId || !data) {
throw new Error('Invalid backup parameters')
}

const sanitizedRoomId = this.sanitizeRoomId(roomId)

try {
await this.acquireLock(sanitizedRoomId)

const backupData = this.prepareBackupData(sanitizedRoomId, data)
await this.writeBackupFile(sanitizedRoomId, backupData)
await this.cleanupOldBackups(sanitizedRoomId)

return backupData.id
} finally {
await this.releaseLock(sanitizedRoomId)
}
}

/**
* Prepares backup data structure
* @param {number} roomId - The room ID
* @param {object} data - The data to backup
* @return {BackupData} Prepared backup data
*/
prepareBackupData(roomId, data) {
return {
id: crypto.randomUUID(),
timestamp: Date.now(),
roomId,
checksum: this.calculateChecksum(data),
data,
savedAt: data.savedAt || Date.now(),
}
}

/**
* Writes backup data to file
* @param {number} roomId - The room ID
* @param {BackupData} backupData - The data to write
*/
async writeBackupFile(roomId, backupData) {
const backupFile = path.join(
this.backupDir,
`${roomId}_${backupData.timestamp}.bak`,
)
const tempFile = `${backupFile}.tmp`

const compressed = await gzip(JSON.stringify(backupData))
await fs.writeFile(tempFile, compressed)
await fs.rename(tempFile, backupFile)
hweihwang marked this conversation as resolved.
Show resolved Hide resolved
}

/**
* Retrieves the latest backup for a room
* @param {number} roomId - The room ID
* @return {Promise<BackupData|null>} The latest backup or null if none exists
* @throws {Error} If backup retrieval fails
*/
async getLatestBackup(roomId) {
const sanitizedRoomId = this.sanitizeRoomId(roomId)
const files = await fs.readdir(this.backupDir)
const roomBackups = files
.filter(
(f) =>
f.startsWith(`${sanitizedRoomId}_`) && f.endsWith('.bak'),
)
.sort()
.reverse()

if (roomBackups.length === 0) return null

try {
const compressed = await fs.readFile(
path.join(this.backupDir, roomBackups[0]),
)
const decompressed = await gunzip(compressed)
const backup = JSON.parse(decompressed.toString())

const calculatedChecksum = this.calculateChecksum(backup.data)
if (calculatedChecksum !== backup.checksum) {
throw new Error('Backup data corruption detected')
}

return backup
} catch (error) {
console.error(
`Failed to read latest backup for room ${sanitizedRoomId}:`,
error,
)
throw error
}
}

/**
* Removes old backups exceeding maxBackupsPerRoom
* @param {number} roomId - The room ID
*/
async cleanupOldBackups(roomId) {
const sanitizedRoomId = this.sanitizeRoomId(roomId)

try {
const files = await fs.readdir(this.backupDir)
const roomBackups = files
.filter(
(f) =>
f.startsWith(`${sanitizedRoomId}_`)
&& f.endsWith('.bak'),
)
.sort()
.reverse()

if (roomBackups.length <= this.maxBackupsPerRoom) {
return
}

const filesToDelete = roomBackups.slice(this.maxBackupsPerRoom)
await Promise.all(
filesToDelete.map((file) =>
fs
.unlink(path.join(this.backupDir, file))
.catch((error) => {
console.error(
`Failed to delete backup ${file}:`,
error,
)
}),
),
)
} catch (error) {
console.error(`Failed to cleanup old backups for ${roomId}:`, error)
}
}

/**
* Gets all backup files for a room
* @param {number} roomId - The room ID
* @return {Promise<string[]>} Array of backup filenames
*/
async getAllBackups(roomId) {
const sanitizedRoomId = this.sanitizeRoomId(roomId)
const files = await fs.readdir(this.backupDir)
return files
.filter(
(f) =>
f.startsWith(`${sanitizedRoomId}_`) && f.endsWith('.bak'),
)
.sort()
.reverse()
}

/**
* Recovers data from the latest backup
* @param {number} roomId - The room ID
* @return {Promise<object | null>} Recovered data or null if no backup exists
*/
async recoverFromBackup(roomId) {
const backup = await this.getLatestBackup(roomId)
if (!backup) {
console.log(`No backup found for room ${roomId}`)
return null
}
return backup.data
}

/**
* Checks if server data is newer than the latest backup
* @param {number} roomId - The room ID
* @param {object} serverData - Current server data
* @return {Promise<boolean>} True if server data is newer
*/
async isDataFresher(roomId, serverData) {
const latestBackup = await this.getLatestBackup(roomId)

if (!latestBackup) return true

const serverTimestamp = serverData?.savedAt || 0
const backupTimestamp = latestBackup.savedAt || 0

return serverTimestamp >= backupTimestamp
}

}
5 changes: 4 additions & 1 deletion websocket_server/LRUCacheStrategy.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export default class LRUCacheStrategy extends StorageStrategy {
ttlAutopurge: true,
dispose: async (value, key) => {
console.log(`[${key}] Disposing room`)

if (value?.data && value?.lastEditedUser) {
try {
await this.apiService.saveRoomDataToServer(
Expand Down Expand Up @@ -53,7 +54,9 @@ export default class LRUCacheStrategy extends StorageStrategy {
}

getRooms() {
const rooms = Array.from(this.cache.values()).filter((room) => room instanceof Room)
const rooms = Array.from(this.cache.values()).filter(
(room) => room instanceof Room,
)

return rooms
}
Expand Down
Loading
Loading
You are viewing a condensed version of this merge commit. You can view the full changes here.