Skip to content
Open
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
2 changes: 2 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ STORAGE_STRATEGY=lru
# Format: redis://[username:password@]host[:port][/database_number]
# Example: redis://user:password@redis.example.com:6379/0
REDIS_URL=redis://localhost:6379
# For Redis Cluster, list the seed nodes separated by commas:
# REDIS_URL=redis://node1:6379,redis://node2:6379,redis://node3:6379

# Timeout in milliseconds for the whiteboard server's graceful shutdown process
# If the server cannot close all connections within this time, it will force shutdown
Expand Down
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,17 @@ STORAGE_STRATEGY=redis
REDIS_URL=redis://[username:password@]host[:port][/database_number]
```

**Redis Cluster**
- List the seed nodes separated by commas; the remaining nodes are discovered automatically
- Any credentials given on the first seed are applied to every discovered node, and so is TLS if the first seed uses `rediss://`
- In a list of seeds, write a comma inside a password as `%2C`, since the list is split on commas; a single URL with a comma in its password is still read as one node
- Note that Redis Cluster only has database 0, so a database number cannot be used to separate whiteboard keys from other tenants of the same cluster

```bash
STORAGE_STRATEGY=redis
REDIS_URL=redis://node1:6379,redis://node2:6379,redis://node3:6379
```

### Scaling (Optional)

