diff --git a/src/StackExchange.Redis/RedisBase.cs b/src/StackExchange.Redis/RedisBase.cs index 84981b71f..f49d43ce1 100644 --- a/src/StackExchange.Redis/RedisBase.cs +++ b/src/StackExchange.Redis/RedisBase.cs @@ -68,6 +68,13 @@ 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.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); + } 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..a8638be53 100644 --- a/tests/StackExchange.Redis.Tests/KeyTests.cs +++ b/tests/StackExchange.Redis.Tests/KeyTests.cs @@ -75,6 +75,37 @@ 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; + // 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)); + + 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() {