diff --git a/src/networking.c b/src/networking.c index 502cacd77cd..eebdaff90ff 100644 --- a/src/networking.c +++ b/src/networking.c @@ -4301,6 +4301,7 @@ sds catClientInfoString(sds s, client *client, int hide_user_data) { p = capa; if (client->capa & CLIENT_CAPA_REDIRECT) *p++ = 'r'; + if (client->capa & CLIENT_CAPA_REDIRECT_KEYLESS) *p++ = 'k'; *p = '\0'; /* Compute the total memory consumed by this client. */ @@ -4782,6 +4783,7 @@ static int validateClientCapaFilter(sds capa) { const char capability = capa[i]; switch (capability) { case 'r': + case 'k': /* Valid capability, do nothing. */ break; default: @@ -4898,6 +4900,9 @@ static int clientMatchesCapaFilter(client *c, sds capa_filter) { case 'r': /* client supports redirection */ if (!(c->capa & CLIENT_CAPA_REDIRECT)) return 0; break; + case 'k': /* client supports keyless redirection */ + if (!(c->capa & CLIENT_CAPA_REDIRECT_KEYLESS)) return 0; + break; default: /* Invalid capa, return false */ return 0; @@ -5005,6 +5010,8 @@ void clientHelpCommand(client *c) { " The client claims its some capability options. Options are:", " * REDIRECT", " The client can handle redirection during primary and replica failover in standalone mode.", + " * REDIRECT-KEYLESS", + " Redirect keyless read commands to the primary when the replica is not in READONLY mode.", "GETREDIR", " Return the client ID we are redirecting to when tracking is enabled.", "GETNAME", @@ -5610,6 +5617,8 @@ void clientCapaCommand(client *c) { for (int i = 2; i < c->argc; i++) { if (!strcasecmp(objectGetVal(c->argv[i]), "redirect")) { c->capa |= CLIENT_CAPA_REDIRECT; + } else if (!strcasecmp(objectGetVal(c->argv[i]), "redirect-keyless")) { + c->capa |= CLIENT_CAPA_REDIRECT_KEYLESS; } } addReply(c, shared.ok); diff --git a/src/server.c b/src/server.c index 03050bbae6e..666af9f19bb 100644 --- a/src/server.c +++ b/src/server.c @@ -4409,8 +4409,8 @@ int processCommand(client *c) { * However we don't perform the redirection if: * 1) The sender of this command is our primary. * 2) The command has no key arguments. */ - if (server.cluster_enabled && !obey_client && - !(!(c->cmd->flags & CMD_MOVABLE_KEYS) && c->cmd->key_specs_num == 0 && c->cmd->proc != execCommand)) { + int is_keyless = !(c->cmd->flags & CMD_MOVABLE_KEYS) && c->cmd->key_specs_num == 0 && c->cmd->proc != execCommand; + if (server.cluster_enabled && !obey_client && !is_keyless) { int error_code; clusterNode *n = getNodeByQuery(c, &error_code); if (n == NULL || !clusterNodeIsMyself(n)) { @@ -4426,6 +4426,30 @@ int processCommand(client *c) { } } + /* If the client has the redirect-keyless capability, redirect keyless + * read commands to the primary when this is a replica and the client + * has not opted into replica reads with READONLY. EXEC with all-keyless + * queued commands is also considered keyless (c->slot remains -1 after + * getNodeByQuery). */ + int is_keyless_exec = is_exec && c->slot == -1; + if (server.cluster_enabled && !obey_client && (is_keyless || is_keyless_exec) && is_read_command && + (c->capa & CLIENT_CAPA_REDIRECT_KEYLESS) && !c->flag.readonly) { + clusterNode *myself = getMyClusterNode(); + if (clusterNodeIsReplica(myself)) { + clusterNode *primary = clusterNodeGetPrimary(myself); + if (is_keyless_exec) { + discardTransaction(c); + } else { + flagTransaction(c); + } + /* We pass -1 as it does not matter which slot will be passed in MOVED command */ + clusterRedirectClient(c, primary, -1, CLUSTER_REDIR_MOVED); + c->duration = 0; + c->cmd->rejected_calls++; + return C_OK; + } + } + if (clientSupportStandAloneRedirect(c) && !obey_client && (is_write_command || (is_read_command && !c->flag.readonly))) { if (server.failover_state == FAILOVER_IN_PROGRESS) { diff --git a/src/server.h b/src/server.h index d0079f7dae5..46a9bccacd1 100644 --- a/src/server.h +++ b/src/server.h @@ -334,7 +334,8 @@ extern int configOOMScoreAdjValuesDefaults[CONFIG_OOM_COUNT]; #define CMD_DOC_SYSCMD (1 << 1) /* System (internal) command */ /* Client capabilities */ -#define CLIENT_CAPA_REDIRECT (1 << 0) /* Indicate that the client can handle redirection */ +#define CLIENT_CAPA_REDIRECT (1 << 0) /* Indicate that the client can handle redirection */ +#define CLIENT_CAPA_REDIRECT_KEYLESS (1 << 1) /* Redirect keyless read commands to primary in cluster mode */ /* Client block type (btype field in client structure) * if CLIENT_BLOCKED flag is set. */ diff --git a/tests/unit/cluster/replica-redirect.tcl b/tests/unit/cluster/replica-redirect.tcl index ac9c8bd3026..1faf8000600 100644 --- a/tests/unit/cluster/replica-redirect.tcl +++ b/tests/unit/cluster/replica-redirect.tcl @@ -56,4 +56,112 @@ start_cluster 1 1 {tags {external:skip cluster}} { $rd0_ro close $rd1 close } + + test {keyless read commands execute on replica without redirect-keyless capa} { + # Without the capa, keyless read commands like SCAN execute locally on the replica + # After failover, node 0 is the new replica. + set rd [valkey_deferring_client 0] + $rd SCAN 0 + set reply [$rd read] + assert_match "0 *" $reply + $rd close + } + + test {keyless read commands are MOVED with redirect-keyless capa} { + set rd [valkey_deferring_client 0] + $rd CLIENT CAPA redirect-keyless + assert_equal OK [$rd read] + + $rd DBSIZE + assert_error "MOVED *" {$rd read} + + $rd RANDOMKEY + assert_error "MOVED *" {$rd read} + + $rd SCAN 0 + assert_error "MOVED *" {$rd read} + + $rd close + } + + test {keyless read commands execute on replica with redirect-keyless and READONLY} { + set rd [valkey_deferring_client 0] + $rd CLIENT CAPA redirect-keyless + assert_equal OK [$rd read] + $rd READONLY + assert_equal OK [$rd read] + + # With READONLY, keyless reads should execute locally + $rd DBSIZE + set reply [$rd read] + assert {$reply >= 0} + + $rd close + } + + test {non-read keyless commands are not affected by redirect-keyless capa} { + set rd [valkey_deferring_client 0] + $rd CLIENT CAPA redirect-keyless + assert_equal OK [$rd read] + + # PING is not CMD_READONLY, should still work on replica + $rd PING + assert_equal PONG [$rd read] + + $rd close + } + + test {CLIENT INFO reports redirect-keyless capa} { + set rd [valkey_deferring_client 0] + $rd CLIENT CAPA redirect-keyless + assert_equal OK [$rd read] + + $rd CLIENT INFO + assert_match "*capa=k*" [$rd read] + + $rd close + } + + test {keyless commands inside MULTI are individually MOVED with redirect-keyless capa} { + set rd [valkey_deferring_client 0] + $rd CLIENT CAPA redirect-keyless + assert_equal OK [$rd read] + + $rd MULTI + assert_equal OK [$rd read] + # Individual keyless commands get MOVED, consistent with keyed commands + $rd DBSIZE + assert_error "MOVED -1 *" {$rd read} + $rd RANDOMKEY + assert_error "MOVED -1 *" {$rd read} + # Transaction was flagged dirty, EXEC returns EXECABORT + $rd EXEC + assert_error "EXECABORT *" {$rd read} + + $rd PING + assert_equal PONG [$rd read] + + $rd close + } + + test {both redirect and redirect-keyless capas work together} { + set rd [valkey_deferring_client 0] + $rd CLIENT CAPA redirect + assert_equal OK [$rd read] + $rd CLIENT CAPA redirect-keyless + assert_equal OK [$rd read] + + $rd CLIENT INFO + assert_match "*capa=rk*" [$rd read] + + # Keyless read is redirected via redirect-keyless + $rd DBSIZE + assert_error "MOVED -1 *" {$rd read} + + # Keyed read is redirected via standard cluster redirect + $rd GET x + assert_error "MOVED *" {$rd read} + + $rd close + } } ;# start_cluster \ No newline at end of file diff --git a/tests/unit/introspection.tcl b/tests/unit/introspection.tcl index c788abac0ba..a2518061c1b 100644 --- a/tests/unit/introspection.tcl +++ b/tests/unit/introspection.tcl @@ -245,9 +245,19 @@ start_server {tags {"introspection"}} { $c1 client setname "client-with-r" $c1 client capa redirect + set c2 [valkey_client] + $c2 client setname "client-with-k" + $c2 client capa redirect-keyless + set output [r client list capa r capa r] assert_match *client-with-r* $output + + set output [r client list capa k] + assert_match *client-with-k* $output + assert_no_match *client-with-r* $output + catch {$c1 close} + catch {$c2 close} } test {CLIENT KILL with IP filter} { @@ -285,10 +295,19 @@ start_server {tags {"introspection"}} { $c1 client setname "killme-capa" $c1 client capa redirect - # Kill using capa filter - r client kill capa r skipme yes + set c2 [valkey_client] + $c2 client setname "killme-capa-k" + $c2 client capa redirect-keyless + # Kill using capa r filter + r client kill capa r skipme yes assert_error "*I/O error*" {$c1 ping} + # c2 should still be alive (only has k, not r) + assert_equal {PONG} [$c2 ping] + + # Now kill using capa k filter + r client kill capa k skipme yes + assert_error "*I/O error*" {$c2 ping} } {} test {CLIENT KILL with NAME filter} {