feat(redis): support Redis Cluster via comma separated seed nodes - #1318
bakiburakogun wants to merge 2 commits into
Conversation
9c8f3f7 to
1cff115
Compare
|
Hello there, We hope that the review process is going smooth and is helpful for you. We want to ensure your pull request is reviewed to your satisfaction. If you have a moment, our community management team would very much appreciate your feedback on your experience with this PR review process. Your feedback is valuable to us as we continuously strive to improve our community developer experience. Please take a moment to complete our short survey by clicking on the following link: https://cloud.nextcloud.com/apps/forms/s/i9Ago4EQRZ7TWxjfmeEpPkf6 Thank you for contributing to Nextcloud and we hope to hear from you soon! (If you believe you should not receive this message, you can add yourself to the blocklist.) |
There was a problem hiding this comment.
Thanks for the PR and the testing notes. All 40 reads and writes passed locally, but cleanup and shutdown fail with the cluster client.
I've left four findings below. Please add tests for those cases and check that existing single-node and Unix socket setups still work.
| if (username) defaults.username = decodeURIComponent(username) | ||
| if (password) defaults.password = decodeURIComponent(password) | ||
|
|
||
| return createCluster({ |
There was a problem hiding this comment.
The redis@4 cluster client has no scanIterator(), which RoomStateStore uses. Both runSweep() and gracefulShutdown() throw a TypeError against a three-primary cluster.
Please scan each primary through its own client and test cleanup and shutdown. RedisAdapter.clear() also needs updating: Redis rejects a single DEL call when its keys belong to different hash slots.
| .filter((url) => url.length > 0) | ||
| .map((url) => ({ url })) | ||
|
|
||
| const { username, password } = new URL(rootNodes[0].url) |
There was a problem hiding this comment.
rediss:// enables TLS for the seed connection, but connections to discovered nodes use defaults. Without defaults.socket.tls, those connections use plain TCP, so TLS-only clusters cannot connect.
Please preserve TLS in the defaults and test connections to discovered nodes.
| static createRedisClient() { | ||
| console.log(`Creating Redis client with URL: ${Config.REDIS_URL}`) | ||
|
|
||
| if (Config.REDIS_URL.includes(',')) { |
There was a problem hiding this comment.
redis://:test,password@localhost:6379 is a valid single-node URL. This check treats it as a cluster URL and splits the password, breaking an existing setup.
Please distinguish a list of nodes from commas inside a password, and add a test for this case.
| console.log(`Creating Redis client with URL: ${Config.REDIS_URL}`) | ||
|
|
||
| if (Config.REDIS_URL.includes(',')) { | ||
| return RedisAdapter.createRedisClusterClient(Config.REDIS_URL) |
There was a problem hiding this comment.
Confirmed the startup error you noted: the first heartbeat runs before connect() finishes and throws Cannot read properties of undefined (reading 'master'). The error is caught, so the server can accept clients before Redis is ready.
Please await the connection before starting services that use Redis, and test slow and failed connections.
1cff115 to
95eaf63
Compare
|
All four confirmed, and the cleanup and shutdown failure you hit was the worst of them. Reading it back, the PR was written as if a cluster client were a drop-in for a single one, and it is not. The TLS. Also right, and I had the reason wrong in my own comment.
The startup error. One behaviour change worth calling out: a connection that never comes up now stops the server from starting, instead of being logged while it carries on serving clients without Redis. Tests, in
I checked each of these fails without its fix rather than trusting that they pass: reverting the comma rule and the TLS default fails three of them, and reverting the awaited connection fails both ordering tests with Whole suite is 119 passing across 15 files, and Branch rebased onto current |
|
I found a way to run this against a real three primary cluster after all, so here is what it does rather than what I believe it does. The setup is three nodes in cluster mode with no replicas, all 16384 slots covered. One caveat up front: the server is Memurai Developer 4.2.3 (API 7.4.9), which is what Your findings, reproduced: The third one is yours exactly. It comes from issuing a command while The fixed paths, on the same cluster:
Nothing failed. What I still have not covered is TLS: I have no TLS cluster to point it at, so The scratch script is not in the branch. The tests that are keep using fakes, so the suite stays runnable without a cluster. |
The websocket server logs its Redis URL verbatim on every start, so a
deployment that authenticates to Redis writes the password into the journal
in clear text:
Creating Redis client with URL: redis://:s3cret@10.0.0.5:6379
Redact the credentials before logging. The host and port, which are what the
line is useful for, stay visible.
Redacting the log line is not enough on its own. The statement after it parses
the same value with new URL(), and the error that throws carries the value it
was given on its input property, which main() prints when the server fails to
start. Raise a configuration error that repeats nothing of the value instead.
A comma is legal inside a Redis password, so the value is only read as several
node URLs when every part on its own is a URL with a scheme and a host. That
leaves redis://:pass,word@host alone while still redacting each node of the
seed list used for Redis Cluster.
Signed-off-by: Baki Burak Öğün <63836730+bakiburakogun@users.noreply.github.com>
95eaf63 to
295f7a0
Compare
The README recommends Redis for multi-node websocket deployments, but the client is built with createClient(), which does not follow MOVED redirections. Pointed at a Redis Cluster it connects and then fails roughly half of all operations at run time, depending on which slot a key hashes to. Treat a comma separated REDIS_URL as a list of cluster seed nodes and build a cluster client from it. A single URL keeps its existing behaviour, and so does a password with a comma in it, since the value is only read as a list when every part on its own is a URL with a scheme and a host. Cluster discovery reports the remaining nodes without credentials, and the scheme of a seed only governs the connection to that seed, so both the credentials of the first seed and its TLS setting are repeated in the client defaults. Without the latter a TLS only cluster is unreachable: the seed connects over TLS and every discovered node is then tried over plain TCP. The cluster client has no scanIterator(), because a scan only covers the keyspace of the node it runs on, and Redis refuses a DEL whose keys span several hash slots. Both are what RedisAdapter.clear() and the key listings in RoomStateStore rely on, so they now go through helpers that scan each primary through its own client and delete one key at a time on a cluster. Finally, nothing that talks to Redis may run before the connection is up. The connection was started and not awaited, so the first heartbeat could reach a cluster client whose slots were still unknown, fail, and leave the server accepting clients while Redis was not ready. The services now wait for it, and a connection that never succeeds stops the server from starting rather than being logged and passed over. Signed-off-by: Baki Burak Öğün <63836730+bakiburakogun@users.noreply.github.com>
295f7a0 to
8df9bea
Compare
Fixes #1317
Summary
RedisAdapter.createRedisClient()builds a single-node client withcreateClient(), which does not followMOVEDredirections. Pointed at a Redis Cluster it connects successfully, the Socket.IO Redis Streams adapter sets up, and then roughly half of all operations fail at run time depending on which slot a key hashes to — a silent, partial failure rather than a clean one.This treats a comma separated
REDIS_URLas a list of cluster seed nodes:A single URL behaves exactly as before.
createCluster()comes from theredispackage already independencies, so there is no new requirement.Credentials given on the first seed are passed as cluster
defaults, because cluster discovery reports the remaining nodes without auth and they would otherwise be rejected.Testing
Against a 3-master / 3-replica cluster on a Debian 12 host, writing 40 keys:
createClient()pointed at one node (before)MOVED 8308 127.0.0.1:7002createCluster()with three seeds (after)Reads afterwards returned 40/40.
I also ran the real websocket server from this branch against that cluster, with
STORAGE_STRATEGY=redisand three seeds, and connected two Socket.IO clients with valid JWTs to the same board. Both receivedinit-room,room-user-changeanduser-joinedfor each other, so room state and the Streams adapter both work through the cluster client.One rough edge I did not fix
With a cluster client, the first command issued before the slot map is loaded throws rather than being queued, and I saw exactly one such error at startup:
It happened once, did not recur, and nothing downstream was affected — the server served boards normally afterwards. The cause is that
ServerServicestarts the connection without awaiting it (this.redisClient.connect().catch(...)), which a single-node client tolerates because it queues commands. Making startup await the connection would fix it properly, but that changes the constructor's shape, so I left it out of this PR rather than restructure startup on your behalf. Happy to follow up with that if you would like it.Note for administrators
Redis Cluster only has database 0, so the
/database_numbersuffix cannot be used to separate whiteboard keys from other users of the same cluster. I mentioned this in the README next to the cluster example.