For high-traffic environments with multiple websocket servers:
Expand Down
3 changes: 0 additions & 3 deletions eslint-suppressions.json
Original file line number Diff line number Diff line change
Expand Up @@ -1248,9 +1248,6 @@
}
},
"websocket_server/Services/ServerService.js": {
"@stylistic/arrow-parens": {
"count": 1
},
"@stylistic/padded-blocks": {
"count": 3
},
Expand Down
68 changes: 68 additions & 0 deletions tests/integration/redisClientErrors.spec.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

import { EventEmitter } from 'node:events'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'

const redis = vi.hoisted(() => ({
createClient: vi.fn(),
createCluster: vi.fn(),
}))
vi.mock('redis', () => redis)

const { default: RedisAdapter } = await import('../../websocket_server/Adapters/RedisAdapter.js')
const { default: Config } = await import('../../websocket_server/Utilities/ConfigUtility.js')

describe('errors emitted by the Redis client', () => {
const configuredUrl = Config.REDIS_URL
let errorLog

beforeEach(() => {
vi.spyOn(console, 'log').mockImplementation(() => {})
errorLog = vi.spyOn(console, 'error').mockImplementation(() => {})
redis.createClient.mockImplementation(() => new EventEmitter())
redis.createCluster.mockImplementation(() => new EventEmitter())
})

afterEach(() => {
Config.REDIS_URL = configuredUrl
vi.useRealTimers()
vi.restoreAllMocks()
})

it.each([
['a single node', 'redis://localhost:6379'],
['a unix socket', 'unix:///var/run/redis.sock'],
['a cluster', 'redis://node1:6379,redis://node2:6379'],
])('does not end the process when %s loses its connection', (_, url) => {
Config.REDIS_URL = url

const client = RedisAdapter.createRedisClient()

expect(client.listenerCount('error')).toBe(1)
expect(() => client.emit('error', new Error('read ECONNRESET'))).not.toThrow()
expect(errorLog).toHaveBeenCalledWith('Redis client error:', 'read ECONNRESET')
})

it('logs a repeated message once a minute rather than on every reconnect attempt', () => {
vi.useFakeTimers()
Config.REDIS_URL = 'redis://node1:6379,redis://node2:6379'
const client = RedisAdapter.createRedisClient()
const refused = new Error('connect ECONNREFUSED 127.0.0.1:7105')

for (let i = 0; i < 10; i++) {
client.emit('error', refused)
vi.advanceTimersByTime(500)
}
expect(errorLog).toHaveBeenCalledTimes(1)

client.emit('error', new Error('read ECONNRESET'))
expect(errorLog).toHaveBeenCalledTimes(2)

vi.advanceTimersByTime(60 * 1000)
client.emit('error', refused)
expect(errorLog).toHaveBeenCalledTimes(3)
})
})
224 changes: 224 additions & 0 deletions tests/integration/redisCluster.spec.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'

const redis = vi.hoisted(() => ({
createClient: vi.fn(() => ({ kind: 'single', on: vi.fn() })),
createCluster: vi.fn(() => ({ kind: 'cluster', on: vi.fn() })),
}))
vi.mock('redis', () => redis)

const { default: RedisAdapter } = await import('../../websocket_server/Adapters/RedisAdapter.js')
const { default: Config } = await import('../../websocket_server/Utilities/ConfigUtility.js')
const { deleteKeys, isClusterClient, scanKeys } = await import('../../websocket_server/Utilities/RedisUtility.js')

const SECRET = 'p@ss:w0rd'

describe('choosing between a single node and a cluster', () => {
const configuredUrl = Config.REDIS_URL

beforeEach(() => {
vi.spyOn(console, 'log').mockImplementation(() => {})
})

afterEach(() => {
Config.REDIS_URL = configuredUrl
redis.createClient.mockClear()
redis.createCluster.mockClear()
vi.restoreAllMocks()
})

it('builds a single client for one URL', () => {
Config.REDIS_URL = 'redis://localhost:6379'

RedisAdapter.createRedisClient()

expect(redis.createCluster).not.toHaveBeenCalled()
expect(redis.createClient).toHaveBeenCalledWith({ url: 'redis://localhost:6379' })
})

it('builds a single client for a unix socket', () => {
Config.REDIS_URL = 'unix:///var/run/redis.sock?db=3'

RedisAdapter.createRedisClient()

expect(redis.createCluster).not.toHaveBeenCalled()
expect(redis.createClient).toHaveBeenCalledWith({
socket: { path: '/var/run/redis.sock' },
database: 3,
})
})

it('keeps a single client when the password holds a comma', () => {
Config.REDIS_URL = 'redis://:one,two@localhost:6379'

RedisAdapter.createRedisClient()

expect(redis.createCluster).not.toHaveBeenCalled()
expect(redis.createClient).toHaveBeenCalledWith({ url: 'redis://:one,two@localhost:6379' })
})

it('builds a cluster client from a list of seed nodes', () => {
Config.REDIS_URL = 'redis://node1:6379,redis://node2:6379,redis://node3:6379'

RedisAdapter.createRedisClient()

expect(redis.createClient).not.toHaveBeenCalled()
expect(redis.createCluster).toHaveBeenCalledWith({
rootNodes: [
{ url: 'redis://node1:6379' },
{ url: 'redis://node2:6379' },
{ url: 'redis://node3:6379' },
],
})
})

it('repeats the credentials of the first seed in the defaults', () => {
Config.REDIS_URL = `redis://user:${encodeURIComponent(SECRET)}@node1:6379,redis://node2:6379`

RedisAdapter.createRedisClient()

const [options] = redis.createCluster.mock.calls[0]
expect(options.defaults).toEqual({ username: 'user', password: SECRET })
})

it('reads a comma written as %2C in a seed list password', () => {
Config.REDIS_URL = 'redis://user:one%2Ctwo@node1:6379,redis://node2:6379'

RedisAdapter.createRedisClient()

const [options] = redis.createCluster.mock.calls[0]
expect(options.rootNodes).toHaveLength(2)
expect(options.defaults).toEqual({ username: 'user', password: 'one,two' })
})

it('repeats TLS in the defaults, which discovered nodes connect with', () => {
Config.REDIS_URL = 'rediss://node1:6380,rediss://node2:6380'

RedisAdapter.createRedisClient()

const [options] = redis.createCluster.mock.calls[0]
expect(options.defaults).toEqual({ socket: { tls: true } })
})

it('repeats both credentials and TLS when the first seed carries both', () => {
Config.REDIS_URL = `rediss://user:${SECRET}@node1:6380,rediss://node2:6380`

RedisAdapter.createRedisClient()

const [options] = redis.createCluster.mock.calls[0]
expect(options.defaults).toEqual({
username: 'user',
password: SECRET,
socket: { tls: true },
})
})

it('leaves the defaults out when the seeds need nothing', () => {
Config.REDIS_URL = 'redis://node1:6379,redis://node2:6379'

RedisAdapter.createRedisClient()

const [options] = redis.createCluster.mock.calls[0]
expect(options).not.toHaveProperty('defaults')
})
})

/**
* A stand-in for one node of a cluster.
*
* @param {string[]} keys the keys this node holds
* @return {object} an object with the scanIterator() of a single client
*/
function fakeNode(keys) {
return {
async* scanIterator() {
for (const key of keys) {
yield key
}
},
}
}

describe('scanning the keyspace', () => {
it('recognises a cluster client by its nodeClient()', () => {
expect(isClusterClient(fakeNode([]))).toBe(false)
expect(isClusterClient({ nodeClient: () => {}, masters: [] })).toBe(true)
expect(isClusterClient(null)).toBe(false)
})

it('scans a single client directly', async () => {
const client = fakeNode(['a', 'b'])
const seen = []
for await (const key of scanKeys(client, { MATCH: 'x*' })) {
seen.push(key)
}
expect(seen).toEqual(['a', 'b'])
})

it('scans every primary of a cluster through its own client', async () => {
const nodes = [fakeNode(['a']), fakeNode(['b', 'c']), fakeNode([])]
const masters = [{ id: 0 }, { id: 1 }, { id: 2 }]
const cluster = {
masters,
nodeClient: vi.fn((master) => nodes[master.id]),
}

const seen = []
for await (const key of scanKeys(cluster, { MATCH: 'x*' })) {
seen.push(key)
}

expect(seen).toEqual(['a', 'b', 'c'])
expect(cluster.nodeClient).toHaveBeenCalledTimes(3)
})

it('waits for a node client that is still connecting', async () => {
const cluster = {
masters: [{ id: 0 }],
nodeClient: () => Promise.resolve(fakeNode(['a'])),
}

const seen = []
for await (const key of scanKeys(cluster, {})) {
seen.push(key)
}

expect(seen).toEqual(['a'])
})
})

describe('deleting keys', () => {
it('deletes a whole batch in one call on a single node', async () => {
const client = { del: vi.fn(async () => 2) }

await deleteKeys(client, ['a', 'b'])

expect(client.del).toHaveBeenCalledTimes(1)
expect(client.del).toHaveBeenCalledWith(['a', 'b'])
})

it('deletes one key at a time on a cluster, which refuses a batch across slots', async () => {
const cluster = {
masters: [],
nodeClient: () => {},
del: vi.fn(async () => 1),
}

await deleteKeys(cluster, ['a', 'b', 'c'])

expect(cluster.del).toHaveBeenCalledTimes(3)
expect(cluster.del.mock.calls.map(([key]) => key)).toEqual(['a', 'b', 'c'])
})

it('does nothing when there is nothing to delete', async () => {
const client = { del: vi.fn() }

await deleteKeys(client, [])

expect(client.del).not.toHaveBeenCalled()
})
})
Loading
Loading