From 7313089d138312f3ecf2577644442c2755bda53f Mon Sep 17 00:00:00 2001 From: Asad Iqbal <135437051+AsadIqbal-Dev@users.noreply.github.com> Date: Fri, 23 Oct 2026 02:02:57 +0500 Subject: [PATCH 1/2] Send primary-only commands to the primary when the caller prefers a replica KeyDelete, KeyExpire (milliseconds), LockExtend and LockRelease pick a server up front to check its features. With PreferReplica that picked a replica, and the primary-only message was then rejected with 'Command cannot be issued to a replica'. GetFeatures now upgrades the flags the same way Message does. Fixes #2821 --- src/StackExchange.Redis/RedisBase.cs | 6 +++++ tests/StackExchange.Redis.Tests/KeyTests.cs | 30 +++++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/src/StackExchange.Redis/RedisBase.cs b/src/StackExchange.Redis/RedisBase.cs index 84981b71f..b6ce65c48 100644 --- a/src/StackExchange.Redis/RedisBase.cs +++ b/src/StackExchange.Redis/RedisBase.cs @@ -68,6 +68,12 @@ internal virtual Task ExecuteAsync(Message? message, ResultProcessor? p internal virtual RedisFeatures GetFeatures(in RedisKey key, CommandFlags flags, RedisCommand command, out ServerEndPoint? server) { + // the server picked here is the one the message is sent to, so a primary-only command has to pick the primary + // here as well, the same way Message does when it is created; otherwise PreferReplica picks a replica that then rejects it + if (command.IsPrimaryOnly() && Message.GetPrimaryReplicaFlags(flags) is CommandFlags.PreferMaster or CommandFlags.PreferReplica) + { + flags = Message.SetPrimaryReplicaFlags(flags, CommandFlags.DemandMaster); + } server = multiplexer.SelectServer(command, flags, key); var version = server == null ? multiplexer.RawConfig.DefaultVersion : server.Version; return new RedisFeatures(version); diff --git a/tests/StackExchange.Redis.Tests/KeyTests.cs b/tests/StackExchange.Redis.Tests/KeyTests.cs index 322cbf00b..738f93c1b 100644 --- a/tests/StackExchange.Redis.Tests/KeyTests.cs +++ b/tests/StackExchange.Redis.Tests/KeyTests.cs @@ -75,6 +75,36 @@ public async Task KeyTypeOfMissingKeyIsNone() // see #3156 Assert.Equal(RedisType.None, await db.KeyTypeAsync(key)); } + [Fact] + public async Task PrimaryOnlyCommandsWithPreferReplicaRunOnThePrimary() // see #2821 + { + await using var conn = Create(configuration: TestConfig.Current.PrimaryServerAndPort + "," + TestConfig.Current.ReplicaServerAndPort); + Assert.SkipUnless( + conn.GetEndPoints().Any(ep => conn.GetServer(ep).IsReplica), + "No replica in this configuration"); + + var db = conn.GetDatabase(); + var key = Me(); + RedisValue token = Guid.NewGuid().ToString(); + + // these look at the server's features before building the message, which used to pick the replica + db.StringSet(key, "abc"); + Assert.True(db.KeyExpire(key, TimeSpan.FromMilliseconds(100_500), CommandFlags.PreferReplica)); + Assert.True(db.KeyDelete(key, CommandFlags.PreferReplica)); + + db.StringSet(key, "abc"); + Assert.True(await db.KeyExpireAsync(key, TimeSpan.FromMilliseconds(100_500), CommandFlags.PreferReplica)); + Assert.True(await db.KeyDeleteAsync(key, CommandFlags.PreferReplica)); + + Assert.True(db.LockTake(key, token, TimeSpan.FromMinutes(1))); + Assert.True(db.LockExtend(key, token, TimeSpan.FromMinutes(1), CommandFlags.PreferReplica)); + Assert.True(await db.LockExtendAsync(key, token, TimeSpan.FromMinutes(1), CommandFlags.PreferReplica)); + Assert.True(db.LockRelease(key, token, CommandFlags.PreferReplica)); + + Assert.True(db.LockTake(key, token, TimeSpan.FromMinutes(1))); + Assert.True(await db.LockReleaseAsync(key, token, CommandFlags.PreferReplica)); + } + [Fact] public async Task Zeros() { From fd37aec41c7e80b2c1b53924dc4d0b73c0b62083 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Fri, 23 Oct 2026 09:33:37 +0100 Subject: [PATCH 2/2] Mirror Message.SetPrimaryOnly exactly; explain the PEXPIRE expiry in the test --- src/StackExchange.Redis/RedisBase.cs | 5 +++-- tests/StackExchange.Redis.Tests/KeyTests.cs | 3 ++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/StackExchange.Redis/RedisBase.cs b/src/StackExchange.Redis/RedisBase.cs index b6ce65c48..f49d43ce1 100644 --- a/src/StackExchange.Redis/RedisBase.cs +++ b/src/StackExchange.Redis/RedisBase.cs @@ -69,8 +69,9 @@ internal virtual Task ExecuteAsync(Message? message, ResultProcessor? p internal virtual RedisFeatures GetFeatures(in RedisKey key, CommandFlags flags, RedisCommand command, out ServerEndPoint? server) { // the server picked here is the one the message is sent to, so a primary-only command has to pick the primary - // here as well, the same way Message does when it is created; otherwise PreferReplica picks a replica that then rejects it - if (command.IsPrimaryOnly() && Message.GetPrimaryReplicaFlags(flags) is CommandFlags.PreferMaster or CommandFlags.PreferReplica) + // here as well, the same way Message.SetPrimaryOnly does when the message is created; otherwise PreferReplica + // picks a replica that then rejects it (DemandReplica is left alone so that it still fails the same way) + if (command.IsPrimaryOnly() && Message.GetPrimaryReplicaFlags(flags) != CommandFlags.DemandReplica) { flags = Message.SetPrimaryReplicaFlags(flags, CommandFlags.DemandMaster); } diff --git a/tests/StackExchange.Redis.Tests/KeyTests.cs b/tests/StackExchange.Redis.Tests/KeyTests.cs index 738f93c1b..a8638be53 100644 --- a/tests/StackExchange.Redis.Tests/KeyTests.cs +++ b/tests/StackExchange.Redis.Tests/KeyTests.cs @@ -87,7 +87,8 @@ public async Task PrimaryOnlyCommandsWithPreferReplicaRunOnThePrimary() // see # var key = Me(); RedisValue token = Guid.NewGuid().ToString(); - // these look at the server's features before building the message, which used to pick the replica + // these look at the server's features before building the message, which used to pick the replica; + // the fractional-second expiry forces the PEXPIRE path, which is the one that checks features db.StringSet(key, "abc"); Assert.True(db.KeyExpire(key, TimeSpan.FromMilliseconds(100_500), CommandFlags.PreferReplica)); Assert.True(db.KeyDelete(key, CommandFlags.PreferReplica));