From f0d744c8ca83c137b933c0f0f1fff18343ae64ad Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Thu, 12 Nov 2026 14:39:49 +0000 Subject: [PATCH] Redis 8.12 atomic sorted-set moves: ZMOVE / ZMOVEMEMBER on IDatabase redis/redis#16135 (8.12-rc3) adds ZMOVE, BZMOVE and ZMOVEMEMBER: - SortedSetMove (ZMOVE MIN|MAX [COUNT]) - from the low or high end - SortedSetMoveByScore (ZMOVE BYSCORE min max [COUNT]) - a score range - SortedSetMoveMembers (ZMOVEMEMBER [NX|XX] MEMBERS n ...) - named members, with When as the per-member condition on the destination Each returns SortedSetEntry[]?, null when nothing moved (the server's null reply is never folded into an empty array). COUNT is sent if and only if the caller gives one: for BYSCORE, no COUNT means the whole range, so the SortedSetPop habit of eliding COUNT 1 is not copied. The reply is nested [member, score] pairs in RESP2 as well as RESP3, unlike ZPOPMIN, so SortedSetWithScores does not fit; NullableSortedSetEntryArray is a strict nested-pair processor. BZMOVE is not exposed (blocking; policy row in CommandCoverage.md). Gated [Experimental] behind SER011 and the new RedisFeatures.v8_12_0_rc3 (8.11.242: rc2 does not have the commands); the test docker image moves to client-libs-test:8.12-rc3. --- docs/CommandCoverage.md | 1 + docs/exp/SER011.md | 4 + .../TransactionAnalyzer.cs | 7 + .../Enums/CommandFlags.Category.cs | 2 + src/StackExchange.Redis/Enums/RedisCommand.cs | 4 + .../Interfaces/IDatabase.cs | 64 ++++ .../Interfaces/IDatabaseAsync.cs | 12 + .../KeyspaceIsolation/KeyPrefixed.cs | 9 + .../KeyspaceIsolation/KeyPrefixedDatabase.cs | 9 + .../PublicAPI/PublicAPI.Unshipped.txt | 7 + .../RedisDatabase.SortedSetMove.cs | 106 +++++++ src/StackExchange.Redis/RedisFeatures.cs | 8 +- .../ResultProcessor.SortedSetMove.cs | 53 ++++ tests/RedisConfigs/.docker/Redis/Dockerfile | 2 +- tests/RedisConfigs/docker-compose.yml | 2 +- .../KeyPrefixedDatabaseTests.cs | 22 ++ .../KeyPrefixedTests.cs | 22 ++ .../ResultProcessorUnitTests/SortedSetMove.cs | 104 +++++++ .../SortedSetMoveRoundTrip.cs | 141 +++++++++ .../SortedSetMoveTests.cs | 291 ++++++++++++++++++ 20 files changed, 867 insertions(+), 3 deletions(-) create mode 100644 src/StackExchange.Redis/RedisDatabase.SortedSetMove.cs create mode 100644 src/StackExchange.Redis/ResultProcessor.SortedSetMove.cs create mode 100644 tests/StackExchange.Redis.Tests/ResultProcessorUnitTests/SortedSetMove.cs create mode 100644 tests/StackExchange.Redis.Tests/RoundTripUnitTests/SortedSetMoveRoundTrip.cs create mode 100644 tests/StackExchange.Redis.Tests/SortedSetMoveTests.cs diff --git a/docs/CommandCoverage.md b/docs/CommandCoverage.md index b6e7fcd96..9567a369b 100644 --- a/docs/CommandCoverage.md +++ b/docs/CommandCoverage.md @@ -32,6 +32,7 @@ Statuses: `policy` (excluded by design), `not-applicable` (not meaningful for a | `BLMOVE` | 6.2.0 | `policy` | Blocking; `ListMove` covers `LMOVE`. | | `BLMOVEM` | 8.10.0 | `policy` | Blocking; the non-blocking `LMOVEM` is implemented. | | `BLMPOP` | 7.0.0 | `policy` | Blocking; `ListLeftPop`/`ListRightPop` cover `LMPOP`. | +| `BZMOVE` | 8.12.0 | `policy` | Blocking; `SortedSetMove` covers `ZMOVE` (the `MIN`/`MAX` forms `BZMOVE` offers). | | `BZMPOP` | 7.0.0 | `policy` | Blocking; `SortedSetPop` covers `ZMPOP`. | | `BZPOPMAX` | 5.0.0 | `policy` | Blocking; `SortedSetPop` covers `ZPOPMAX`. | | `BZPOPMIN` | 5.0.0 | `policy` | Blocking; `SortedSetPop` covers `ZPOPMIN`. | diff --git a/docs/exp/SER011.md b/docs/exp/SER011.md index ceb816e1a..57026bb74 100644 --- a/docs/exp/SER011.md +++ b/docs/exp/SER011.md @@ -10,6 +10,10 @@ New features in Redis 8.12: equivalents) - Per-key eviction protection (`BLESS`), surfaced as `IDatabase.KeyBless`/`KeyUnbless`/`KeyBlessFlags` (and async equivalents) with the `BlessFlags` enum, and `IServer.BlessedKeys`/`BlessedKeysAsync` for enumeration +- Atomic sorted-set moves (`ZMOVE`, `ZMOVEMEMBER`; 8.12-rc3 onwards), surfaced as `IDatabase.SortedSetMove` (from the + low or high end), `SortedSetMoveByScore` (a score range) and `SortedSetMoveMembers` (named members, with an optional + `When` condition on the destination), and async equivalents; each returns the moved entries, or `null` if nothing + moved. The blocking `BZMOVE` is not exposed, as with every blocking command The corresponding library features must also be considered subject to change: diff --git a/eng/StackExchange.Redis.Build/TransactionAnalyzer.cs b/eng/StackExchange.Redis.Build/TransactionAnalyzer.cs index 45757f551..d43aaacaf 100644 --- a/eng/StackExchange.Redis.Build/TransactionAnalyzer.cs +++ b/eng/StackExchange.Redis.Build/TransactionAnalyzer.cs @@ -831,6 +831,13 @@ private static (string Suggestion, ServerVersion MinVersion, string CoveredFirst return ("SetMove[Async](source, destination, value)", ServerVersion.Any, "key,value", "key,value"); } + // Deliberately NOT mapped: SortedSetRemove + SortedSetAdd of the same member is not ZMOVEMEMBER + // (SortedSetMoveMembers, 8.12), however much it looks like the SMOVE pair above. A set member has no + // payload, so "remove here, add there" is the whole move; a sorted-set member has a score. The + // transaction writes the score the caller supplied, and adds the member whether or not the source had + // it; ZMOVEMEMBER carries the source's score across, and moves nothing when the source lacks the + // member. Same shape, different operation. + return null; static bool SameKey(QueuedOperation a, QueuedOperation b) diff --git a/src/StackExchange.Redis/Enums/CommandFlags.Category.cs b/src/StackExchange.Redis/Enums/CommandFlags.Category.cs index 50d6746e6..8213c2e59 100644 --- a/src/StackExchange.Redis/Enums/CommandFlags.Category.cs +++ b/src/StackExchange.Redis/Enums/CommandFlags.Category.cs @@ -287,6 +287,8 @@ static CommandFlags DefaultCategory(RedisCommand command) case RedisCommand.ZPOPMIN: case RedisCommand.ZPOPMAX: case RedisCommand.ZMPOP: + case RedisCommand.ZMOVE: // a replay moves further members (MIN/MAX), or finds the first batch already gone + case RedisCommand.ZMOVEMEMBER: case RedisCommand.XADD: // explicit ids and IDMP/IDMPAUTO are demoted where we can see the args case RedisCommand.PFMERGE: // destination accumulates union each call case RedisCommand.BITFIELD: // INCRBY sub-ops compound; demoted to last-wins when the payload has none diff --git a/src/StackExchange.Redis/Enums/RedisCommand.cs b/src/StackExchange.Redis/Enums/RedisCommand.cs index d3c79f3a2..b3b01f8e6 100644 --- a/src/StackExchange.Redis/Enums/RedisCommand.cs +++ b/src/StackExchange.Redis/Enums/RedisCommand.cs @@ -299,6 +299,8 @@ internal enum RedisCommand ZINTERCARD, ZINTERSTORE, ZLEXCOUNT, + ZMOVE, + ZMOVEMEMBER, ZMPOP, ZMSCORE, ZPOPMAX, @@ -455,6 +457,8 @@ internal static bool IsPrimaryOnly(this RedisCommand command) case RedisCommand.ZDIFFSTORE: case RedisCommand.ZINTERSTORE: case RedisCommand.ZINCRBY: + case RedisCommand.ZMOVE: + case RedisCommand.ZMOVEMEMBER: case RedisCommand.ZMPOP: case RedisCommand.ZPOPMAX: case RedisCommand.ZPOPMIN: diff --git a/src/StackExchange.Redis/Interfaces/IDatabase.cs b/src/StackExchange.Redis/Interfaces/IDatabase.cs index 748930ebf..708ab987c 100644 --- a/src/StackExchange.Redis/Interfaces/IDatabase.cs +++ b/src/StackExchange.Redis/Interfaces/IDatabase.cs @@ -2909,6 +2909,70 @@ IEnumerable SortedSetScan( /// SortedSetPopResult SortedSetPop(RedisKey[] keys, long count, Order order = Order.Ascending, CommandFlags flags = CommandFlags.None); + /// + /// Atomically moves entries from the low-score or high-score end of the sorted set at + /// to the sorted set at , keeping their scores. A source that becomes empty is + /// deleted; an entry that already exists in the destination takes the score from the source. + /// + /// The key of the sorted set to move entries from. + /// The key of the sorted set to move entries to; it is created if it does not exist. + /// moves the lowest scores first (MIN); moves the highest scores first (MAX). + /// The maximum number of entries to move; when omitted no COUNT is sent, and the server moves one entry. + /// The flags to use for this operation. + /// The entries moved, in the order they were taken, or if nothing was moved. + /// + /// Both keys must be in the same hash slot on a cluster. + /// The blocking form, BZMOVE, is not exposed: see . + /// + /// + [Experimental(Experiments.Server_8_12, UrlFormat = Experiments.UrlFormat)] + SortedSetEntry[]? SortedSetMove(RedisKey sourceKey, RedisKey destinationKey, Order order = Order.Ascending, long? count = null, CommandFlags flags = CommandFlags.None); + + /// + /// Atomically moves the entries with a score between and (inclusive by default), + /// lowest score first, from the sorted set at to the sorted set at , + /// keeping their scores. A source that becomes empty is deleted; an entry that already exists in the destination takes + /// the score from the source. + /// + /// The key of the sorted set to move entries from. + /// The key of the sorted set to move entries to; it is created if it does not exist. + /// The minimum score to move. + /// The maximum score to move. + /// Which of and to exclude (defaults to both inclusive). + /// + /// The maximum number of entries to move. When omitted no COUNT is sent, and the server moves every entry in + /// the range in a single command; on a large range that occupies the server for the duration. + /// + /// The flags to use for this operation. + /// The entries moved, lowest score first, or if nothing was moved. + /// + /// Both keys must be in the same hash slot on a cluster. + /// + /// + [Experimental(Experiments.Server_8_12, UrlFormat = Experiments.UrlFormat)] + SortedSetEntry[]? SortedSetMoveByScore(RedisKey sourceKey, RedisKey destinationKey, double start, double stop, Exclude exclude = Exclude.None, long? count = null, CommandFlags flags = CommandFlags.None); + + /// + /// Atomically moves the specified from the sorted set at to the + /// sorted set at , keeping their scores. A source that becomes empty is deleted. + /// + /// The key of the sorted set to move members from. + /// The key of the sorted set to move members to; it is created if it does not exist. + /// The members to move; a member that is not in the source is skipped, and a member given twice moves once. + /// + /// The condition on the destination, evaluated per member: moves the member and overwrites any + /// score it has in the destination; (NX) moves it only if the destination does not have it; + /// (XX) moves it only if the destination already has it. A member that fails the condition stays in the source. + /// + /// The flags to use for this operation. + /// The entries moved, in the order of , or if nothing was moved. + /// + /// Both keys must be in the same hash slot on a cluster. + /// + /// + [Experimental(Experiments.Server_8_12, UrlFormat = Experiments.UrlFormat)] + SortedSetEntry[]? SortedSetMoveMembers(RedisKey sourceKey, RedisKey destinationKey, RedisValue[] members, When when = When.Always, CommandFlags flags = CommandFlags.None); + /// /// Same as but return the number of the elements changed. /// diff --git a/src/StackExchange.Redis/Interfaces/IDatabaseAsync.cs b/src/StackExchange.Redis/Interfaces/IDatabaseAsync.cs index eaa628e69..49b880b8d 100644 --- a/src/StackExchange.Redis/Interfaces/IDatabaseAsync.cs +++ b/src/StackExchange.Redis/Interfaces/IDatabaseAsync.cs @@ -711,6 +711,18 @@ IAsyncEnumerable SortedSetScanAsync( /// Task SortedSetPopAsync(RedisKey[] keys, long count, Order order = Order.Ascending, CommandFlags flags = CommandFlags.None); + /// + [Experimental(Experiments.Server_8_12, UrlFormat = Experiments.UrlFormat)] + Task SortedSetMoveAsync(RedisKey sourceKey, RedisKey destinationKey, Order order = Order.Ascending, long? count = null, CommandFlags flags = CommandFlags.None); + + /// + [Experimental(Experiments.Server_8_12, UrlFormat = Experiments.UrlFormat)] + Task SortedSetMoveByScoreAsync(RedisKey sourceKey, RedisKey destinationKey, double start, double stop, Exclude exclude = Exclude.None, long? count = null, CommandFlags flags = CommandFlags.None); + + /// + [Experimental(Experiments.Server_8_12, UrlFormat = Experiments.UrlFormat)] + Task SortedSetMoveMembersAsync(RedisKey sourceKey, RedisKey destinationKey, RedisValue[] members, When when = When.Always, CommandFlags flags = CommandFlags.None); + /// Task StreamAcknowledgeAsync(RedisKey key, RedisValue groupName, RedisValue messageId, CommandFlags flags = CommandFlags.None); diff --git a/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixed.cs b/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixed.cs index f279cef13..ecfcd1a21 100644 --- a/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixed.cs +++ b/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixed.cs @@ -715,6 +715,15 @@ public Task SortedSetPopAsync(RedisKey key, long count, Order public Task SortedSetPopAsync(RedisKey[] keys, long count, Order order = Order.Ascending, CommandFlags flags = CommandFlags.None) => Inner.SortedSetPopAsync(ToInner(keys), count, order, flags); + public Task SortedSetMoveAsync(RedisKey sourceKey, RedisKey destinationKey, Order order = Order.Ascending, long? count = null, CommandFlags flags = CommandFlags.None) => + Inner.SortedSetMoveAsync(ToInner(sourceKey), ToInner(destinationKey), order, count, flags); + + public Task SortedSetMoveByScoreAsync(RedisKey sourceKey, RedisKey destinationKey, double start, double stop, Exclude exclude = Exclude.None, long? count = null, CommandFlags flags = CommandFlags.None) => + Inner.SortedSetMoveByScoreAsync(ToInner(sourceKey), ToInner(destinationKey), start, stop, exclude, count, flags); + + public Task SortedSetMoveMembersAsync(RedisKey sourceKey, RedisKey destinationKey, RedisValue[] members, When when = When.Always, CommandFlags flags = CommandFlags.None) => + Inner.SortedSetMoveMembersAsync(ToInner(sourceKey), ToInner(destinationKey), members, when, flags); + public Task StreamAcknowledgeAsync(RedisKey key, RedisValue groupName, RedisValue messageId, CommandFlags flags = CommandFlags.None) => Inner.StreamAcknowledgeAsync(ToInner(key), groupName, messageId, flags); diff --git a/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixedDatabase.cs b/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixedDatabase.cs index b309a6b80..3b3f74334 100644 --- a/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixedDatabase.cs +++ b/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixedDatabase.cs @@ -679,6 +679,15 @@ public SortedSetEntry[] SortedSetPop(RedisKey key, long count, Order order = Ord public SortedSetPopResult SortedSetPop(RedisKey[] keys, long count, Order order = Order.Ascending, CommandFlags flags = CommandFlags.None) => Inner.SortedSetPop(ToInner(keys), count, order, flags); + public SortedSetEntry[]? SortedSetMove(RedisKey sourceKey, RedisKey destinationKey, Order order = Order.Ascending, long? count = null, CommandFlags flags = CommandFlags.None) => + Inner.SortedSetMove(ToInner(sourceKey), ToInner(destinationKey), order, count, flags); + + public SortedSetEntry[]? SortedSetMoveByScore(RedisKey sourceKey, RedisKey destinationKey, double start, double stop, Exclude exclude = Exclude.None, long? count = null, CommandFlags flags = CommandFlags.None) => + Inner.SortedSetMoveByScore(ToInner(sourceKey), ToInner(destinationKey), start, stop, exclude, count, flags); + + public SortedSetEntry[]? SortedSetMoveMembers(RedisKey sourceKey, RedisKey destinationKey, RedisValue[] members, When when = When.Always, CommandFlags flags = CommandFlags.None) => + Inner.SortedSetMoveMembers(ToInner(sourceKey), ToInner(destinationKey), members, when, flags); + public long StreamAcknowledge(RedisKey key, RedisValue groupName, RedisValue messageId, CommandFlags flags = CommandFlags.None) => Inner.StreamAcknowledge(ToInner(key), groupName, messageId, flags); diff --git a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt index 49b9f7228..353e2cf8b 100644 --- a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt @@ -32,6 +32,13 @@ StackExchange.Redis.RedisFeatures.SetMemberExpiry.get -> bool StackExchange.Redis.KeyNotificationType.Bless = 66 -> StackExchange.Redis.KeyNotificationType StackExchange.Redis.KeyNotificationType.Unbless = 67 -> StackExchange.Redis.KeyNotificationType StackExchange.Redis.RedisFeatures.Bless.get -> bool +[SER011]StackExchange.Redis.IDatabase.SortedSetMove(StackExchange.Redis.RedisKey sourceKey, StackExchange.Redis.RedisKey destinationKey, StackExchange.Redis.Order order = StackExchange.Redis.Order.Ascending, long? count = null, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> StackExchange.Redis.SortedSetEntry[]? +[SER011]StackExchange.Redis.IDatabase.SortedSetMoveByScore(StackExchange.Redis.RedisKey sourceKey, StackExchange.Redis.RedisKey destinationKey, double start, double stop, StackExchange.Redis.Exclude exclude = StackExchange.Redis.Exclude.None, long? count = null, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> StackExchange.Redis.SortedSetEntry[]? +[SER011]StackExchange.Redis.IDatabase.SortedSetMoveMembers(StackExchange.Redis.RedisKey sourceKey, StackExchange.Redis.RedisKey destinationKey, StackExchange.Redis.RedisValue[]! members, StackExchange.Redis.When when = StackExchange.Redis.When.Always, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> StackExchange.Redis.SortedSetEntry[]? +[SER011]StackExchange.Redis.IDatabaseAsync.SortedSetMoveAsync(StackExchange.Redis.RedisKey sourceKey, StackExchange.Redis.RedisKey destinationKey, StackExchange.Redis.Order order = StackExchange.Redis.Order.Ascending, long? count = null, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.Task! +[SER011]StackExchange.Redis.IDatabaseAsync.SortedSetMoveByScoreAsync(StackExchange.Redis.RedisKey sourceKey, StackExchange.Redis.RedisKey destinationKey, double start, double stop, StackExchange.Redis.Exclude exclude = StackExchange.Redis.Exclude.None, long? count = null, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.Task! +[SER011]StackExchange.Redis.IDatabaseAsync.SortedSetMoveMembersAsync(StackExchange.Redis.RedisKey sourceKey, StackExchange.Redis.RedisKey destinationKey, StackExchange.Redis.RedisValue[]! members, StackExchange.Redis.When when = StackExchange.Redis.When.Always, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.Task! +StackExchange.Redis.RedisFeatures.SortedSetMove.get -> bool [SER012]StackExchange.Redis.ClusterSlotStatsEntry [SER012]StackExchange.Redis.ClusterSlotStatsEntry.CpuTime.get -> System.TimeSpan? [SER012]StackExchange.Redis.ClusterSlotStatsEntry.KeyCount.get -> long diff --git a/src/StackExchange.Redis/RedisDatabase.SortedSetMove.cs b/src/StackExchange.Redis/RedisDatabase.SortedSetMove.cs new file mode 100644 index 000000000..8acffaeb8 --- /dev/null +++ b/src/StackExchange.Redis/RedisDatabase.SortedSetMove.cs @@ -0,0 +1,106 @@ +using System; +using System.Threading.Tasks; + +// ReSharper disable once CheckNamespace +namespace StackExchange.Redis; + +// Redis 8.12 atomic sorted-set moves: ZMOVE and ZMOVEMEMBER. +// BZMOVE is deliberately absent: a blocking command parks the shared connection (see docs/CommandCoverage.md). +internal partial class RedisDatabase +{ + public SortedSetEntry[]? SortedSetMove(RedisKey sourceKey, RedisKey destinationKey, Order order = Order.Ascending, long? count = null, CommandFlags flags = CommandFlags.None) + { + var msg = GetSortedSetMoveMessage(sourceKey, destinationKey, order, count, flags); + return ExecuteSync(msg, ResultProcessor.NullableSortedSetEntryArray); + } + + public Task SortedSetMoveAsync(RedisKey sourceKey, RedisKey destinationKey, Order order = Order.Ascending, long? count = null, CommandFlags flags = CommandFlags.None) + { + var msg = GetSortedSetMoveMessage(sourceKey, destinationKey, order, count, flags); + return ExecuteAsync(msg, ResultProcessor.NullableSortedSetEntryArray); + } + + public SortedSetEntry[]? SortedSetMoveByScore(RedisKey sourceKey, RedisKey destinationKey, double start, double stop, Exclude exclude = Exclude.None, long? count = null, CommandFlags flags = CommandFlags.None) + { + var msg = GetSortedSetMoveByScoreMessage(sourceKey, destinationKey, start, stop, exclude, count, flags); + return ExecuteSync(msg, ResultProcessor.NullableSortedSetEntryArray); + } + + public Task SortedSetMoveByScoreAsync(RedisKey sourceKey, RedisKey destinationKey, double start, double stop, Exclude exclude = Exclude.None, long? count = null, CommandFlags flags = CommandFlags.None) + { + var msg = GetSortedSetMoveByScoreMessage(sourceKey, destinationKey, start, stop, exclude, count, flags); + return ExecuteAsync(msg, ResultProcessor.NullableSortedSetEntryArray); + } + + public SortedSetEntry[]? SortedSetMoveMembers(RedisKey sourceKey, RedisKey destinationKey, RedisValue[] members, When when = When.Always, CommandFlags flags = CommandFlags.None) + { + var msg = GetSortedSetMoveMembersMessage(sourceKey, destinationKey, members, when, flags); + return ExecuteSync(msg, ResultProcessor.NullableSortedSetEntryArray); + } + + public Task SortedSetMoveMembersAsync(RedisKey sourceKey, RedisKey destinationKey, RedisValue[] members, When when = When.Always, CommandFlags flags = CommandFlags.None) + { + var msg = GetSortedSetMoveMembersMessage(sourceKey, destinationKey, members, when, flags); + return ExecuteAsync(msg, ResultProcessor.NullableSortedSetEntryArray); + } + + // ZMOVE source destination [COUNT count] + // The count passes through untouched: COUNT is sent if and only if the caller gave one, including values the + // server rejects (0, negative), so that the server's error is what the caller sees. + internal Message GetSortedSetMoveMessage(in RedisKey sourceKey, in RedisKey destinationKey, Order order, long? count, CommandFlags flags) + { + var end = order switch + { + Order.Ascending => RedisLiterals.MIN, + Order.Descending => RedisLiterals.MAX, + _ => throw new ArgumentOutOfRangeException(nameof(order)), + }; + + return count is null + ? Message.Create(Database, flags, RedisCommand.ZMOVE, sourceKey, destinationKey, end) + : Message.Create(Database, flags, RedisCommand.ZMOVE, sourceKey, destinationKey, end, RedisLiterals.COUNT, count.GetValueOrDefault()); + } + + // ZMOVE source destination BYSCORE score-min score-max [COUNT count] + // No COUNT means "the whole range", which is a different command to COUNT 1 - so, as above, never defaulted. + internal Message GetSortedSetMoveByScoreMessage(in RedisKey sourceKey, in RedisKey destinationKey, double start, double stop, Exclude exclude, long? count, CommandFlags flags) + { + RedisValue from = GetRange(start, exclude, true), to = GetRange(stop, exclude, false); + + return count is null + ? Message.Create(Database, flags, RedisCommand.ZMOVE, sourceKey, destinationKey, RedisLiterals.BYSCORE, from, to) + : Message.Create(Database, flags, RedisCommand.ZMOVE, sourceKey, destinationKey, [RedisLiterals.BYSCORE, from, to, RedisLiterals.COUNT, count.GetValueOrDefault()]); + } + + // ZMOVEMEMBER source destination [NX | XX] MEMBERS num-members member [member ...] + // The members go out exactly as given - not de-duplicated or re-ordered - and after MEMBERS the server reads + // the next n tokens as data, so a member called "NX" or "MEMBERS" is safe. An empty list is sent as MEMBERS 0 + // and left for the server to reject. + internal Message GetSortedSetMoveMembersMessage(in RedisKey sourceKey, in RedisKey destinationKey, RedisValue[] members, When when, CommandFlags flags) + { + if (members is null) throw new ArgumentNullException(nameof(members)); + + int offset = when == When.Always ? 2 : 3; + var args = new RedisValue[offset + members.Length]; + int index = 0; + switch (when) + { + case When.Always: + break; + case When.NotExists: + args[index++] = RedisLiterals.NX; + break; + case When.Exists: + args[index++] = RedisLiterals.XX; + break; + default: + throw new ArgumentOutOfRangeException(nameof(when)); + } + + args[index++] = RedisLiterals.MEMBERS; + args[index++] = members.Length; + members.CopyTo(args, index); + + return Message.Create(Database, flags, RedisCommand.ZMOVEMEMBER, sourceKey, destinationKey, args); + } +} diff --git a/src/StackExchange.Redis/RedisFeatures.cs b/src/StackExchange.Redis/RedisFeatures.cs index 65222a173..330f8e58b 100644 --- a/src/StackExchange.Redis/RedisFeatures.cs +++ b/src/StackExchange.Redis/RedisFeatures.cs @@ -52,7 +52,8 @@ namespace StackExchange.Redis v8_6_0 = new Version(8, 6, 0), v8_8_0 = new Version(8, 7, 225), // 8.8 is version 8.7.225 v8_10_0 = new Version(8, 9, 241), // 8.10 preview is version 8.9.241 - v8_12_0 = new Version(8, 11, 241); // 8.12 RC2 is version 8.11.241 + v8_12_0 = new Version(8, 11, 241), // 8.12 RC2 is version 8.11.241 + v8_12_0_rc3 = new Version(8, 11, 242); // 8.12 RC3 is version 8.11.242 #pragma warning restore SA1310 // Field names should not contain underscore #pragma warning restore SA1311 // Static readonly fields should begin with upper-case letter @@ -343,6 +344,11 @@ public RedisFeatures(Version version) /// public bool Bless => Version.IsAtLeast(v8_12_0); + /// + /// Are atomic sorted-set moves (ZMOVE, ZMOVEMEMBER) available? + /// + public bool SortedSetMove => Version.IsAtLeast(v8_12_0_rc3); + #pragma warning restore 1629 // Documentation text should end with a period. /// diff --git a/src/StackExchange.Redis/ResultProcessor.SortedSetMove.cs b/src/StackExchange.Redis/ResultProcessor.SortedSetMove.cs new file mode 100644 index 000000000..c996b53a4 --- /dev/null +++ b/src/StackExchange.Redis/ResultProcessor.SortedSetMove.cs @@ -0,0 +1,53 @@ +using RESPite.Messages; + +// ReSharper disable once CheckNamespace +namespace StackExchange.Redis; + +internal abstract partial class ResultProcessor +{ + /// + /// Parses the reply of ZMOVE / ZMOVEMEMBER: an array of [member, score] pairs, or + /// when nothing moved. The null reply is preserved as . + /// + /// + /// Unlike ZPOPMIN and ZRANGE ... WITHSCORES, the pairs are nested in RESP2 as well as in RESP3 (only the + /// score differs: a bulk string in RESP2, a double in RESP3), so - which reads + /// RESP2 as a flat, interleaved list - does not fit. + /// + public static readonly ResultProcessor + NullableSortedSetEntryArray = new NullableSortedSetEntryArrayProcessor(); + + private sealed class NullableSortedSetEntryArrayProcessor : ResultProcessor + { + protected override bool SetResultCore(PhysicalConnection connection, Message message, ref RespReader reader) + { + if (reader.IsNull) + { + SetResult(message, null); + return true; + } + + if (!reader.IsAggregate) return false; // scalar / other => unexpected + + var entries = new SortedSetEntry[reader.AggregateLength()]; + var iter = reader.AggregateChildren(); + for (int i = 0; i < entries.Length; i++) + { + iter.DemandNext(); + if (!(iter.Value.IsAggregate && iter.Value.AggregateLengthIs(2))) return false; // not a pair + + var pair = iter.Value.AggregateChildren(); + pair.DemandNext(); + var member = pair.Value; + pair.DemandNext(); + var score = pair.Value; + if (!(member.IsScalar && score.IsScalar)) return false; + + entries[i] = new SortedSetEntry(member.ReadRedisValue(), score.TryReadDouble(out double val) ? val : double.NaN); + } + + SetResult(message, entries); + return true; + } + } +} diff --git a/tests/RedisConfigs/.docker/Redis/Dockerfile b/tests/RedisConfigs/.docker/Redis/Dockerfile index db5b66e09..6493dd8f0 100644 --- a/tests/RedisConfigs/.docker/Redis/Dockerfile +++ b/tests/RedisConfigs/.docker/Redis/Dockerfile @@ -1,4 +1,4 @@ -ARG CLIENT_LIBS_TEST_IMAGE=redislabs/client-libs-test:8.12-rc2 +ARG CLIENT_LIBS_TEST_IMAGE=redislabs/client-libs-test:8.12-rc3 FROM ${CLIENT_LIBS_TEST_IMAGE} COPY --from=configs ./Basic /data/Basic/ diff --git a/tests/RedisConfigs/docker-compose.yml b/tests/RedisConfigs/docker-compose.yml index 0130dec75..c98f47ee1 100644 --- a/tests/RedisConfigs/docker-compose.yml +++ b/tests/RedisConfigs/docker-compose.yml @@ -5,7 +5,7 @@ services: build: context: .docker/Redis args: - CLIENT_LIBS_TEST_IMAGE: ${CLIENT_LIBS_TEST_IMAGE:-redislabs/client-libs-test:8.12-rc2} + CLIENT_LIBS_TEST_IMAGE: ${CLIENT_LIBS_TEST_IMAGE:-redislabs/client-libs-test:8.12-rc3} additional_contexts: configs: . platform: linux diff --git a/tests/StackExchange.Redis.Tests/KeyPrefixedDatabaseTests.cs b/tests/StackExchange.Redis.Tests/KeyPrefixedDatabaseTests.cs index 82aaa17b8..c204c943f 100644 --- a/tests/StackExchange.Redis.Tests/KeyPrefixedDatabaseTests.cs +++ b/tests/StackExchange.Redis.Tests/KeyPrefixedDatabaseTests.cs @@ -505,6 +505,28 @@ public void ListMove() mock.Received().ListMove("prefix:key", "prefix:destination", ListSide.Left, ListSide.Right, CommandFlags.None); } + [Fact] + public void SortedSetMove() + { + prefixed.SortedSetMove("key", "destination", Order.Descending, 3, CommandFlags.None); + mock.Received().SortedSetMove("prefix:key", "prefix:destination", Order.Descending, 3, CommandFlags.None); + } + + [Fact] + public void SortedSetMoveByScore() + { + prefixed.SortedSetMoveByScore("key", "destination", 1, 2, Exclude.Start, 3, CommandFlags.None); + mock.Received().SortedSetMoveByScore("prefix:key", "prefix:destination", 1, 2, Exclude.Start, 3, CommandFlags.None); + } + + [Fact] + public void SortedSetMoveMembers() + { + RedisValue[] members = ["a", "b"]; + prefixed.SortedSetMoveMembers("key", "destination", members, When.NotExists, CommandFlags.None); + mock.Received().SortedSetMoveMembers("prefix:key", "prefix:destination", members, When.NotExists, CommandFlags.None); + } + [Fact] public void ListRange() { diff --git a/tests/StackExchange.Redis.Tests/KeyPrefixedTests.cs b/tests/StackExchange.Redis.Tests/KeyPrefixedTests.cs index 9da4ec08b..9061f06f5 100644 --- a/tests/StackExchange.Redis.Tests/KeyPrefixedTests.cs +++ b/tests/StackExchange.Redis.Tests/KeyPrefixedTests.cs @@ -458,6 +458,28 @@ public async Task ListMoveAsync() await mock.Received().ListMoveAsync("prefix:key", "prefix:destination", ListSide.Left, ListSide.Right, CommandFlags.None); } + [Fact] + public async Task SortedSetMoveAsync() + { + await prefixed.SortedSetMoveAsync("key", "destination", Order.Descending, 3, CommandFlags.None); + await mock.Received().SortedSetMoveAsync("prefix:key", "prefix:destination", Order.Descending, 3, CommandFlags.None); + } + + [Fact] + public async Task SortedSetMoveByScoreAsync() + { + await prefixed.SortedSetMoveByScoreAsync("key", "destination", 1, 2, Exclude.Start, 3, CommandFlags.None); + await mock.Received().SortedSetMoveByScoreAsync("prefix:key", "prefix:destination", 1, 2, Exclude.Start, 3, CommandFlags.None); + } + + [Fact] + public async Task SortedSetMoveMembersAsync() + { + RedisValue[] members = ["a", "b"]; + await prefixed.SortedSetMoveMembersAsync("key", "destination", members, When.NotExists, CommandFlags.None); + await mock.Received().SortedSetMoveMembersAsync("prefix:key", "prefix:destination", members, When.NotExists, CommandFlags.None); + } + [Fact] public async Task ListRangeAsync() { diff --git a/tests/StackExchange.Redis.Tests/ResultProcessorUnitTests/SortedSetMove.cs b/tests/StackExchange.Redis.Tests/ResultProcessorUnitTests/SortedSetMove.cs new file mode 100644 index 000000000..ef4a83c7c --- /dev/null +++ b/tests/StackExchange.Redis.Tests/ResultProcessorUnitTests/SortedSetMove.cs @@ -0,0 +1,104 @@ +using Xunit; + +namespace StackExchange.Redis.Tests.ResultProcessorUnitTests; + +/// +/// ZMOVE / ZMOVEMEMBER reply with an array of [member, score] pairs - nested in RESP2 as well as +/// RESP3, unlike ZPOPMIN - or the null reply when nothing moved. +/// +public class SortedSetMove(ITestOutputHelper log) : ResultProcessorUnitTest(log) +{ + [Fact] + public void Pairs_Resp2_ScoresAreBulkStrings() + { + var resp = "*2\r\n*2\r\n$1\r\na\r\n$1\r\n1\r\n*2\r\n$1\r\nb\r\n$3\r\n2.5\r\n"; + + var result = Execute(resp, ResultProcessor.NullableSortedSetEntryArray); + + Assert.NotNull(result); + Assert.Equal(2, result.Length); + Assert.Equal("a", result[0].Element); + Assert.Equal(1, result[0].Score); + Assert.Equal("b", result[1].Element); + Assert.Equal(2.5, result[1].Score); + } + + [Fact] + public void Pairs_Resp3_ScoresAreDoubles() + { + var resp = "*2\r\n*2\r\n$1\r\na\r\n,1\r\n*2\r\n$1\r\nb\r\n,2.5\r\n"; + + var result = Execute(resp, ResultProcessor.NullableSortedSetEntryArray, protocol: RedisProtocol.Resp3); + + Assert.NotNull(result); + Assert.Equal(2, result.Length); + Assert.Equal("a", result[0].Element); + Assert.Equal(1, result[0].Score); + Assert.Equal("b", result[1].Element); + Assert.Equal(2.5, result[1].Score); + } + + [Fact] + public void SinglePair_StaysNested() + { + // one moved member is still an array of one pair; in particular the RESP2 form must not be read as the + // flat [member, score] list that ZPOPMIN sends + var resp = "*1\r\n*2\r\n$1\r\na\r\n$1\r\n1\r\n"; + + var result = Execute(resp, ResultProcessor.NullableSortedSetEntryArray); + + var entry = Assert.Single(result!); + Assert.Equal("a", entry.Element); + Assert.Equal(1, entry.Score); + } + + [Theory] + [InlineData("*2\r\n*2\r\n$1\r\na\r\n$4\r\n-inf\r\n*2\r\n$1\r\nb\r\n$3\r\ninf\r\n", RedisProtocol.Resp2)] + [InlineData("*2\r\n*2\r\n$1\r\na\r\n,-inf\r\n*2\r\n$1\r\nb\r\n,inf\r\n", RedisProtocol.Resp3)] + public void InfiniteScores(string resp, RedisProtocol protocol) + { + var result = Execute(resp, ResultProcessor.NullableSortedSetEntryArray, protocol: protocol); + + Assert.NotNull(result); + Assert.Equal(double.NegativeInfinity, result[0].Score); + Assert.Equal(double.PositiveInfinity, result[1].Score); + } + + [Fact] + public void OrderIsPreserved() + { + // MAX replies highest-first; the order is part of the reply + var resp = "*3\r\n*2\r\n$1\r\nc\r\n$1\r\n3\r\n*2\r\n$1\r\nb\r\n$1\r\n2\r\n*2\r\n$1\r\na\r\n$1\r\n1\r\n"; + + var result = Execute(resp, ResultProcessor.NullableSortedSetEntryArray); + + Assert.Equal("c,b,a", Join(System.Array.ConvertAll(result!, static x => x.Element))); + } + + [Fact] + public void NullArray_Resp2_IsNull() + => Assert.Null(Execute("*-1\r\n", ResultProcessor.NullableSortedSetEntryArray)); + + [Fact] + public void Null_Resp3_IsNull() + => Assert.Null(Execute("_\r\n", ResultProcessor.NullableSortedSetEntryArray, protocol: RedisProtocol.Resp3)); + + [Fact] + public void EmptyArray_IsEmptyNotNull() + { + // the server never sends this (nothing moved is the null reply), but it must not be folded into null + var result = Execute("*0\r\n", ResultProcessor.NullableSortedSetEntryArray); + + Assert.NotNull(result); + Assert.Empty(result); + } + + [Theory] + [InlineData("$5\r\nhello\r\n")] // scalar + [InlineData(":5\r\n")] // integer + [InlineData("*2\r\n$1\r\na\r\n$1\r\n1\r\n")] // flat (ZPOPMIN-style) list, not pairs + [InlineData("*1\r\n*3\r\n$1\r\na\r\n$1\r\n1\r\n$1\r\nx\r\n")] // a "pair" of three + [InlineData("*1\r\n*1\r\n$1\r\na\r\n")] // a "pair" of one + [InlineData("*1\r\n*2\r\n$1\r\na\r\n*0\r\n")] // score is not a scalar + public void Malformed_Failure(string resp) => ExecuteUnexpected(resp, ResultProcessor.NullableSortedSetEntryArray); +} diff --git a/tests/StackExchange.Redis.Tests/RoundTripUnitTests/SortedSetMoveRoundTrip.cs b/tests/StackExchange.Redis.Tests/RoundTripUnitTests/SortedSetMoveRoundTrip.cs new file mode 100644 index 000000000..c4169b506 --- /dev/null +++ b/tests/StackExchange.Redis.Tests/RoundTripUnitTests/SortedSetMoveRoundTrip.cs @@ -0,0 +1,141 @@ +using System; +using System.Threading.Tasks; +using Xunit; + +namespace StackExchange.Redis.Tests.RoundTripUnitTests; + +/// +/// Exact-bytes tests for the Redis 8.12 sorted-set moves, ZMOVE and ZMOVEMEMBER. +/// +public class SortedSetMoveRoundTrip(ITestOutputHelper log) +{ + private const string Prefix = "$1\r\ns\r\n$1\r\nd\r\n"; // the two keys + private const string TwoPairsResp2 = "*2\r\n*2\r\n$1\r\na\r\n$1\r\n1\r\n*2\r\n$1\r\nb\r\n$1\r\n2\r\n"; + private const string TwoPairsResp3 = "*2\r\n*2\r\n$1\r\na\r\n,1\r\n*2\r\n$1\r\nb\r\n,2\r\n"; + + private static RedisDatabase Db() => new(null!, 0, null); + + [Theory(Timeout = 1000)] + [InlineData(Order.Ascending, null, "*4\r\n$5\r\nZMOVE\r\n" + Prefix + "$3\r\nMIN\r\n")] + [InlineData(Order.Descending, null, "*4\r\n$5\r\nZMOVE\r\n" + Prefix + "$3\r\nMAX\r\n")] + [InlineData(Order.Ascending, 10L, "*6\r\n$5\r\nZMOVE\r\n" + Prefix + "$3\r\nMIN\r\n$5\r\nCOUNT\r\n$2\r\n10\r\n")] + [InlineData(Order.Descending, 10L, "*6\r\n$5\r\nZMOVE\r\n" + Prefix + "$3\r\nMAX\r\n$5\r\nCOUNT\r\n$2\r\n10\r\n")] + // the count passes through: an explicit 1 is not elided (as SortedSetPop does), and values the server rejects are still sent + [InlineData(Order.Ascending, 1L, "*6\r\n$5\r\nZMOVE\r\n" + Prefix + "$3\r\nMIN\r\n$5\r\nCOUNT\r\n$1\r\n1\r\n")] + [InlineData(Order.Ascending, 0L, "*6\r\n$5\r\nZMOVE\r\n" + Prefix + "$3\r\nMIN\r\n$5\r\nCOUNT\r\n$1\r\n0\r\n")] + [InlineData(Order.Ascending, -1L, "*6\r\n$5\r\nZMOVE\r\n" + Prefix + "$3\r\nMIN\r\n$5\r\nCOUNT\r\n$2\r\n-1\r\n")] + public async Task Move_ByEnd(Order order, long? count, string requestResp) + { + var msg = Db().GetSortedSetMoveMessage("s", "d", order, count, CommandFlags.None); + + var result = await TestConnection.ExecuteAsync(msg, ResultProcessor.NullableSortedSetEntryArray, requestResp, TwoPairsResp2, log: log); + + AssertTwoPairs(result); + } + + [Theory(Timeout = 1000)] + [InlineData(1d, 2d, Exclude.None, null, "*6\r\n$5\r\nZMOVE\r\n" + Prefix + "$7\r\nBYSCORE\r\n$1\r\n1\r\n$1\r\n2\r\n")] + [InlineData(1d, 2d, Exclude.None, 500L, "*8\r\n$5\r\nZMOVE\r\n" + Prefix + "$7\r\nBYSCORE\r\n$1\r\n1\r\n$1\r\n2\r\n$5\r\nCOUNT\r\n$3\r\n500\r\n")] + [InlineData(1d, 2d, Exclude.Start, null, "*6\r\n$5\r\nZMOVE\r\n" + Prefix + "$7\r\nBYSCORE\r\n$2\r\n(1\r\n$1\r\n2\r\n")] + [InlineData(1d, 2d, Exclude.Stop, null, "*6\r\n$5\r\nZMOVE\r\n" + Prefix + "$7\r\nBYSCORE\r\n$1\r\n1\r\n$2\r\n(2\r\n")] + [InlineData(1.5d, 2.5d, Exclude.Both, 3L, "*8\r\n$5\r\nZMOVE\r\n" + Prefix + "$7\r\nBYSCORE\r\n$4\r\n(1.5\r\n$4\r\n(2.5\r\n$5\r\nCOUNT\r\n$1\r\n3\r\n")] + [InlineData(double.NegativeInfinity, double.PositiveInfinity, Exclude.None, null, "*6\r\n$5\r\nZMOVE\r\n" + Prefix + "$7\r\nBYSCORE\r\n$4\r\n-inf\r\n$4\r\n+inf\r\n")] + [InlineData(double.NegativeInfinity, 1700000000d, Exclude.Stop, 500L, "*8\r\n$5\r\nZMOVE\r\n" + Prefix + "$7\r\nBYSCORE\r\n$4\r\n-inf\r\n$11\r\n(1700000000\r\n$5\r\nCOUNT\r\n$3\r\n500\r\n")] + public async Task Move_ByScore(double start, double stop, Exclude exclude, long? count, string requestResp) + { + var msg = Db().GetSortedSetMoveByScoreMessage("s", "d", start, stop, exclude, count, CommandFlags.None); + + var result = await TestConnection.ExecuteAsync(msg, ResultProcessor.NullableSortedSetEntryArray, requestResp, TwoPairsResp2, log: log); + + AssertTwoPairs(result); + } + + [Theory(Timeout = 1000)] + [InlineData(When.Always, "*7\r\n$11\r\nZMOVEMEMBER\r\n" + Prefix + "$7\r\nMEMBERS\r\n$1\r\n2\r\n$1\r\na\r\n$1\r\nb\r\n")] + [InlineData(When.NotExists, "*8\r\n$11\r\nZMOVEMEMBER\r\n" + Prefix + "$2\r\nNX\r\n$7\r\nMEMBERS\r\n$1\r\n2\r\n$1\r\na\r\n$1\r\nb\r\n")] + [InlineData(When.Exists, "*8\r\n$11\r\nZMOVEMEMBER\r\n" + Prefix + "$2\r\nXX\r\n$7\r\nMEMBERS\r\n$1\r\n2\r\n$1\r\na\r\n$1\r\nb\r\n")] + public async Task MoveMembers_Condition(When when, string requestResp) + { + var msg = Db().GetSortedSetMoveMembersMessage("s", "d", ["a", "b"], when, CommandFlags.None); + + var result = await TestConnection.ExecuteAsync(msg, ResultProcessor.NullableSortedSetEntryArray, requestResp, TwoPairsResp2, log: log); + + AssertTwoPairs(result); + } + + [Fact(Timeout = 1000)] + public async Task MoveMembers_PassThrough_KeywordsDuplicatesAndOrder() + { + // members that look like keywords are data, and duplicates / order are not "tidied" + var msg = Db().GetSortedSetMoveMembersMessage("s", "d", ["NX", "MEMBERS", "b", "b", "a"], When.Always, CommandFlags.None); + const string RequestResp = "*10\r\n$11\r\nZMOVEMEMBER\r\n" + Prefix + "$7\r\nMEMBERS\r\n$1\r\n5\r\n$2\r\nNX\r\n$7\r\nMEMBERS\r\n$1\r\nb\r\n$1\r\nb\r\n$1\r\na\r\n"; + + var result = await TestConnection.ExecuteAsync(msg, ResultProcessor.NullableSortedSetEntryArray, RequestResp, "*-1\r\n", log: log); + + Assert.Null(result); + } + + [Fact(Timeout = 1000)] + public async Task MoveMembers_Empty_IsSentForTheServerToReject() + { + var msg = Db().GetSortedSetMoveMembersMessage("s", "d", [], When.Always, CommandFlags.None); + const string RequestResp = "*5\r\n$11\r\nZMOVEMEMBER\r\n" + Prefix + "$7\r\nMEMBERS\r\n$1\r\n0\r\n"; + + var ex = await Assert.ThrowsAsync(() => TestConnection.ExecuteAsync( + msg, ResultProcessor.NullableSortedSetEntryArray, RequestResp, "-ERR wrong number of arguments for 'zmovemember' command\r\n", log: log)); + + Assert.Equal("ERR wrong number of arguments for 'zmovemember' command", ex.Message); + } + + [Fact] + public void InvalidArguments_Throw() + { + var db = Db(); + Assert.Throws("members", () => db.GetSortedSetMoveMembersMessage("s", "d", null!, When.Always, CommandFlags.None)); + Assert.Throws("when", () => db.GetSortedSetMoveMembersMessage("s", "d", ["a"], (When)42, CommandFlags.None)); + Assert.Throws("order", () => db.GetSortedSetMoveMessage("s", "d", (Order)42, null, CommandFlags.None)); + } + + [Theory(Timeout = 1000)] + [InlineData(RedisProtocol.Resp2, TwoPairsResp2)] + [InlineData(RedisProtocol.Resp3, TwoPairsResp3)] + public async Task Reply_SameResultInBothProtocols(RedisProtocol protocol, string responseResp) + { + var msg = Db().GetSortedSetMoveMessage("s", "d", Order.Ascending, 2, CommandFlags.None); + const string RequestResp = "*6\r\n$5\r\nZMOVE\r\n" + Prefix + "$3\r\nMIN\r\n$5\r\nCOUNT\r\n$1\r\n2\r\n"; + + var result = await TestConnection.ExecuteAsync(msg, ResultProcessor.NullableSortedSetEntryArray, RequestResp, responseResp, protocol: protocol, log: log); + + AssertTwoPairs(result); + } + + [Theory(Timeout = 1000)] + [InlineData(RedisProtocol.Resp2, "*-1\r\n")] + [InlineData(RedisProtocol.Resp3, "_\r\n")] + public async Task Reply_NothingMoved_IsNull(RedisProtocol protocol, string responseResp) + { + var msg = Db().GetSortedSetMoveMessage("s", "d", Order.Ascending, null, CommandFlags.None); + const string RequestResp = "*4\r\n$5\r\nZMOVE\r\n" + Prefix + "$3\r\nMIN\r\n"; + + var result = await TestConnection.ExecuteAsync(msg, ResultProcessor.NullableSortedSetEntryArray, RequestResp, responseResp, protocol: protocol, log: log); + + Assert.Null(result); + } + + [Fact] + public void BothAreWritesToThePrimary() + { + Assert.True(RedisCommand.ZMOVE.IsPrimaryOnly()); + Assert.True(RedisCommand.ZMOVEMEMBER.IsPrimaryOnly()); + } + + private static void AssertTwoPairs(SortedSetEntry[]? result) + { + Assert.NotNull(result); + Assert.Equal(2, result.Length); + Assert.Equal("a", result[0].Element); + Assert.Equal(1, result[0].Score); + Assert.Equal("b", result[1].Element); + Assert.Equal(2, result[1].Score); + } +} diff --git a/tests/StackExchange.Redis.Tests/SortedSetMoveTests.cs b/tests/StackExchange.Redis.Tests/SortedSetMoveTests.cs new file mode 100644 index 000000000..fc99cf489 --- /dev/null +++ b/tests/StackExchange.Redis.Tests/SortedSetMoveTests.cs @@ -0,0 +1,291 @@ +using System.Linq; +using System.Threading.Tasks; +using Xunit; + +namespace StackExchange.Redis.Tests; + +/// +/// Live tests for the Redis 8.12 (rc3 onwards) atomic sorted-set moves: +/// and . +/// +[RunPerProtocol] +public class SortedSetMoveTests(ITestOutputHelper output, SharedConnectionFixture fixture) : TestBase(output, fixture) +{ + private static readonly SortedSetEntry[] Entries = + [ + new("a", 1), new("b", 2), new("c", 3), new("d", 4), new("e", 5), + ]; + + private async Task<(IDatabase Db, RedisKey Source, RedisKey Destination)> InitAsync(IConnectionMultiplexer conn, string caller) + { + var db = conn.GetDatabase(); + // one slot, so that the same tests hold against a cluster + RedisKey source = "{" + caller + "}:src", destination = "{" + caller + "}:dst"; + await db.KeyDeleteAsync([source, destination]); + await db.SortedSetAddAsync(source, Entries); + return (db, source, destination); + } + + private static string Elements(SortedSetEntry[]? entries) => entries is null ? "(null)" : string.Join(",", entries.Select(x => x.Element.ToString())); + + [Fact] + public async Task Move_DefaultIsOneFromTheLowEnd() + { + await using var conn = Create(require: RedisFeatures.v8_12_0_rc3); + var (db, src, dst) = await InitAsync(conn, Me()); + + var moved = db.SortedSetMove(src, dst); + + var entry = Assert.Single(moved!); // one member is still a list of one + Assert.Equal("a", entry.Element); + Assert.Equal(1, entry.Score); + Assert.Equal(4, db.SortedSetLength(src)); + Assert.Equal(1, db.SortedSetScore(dst, "a")); // the score travels + } + + [Fact] + public async Task Move_HighEndWithCount_IsDescending() + { + await using var conn = Create(require: RedisFeatures.v8_12_0_rc3); + var (db, src, dst) = await InitAsync(conn, Me()); + + var moved = await db.SortedSetMoveAsync(src, dst, Order.Descending, 2); + + Assert.Equal("e,d", Elements(moved)); + Assert.Equal(new double[] { 5, 4 }, moved!.Select(x => x.Score)); + Assert.Equal(3, await db.SortedSetLengthAsync(src)); + Assert.Equal(2, await db.SortedSetLengthAsync(dst)); + } + + [Fact] + public async Task Move_CountBeyondLength_MovesAllAndDeletesSource() + { + await using var conn = Create(require: RedisFeatures.v8_12_0_rc3); + var (db, src, dst) = await InitAsync(conn, Me()); + + var moved = await db.SortedSetMoveAsync(src, dst, count: 100); + + Assert.Equal("a,b,c,d,e", Elements(moved)); + Assert.False(await db.KeyExistsAsync(src)); + Assert.Equal(5, await db.SortedSetLengthAsync(dst)); + } + + [Fact] + public async Task Move_NothingToMove_IsNull() + { + await using var conn = Create(require: RedisFeatures.v8_12_0_rc3); + var (db, src, dst) = await InitAsync(conn, Me()); + await db.KeyDeleteAsync(src); + + Assert.Null(db.SortedSetMove(src, dst)); // missing source + Assert.Null(await db.SortedSetMoveAsync(src, dst, Order.Descending, 10)); + Assert.Null(await db.SortedSetMoveByScoreAsync(src, dst, 0, 100)); + Assert.Null(await db.SortedSetMoveMembersAsync(src, dst, ["a"])); + Assert.False(await db.KeyExistsAsync(dst)); // and nothing is created + } + + [Fact] + public async Task Move_SameKey_IsNull() + { + await using var conn = Create(require: RedisFeatures.v8_12_0_rc3); + var (db, src, _) = await InitAsync(conn, Me()); + + Assert.Null(await db.SortedSetMoveAsync(src, src, count: 2)); + Assert.Equal(5, await db.SortedSetLengthAsync(src)); + } + + [Fact] + public async Task Move_CountZero_IsTheServersError() + { + await using var conn = Create(require: RedisFeatures.v8_12_0_rc3); + var (db, src, dst) = await InitAsync(conn, Me()); + + // not swallowed or "fixed" client-side + var ex = await Assert.ThrowsAsync(() => db.SortedSetMoveAsync(src, dst, count: 0)); + Assert.Equal("ERR count should be greater than 0", ex.Message); + Assert.Equal(5, await db.SortedSetLengthAsync(src)); + } + + [Fact] + public async Task MoveByScore_NoCount_MovesTheWholeRange() + { + await using var conn = Create(require: RedisFeatures.v8_12_0_rc3); + var (db, src, dst) = await InitAsync(conn, Me()); + + var moved = db.SortedSetMoveByScore(src, dst, 2, 4); + + Assert.Equal("b,c,d", Elements(moved)); + Assert.Equal(2, db.SortedSetLength(src)); + } + + [Fact] + public async Task MoveByScore_ExclusiveAndInfiniteBounds_WithCount() + { + await using var conn = Create(require: RedisFeatures.v8_12_0_rc3); + var (db, src, dst) = await InitAsync(conn, Me()); + + // (2, 4] + Assert.Equal("c,d", Elements(await db.SortedSetMoveByScoreAsync(src, dst, 2, 4, Exclude.Start))); + + // [-inf, 5) with a count: the lowest first; a, b and e remain, e is excluded + Assert.Equal("a", Elements(await db.SortedSetMoveByScoreAsync(src, dst, double.NegativeInfinity, 5, Exclude.Stop, count: 1))); + Assert.Equal("b", Elements(await db.SortedSetMoveByScoreAsync(src, dst, double.NegativeInfinity, 5, Exclude.Stop))); + + Assert.Null(await db.SortedSetMoveByScoreAsync(src, dst, 10, 20)); // empty range + Assert.Equal("e", Elements(await db.SortedSetRangeByRankWithScoresAsync(src))); + } + + [Fact] + public async Task MoveMembers_ArgumentOrder_SkipsMissingAndDuplicates() + { + await using var conn = Create(require: RedisFeatures.v8_12_0_rc3); + var (db, src, dst) = await InitAsync(conn, Me()); + + var moved = db.SortedSetMoveMembers(src, dst, ["d", "nope", "b", "d"]); + + Assert.Equal("d,b", Elements(moved)); + Assert.Equal(new double[] { 4, 2 }, moved!.Select(x => x.Score)); + Assert.Equal("a,c,e", Elements(db.SortedSetRangeByRankWithScores(src))); + } + + [Fact] + public async Task MoveMembers_Always_OverwritesDestinationScore() + { + await using var conn = Create(require: RedisFeatures.v8_12_0_rc3); + var (db, src, dst) = await InitAsync(conn, Me()); + await db.SortedSetAddAsync(dst, "a", 100); + + var moved = await db.SortedSetMoveMembersAsync(src, dst, ["a"]); + + Assert.Equal(1, Assert.Single(moved!).Score); + Assert.Equal(1, await db.SortedSetScoreAsync(dst, "a")); + Assert.Null(await db.SortedSetScoreAsync(src, "a")); + } + + [Fact] + public async Task MoveMembers_NotExists_LeavesClashesInTheSource() + { + await using var conn = Create(require: RedisFeatures.v8_12_0_rc3); + var (db, src, dst) = await InitAsync(conn, Me()); + await db.SortedSetAddAsync(dst, "a", 100); + + var moved = await db.SortedSetMoveMembersAsync(src, dst, ["a", "b"], When.NotExists); + + Assert.Equal("b", Elements(moved)); // a member the condition rejects is not in the reply + Assert.Equal(100, await db.SortedSetScoreAsync(dst, "a")); + Assert.Equal(1, await db.SortedSetScoreAsync(src, "a")); + } + + [Fact] + public async Task MoveMembers_Exists_OnlyMovesWhatTheDestinationHas() + { + await using var conn = Create(require: RedisFeatures.v8_12_0_rc3); + var (db, src, dst) = await InitAsync(conn, Me()); + + // XX against a missing destination moves nothing and creates nothing + Assert.Null(await db.SortedSetMoveMembersAsync(src, dst, ["a", "b"], When.Exists)); + Assert.False(await db.KeyExistsAsync(dst)); + + await db.SortedSetAddAsync(dst, "a", 100); + var moved = await db.SortedSetMoveMembersAsync(src, dst, ["a", "b"], When.Exists); + + Assert.Equal("a", Elements(moved)); + Assert.Equal(1, await db.SortedSetScoreAsync(dst, "a")); + Assert.Equal(2, await db.SortedSetScoreAsync(src, "b")); + } + + [Fact] + public async Task MoveMembers_KeywordLikeMembers_AreData() + { + await using var conn = Create(require: RedisFeatures.v8_12_0_rc3); + var (db, src, dst) = await InitAsync(conn, Me()); + await db.SortedSetAddAsync(src, [new("NX", 10), new("MEMBERS", 11), new("COUNT", 12)]); + + var moved = await db.SortedSetMoveMembersAsync(src, dst, ["NX", "MEMBERS", "COUNT"]); + + Assert.Equal("NX,MEMBERS,COUNT", Elements(moved)); + } + + [Fact] + public async Task Move_WrongType_IsTheServersError() + { + await using var conn = Create(require: RedisFeatures.v8_12_0_rc3); + var (db, src, dst) = await InitAsync(conn, Me()); + await db.StringSetAsync(dst, "not a sorted set"); + + var ex = await Assert.ThrowsAsync(() => db.SortedSetMoveAsync(src, dst)); + Assert.StartsWith("WRONGTYPE", ex.Message); + Assert.Equal(5, await db.SortedSetLengthAsync(src)); // nothing moved + } + + [Fact] + public async Task Move_InTransaction_HasTheSameTwoReplies() + { + await using var conn = Create(require: RedisFeatures.v8_12_0_rc3); + var (db, src, dst) = await InitAsync(conn, Me()); + + var tran = db.CreateTransaction(); + var first = tran.SortedSetMoveAsync(src, dst, count: 5); + var second = tran.SortedSetMoveAsync(src, dst); // the source is gone by now + Assert.True(await tran.ExecuteAsync()); + + Assert.Equal("a,b,c,d,e", Elements(await first)); + Assert.Null(await second); + } + + [Fact] + public async Task Cluster_SameSlot_RoutesAndMoves() + { + await using var conn = Create(configuration: GetClusterConfiguration(), require: RedisFeatures.v8_12_0_rc3); + var (db, src, dst) = await InitAsync(conn, Me()); + + Assert.Equal("a,b", Elements(await db.SortedSetMoveAsync(src, dst, count: 2))); + Assert.Equal("c", Elements(await db.SortedSetMoveByScoreAsync(src, dst, 3, 3))); + Assert.Equal("e,d", Elements(await db.SortedSetMoveMembersAsync(src, dst, ["e", "d"]))); + Assert.False(await db.KeyExistsAsync(src)); + Assert.Equal(5, await db.SortedSetLengthAsync(dst)); + } + + [Fact] + public async Task Cluster_CrossSlot_FailsAndMovesNothing() + { + await using var conn = Create(configuration: GetClusterConfiguration(), require: RedisFeatures.v8_12_0_rc3); + var db = conn.GetDatabase(); + RedisKey src = "{" + Me() + ":1}:src", dst = "{" + Me() + ":2}:dst"; + Assert.NotEqual(conn.HashSlot(src), conn.HashSlot(dst)); + await db.KeyDeleteAsync(src); + await db.KeyDeleteAsync(dst); + await db.SortedSetAddAsync(src, Entries); + + // One command, never split across slots. Which layer says no depends on the message shape: the bare form is + // refused by the library ("Multi-key operations must involve a single slot"), the longer forms are sent to + // the source's node and refused by the server (CROSSSLOT). Either way it is an error that names the slot + // rule, and nothing moves. + async Task AssertRefused(System.Func> move) + { + var ex = await Assert.ThrowsAnyAsync(move); + Assert.Contains("slot", ex.Message, System.StringComparison.OrdinalIgnoreCase); + } + + await AssertRefused(() => db.SortedSetMoveAsync(src, dst)); + await AssertRefused(() => db.SortedSetMoveAsync(src, dst, count: 2)); + await AssertRefused(() => db.SortedSetMoveByScoreAsync(src, dst, 0, 10)); + await AssertRefused(() => db.SortedSetMoveMembersAsync(src, dst, ["a", "b"])); + + Assert.Equal(5, await db.SortedSetLengthAsync(src)); + Assert.False(await db.KeyExistsAsync(dst)); + } + + [Fact] + public async Task Move_ViaKeyPrefix() + { + await using var conn = Create(require: RedisFeatures.v8_12_0_rc3); + var (db, src, dst) = await InitAsync(conn, Me()); + var prefixed = StackExchange.Redis.KeyspaceIsolation.DatabaseExtensions.WithKeyPrefix(db, "{" + Me() + "}:"); + + var moved = await prefixed.SortedSetMoveMembersAsync("src", "dst", ["c"]); + + Assert.Equal("c", Elements(moved)); + Assert.Equal(3, await db.SortedSetScoreAsync(dst, "c")); + } +}