diff --git a/src/NRedisStack/Auxiliary.cs b/src/NRedisStack/Auxiliary.cs index 761e6a7a..4dfd1c62 100644 --- a/src/NRedisStack/Auxiliary.cs +++ b/src/NRedisStack/Auxiliary.cs @@ -61,35 +61,33 @@ internal static void SetInfoInPipeline(this IDatabase db) } } -#if DEBUG - private const CommandFlags Flags = CommandFlags.NoRedirect; // disable redirect, so we spot -MOVED in tests -#else - private const CommandFlags Flags = CommandFlags.None; -#endif public static RedisResult Execute(this IDatabase db, SerializedCommand command) { db.SetInfoInPipeline(); - return db.Execute(command.Command, command.Args, flags: Flags); + return db.Execute(command.Command, command.Args, flags: command.EffectiveFlags); } internal static RedisResult Execute(this IServer server, int? db, SerializedCommand command) { - return server.Execute(db, command.Command, command.Args, flags: Flags); + return server.Execute(db, command.Command, command.Args, flags: command.EffectiveFlags); } public static async Task ExecuteAsync(this IDatabaseAsync db, SerializedCommand command) { ((IDatabase)db).SetInfoInPipeline(); - return await db.ExecuteAsync(command.Command, command.Args, flags: Flags); + return await db.ExecuteAsync(command.Command, command.Args, flags: command.EffectiveFlags); } internal static async Task ExecuteAsync(this IServer server, int? db, SerializedCommand command) { - return await server.ExecuteAsync(db, command.Command, command.Args, flags: Flags); + return await server.ExecuteAsync(db, command.Command, command.Args, flags: command.EffectiveFlags); } public static List ExecuteBroadcast(this IDatabase db, string command) - => db.ExecuteBroadcast(new SerializedCommand(command)); + => db.ExecuteBroadcast(SerializedCommand.Uncategorized(command)); + + public static List ExecuteBroadcast(this IDatabase db, CommandFlags category, string command) + => db.ExecuteBroadcast(new SerializedCommand(category, command)); public static List ExecuteBroadcast(this IDatabase db, SerializedCommand command) { @@ -113,7 +111,10 @@ public static List ExecuteBroadcast(this IDatabase db, SerializedCo } public static async Task> ExecuteBroadcastAsync(this IDatabaseAsync db, string command) - => await db.ExecuteBroadcastAsync(new SerializedCommand(command)); + => await db.ExecuteBroadcastAsync(SerializedCommand.Uncategorized(command)); + + public static async Task> ExecuteBroadcastAsync(this IDatabaseAsync db, CommandFlags category, string command) + => await db.ExecuteBroadcastAsync(new SerializedCommand(category, command)); private static async Task> ExecuteBroadcastAsync(this IDatabaseAsync db, SerializedCommand command) { diff --git a/src/NRedisStack/Bloom/BloomCommandBuilder.cs b/src/NRedisStack/Bloom/BloomCommandBuilder.cs index ffb4d162..c5a66270 100644 --- a/src/NRedisStack/Bloom/BloomCommandBuilder.cs +++ b/src/NRedisStack/Bloom/BloomCommandBuilder.cs @@ -7,22 +7,22 @@ public static class BloomCommandBuilder { public static SerializedCommand Add(RedisKey key, RedisValue item) { - return new(BF.ADD, key, item); + return new(CommandCategories.WriteChecked, BF.ADD, key, item); } public static SerializedCommand Card(RedisKey key) { - return new(BF.CARD, key); + return new(CommandCategories.ReadOnly, BF.CARD, key); } public static SerializedCommand Exists(RedisKey key, RedisValue item) { - return new(BF.EXISTS, key, item); + return new(CommandCategories.ReadOnly, BF.EXISTS, key, item); } public static SerializedCommand Info(RedisKey key) { - return new(BF.INFO, key); + return new(CommandCategories.ReadOnly, BF.INFO, key); } public static SerializedCommand Insert(RedisKey key, RedisValue[] items, int? capacity = null, @@ -34,12 +34,12 @@ public static SerializedCommand Insert(RedisKey key, RedisValue[] items, int? ca var args = BloomAux.BuildInsertArgs(key, items, capacity, error, expansion, nocreate, nonscaling); - return new(BF.INSERT, args); + return new(CommandCategories.WriteChecked, BF.INSERT, args); } public static SerializedCommand LoadChunk(RedisKey key, long iterator, Byte[] data) { - return new(BF.LOADCHUNK, key, iterator, data); + return new(CommandCategories.WriteAccumulating, BF.LOADCHUNK, key, iterator, data); } public static SerializedCommand MAdd(RedisKey key, params RedisValue[] items) @@ -50,7 +50,7 @@ public static SerializedCommand MAdd(RedisKey key, params RedisValue[] items) List args = [key]; args.AddRange(items.Cast()); - return new(BF.MADD, args); + return new(CommandCategories.WriteChecked, BF.MADD, args); } public static SerializedCommand MExists(RedisKey key, RedisValue[] items) @@ -61,7 +61,7 @@ public static SerializedCommand MExists(RedisKey key, RedisValue[] items) List args = [key]; args.AddRange(items.Cast()); - return new(BF.MEXISTS, args); + return new(CommandCategories.ReadOnly, BF.MEXISTS, args); } @@ -80,11 +80,11 @@ public static SerializedCommand Reserve(RedisKey key, double errorRate, long cap args.Add(BloomArgs.NONSCALING); } - return new(BF.RESERVE, args); + return new(CommandCategories.WriteAccumulating, BF.RESERVE, args); } public static SerializedCommand ScanDump(RedisKey key, long iterator) { - return new(BF.SCANDUMP, key, iterator); + return new(CommandCategories.ReadOnly | CommandCategories.ServerSpecific, BF.SCANDUMP, key, iterator); } } \ No newline at end of file diff --git a/src/NRedisStack/CoreCommands/CoreCommandBuilder.cs b/src/NRedisStack/CoreCommands/CoreCommandBuilder.cs index 5a557c8f..4862c423 100644 --- a/src/NRedisStack/CoreCommands/CoreCommandBuilder.cs +++ b/src/NRedisStack/CoreCommands/CoreCommandBuilder.cs @@ -17,7 +17,8 @@ public static SerializedCommand ClientSetInfo(SetInfoAttr attr, string value) _ => throw new ArgumentOutOfRangeException(nameof(attr)), }; - return new(RedisCoreCommands.CLIENT, RedisCoreCommands.SETINFO, attrValue, value); + // as SE.Redis categorizes CLIENT: connection-scoped, and scoped to *this* connection's server + return new(CommandCategories.Connection | CommandCategories.ServerSpecific, RedisCoreCommands.CLIENT, RedisCoreCommands.SETINFO, attrValue, value); } public static SerializedCommand BZMPop(double timeout, RedisKey[] keys, MinMaxModifier minMaxModifier, long? count) @@ -43,7 +44,7 @@ public static SerializedCommand BZMPop(double timeout, RedisKey[] keys, MinMaxMo args.Add(count); } - return new(RedisCoreCommands.BZMPOP, args); + return new(CommandCategories.WriteAccumulating, RedisCoreCommands.BZMPOP, args); } public static SerializedCommand BZPopMin(RedisKey[] keys, double timeout) @@ -78,7 +79,7 @@ public static SerializedCommand BLMPop(double timeout, RedisKey[] keys, ListSide args.Add(count); } - return new(RedisCoreCommands.BLMPOP, args); + return new(CommandCategories.WriteAccumulating, RedisCoreCommands.BLMPOP, args); } public static SerializedCommand BLPop(RedisKey[] keys, double timeout) @@ -102,7 +103,7 @@ public static SerializedCommand BLMove(RedisKey source, RedisKey destination, Li timeout ]; - return new(RedisCoreCommands.BLMOVE, args); + return new(CommandCategories.WriteAccumulating, RedisCoreCommands.BLMOVE, args); } public static SerializedCommand BRPopLPush(RedisKey source, RedisKey destination, double timeout) @@ -114,7 +115,7 @@ public static SerializedCommand BRPopLPush(RedisKey source, RedisKey destination timeout ]; - return new(RedisCoreCommands.BRPOPLPUSH, args); + return new(CommandCategories.WriteAccumulating, RedisCoreCommands.BRPOPLPUSH, args); } public static SerializedCommand XRead(RedisKey[] keys, RedisValue[] positions, int? count, int? timeoutMilliseconds) @@ -147,7 +148,7 @@ public static SerializedCommand XRead(RedisKey[] keys, RedisValue[] positions, i args.AddRange(keys.Cast()); args.AddRange(positions.Cast()); - return new(RedisCoreCommands.XREAD, args); + return new(CommandCategories.ReadOnly, RedisCoreCommands.XREAD, args); } public static SerializedCommand XReadGroup(RedisValue groupName, RedisValue consumerName, RedisKey[] keys, RedisValue[] positions, int? count, int? timeoutMilliseconds, bool? noAcknowledge) @@ -190,7 +191,7 @@ public static SerializedCommand XReadGroup(RedisValue groupName, RedisValue cons args.AddRange(keys.Cast()); args.AddRange(positions.Cast()); - return new(RedisCoreCommands.XREADGROUP, args); + return new(CommandCategories.WriteAccumulating, RedisCoreCommands.XREADGROUP, args); } private static SerializedCommand BlockingCommandWithKeysAndTimeout(String command, RedisKey[] keys, double timeout) @@ -204,6 +205,7 @@ private static SerializedCommand BlockingCommandWithKeysAndTimeout(String comman args.AddRange(keys.Cast()); args.Add(timeout); - return new(command, args); + // destructive reads: a replay pops a further element, and the popped one is already lost + return new(CommandCategories.WriteAccumulating, command, args); } } \ No newline at end of file diff --git a/src/NRedisStack/CountMinSketch/CmsCommandBuilder.cs b/src/NRedisStack/CountMinSketch/CmsCommandBuilder.cs index 33ceb99b..2f9dd6b2 100644 --- a/src/NRedisStack/CountMinSketch/CmsCommandBuilder.cs +++ b/src/NRedisStack/CountMinSketch/CmsCommandBuilder.cs @@ -8,7 +8,7 @@ public static class CmsCommandBuilder { public static SerializedCommand IncrBy(RedisKey key, RedisValue item, long increment) { - return new(CMS.INCRBY, key, item, increment); + return new(CommandCategories.WriteAccumulating, CMS.INCRBY, key, item, increment); } public static SerializedCommand IncrBy(RedisKey key, Tuple[] itemIncrements) @@ -23,23 +23,23 @@ public static SerializedCommand IncrBy(RedisKey key, Tuple[] i args.Add(pair.Item2); } - return new(CMS.INCRBY, args); + return new(CommandCategories.WriteAccumulating, CMS.INCRBY, args); } public static SerializedCommand Info(RedisKey key) { - var info = new SerializedCommand(CMS.INFO, key); + var info = new SerializedCommand(CommandCategories.ReadOnly, CMS.INFO, key); return info; } public static SerializedCommand InitByDim(RedisKey key, long width, long depth) { - return new(CMS.INITBYDIM, key, width, depth); + return new(CommandCategories.WriteAccumulating, CMS.INITBYDIM, key, width, depth); } public static SerializedCommand InitByProb(RedisKey key, double error, double probability) { - return new(CMS.INITBYPROB, key, error, probability); + return new(CommandCategories.WriteAccumulating, CMS.INITBYPROB, key, error, probability); } public static SerializedCommand Merge(RedisValue destination, long numKeys, RedisValue[] source, @@ -58,7 +58,7 @@ public static SerializedCommand Merge(RedisValue destination, long numKeys, Redi foreach (var w in weight) args.Add(w); } - return new(CMS.MERGE, args); + return new(CommandCategories.WriteAccumulating, CMS.MERGE, args); } public static SerializedCommand Query(RedisKey key, params RedisValue[] items) @@ -69,6 +69,6 @@ public static SerializedCommand Query(RedisKey key, params RedisValue[] items) List args = [key]; foreach (var item in items) args.Add(item); - return new(CMS.QUERY, args); + return new(CommandCategories.ReadOnly, CMS.QUERY, args); } } \ No newline at end of file diff --git a/src/NRedisStack/CuckooFilter/CuckooCommandBuilder.cs b/src/NRedisStack/CuckooFilter/CuckooCommandBuilder.cs index 0560509a..ef2f68c6 100644 --- a/src/NRedisStack/CuckooFilter/CuckooCommandBuilder.cs +++ b/src/NRedisStack/CuckooFilter/CuckooCommandBuilder.cs @@ -8,32 +8,32 @@ public static class CuckooCommandBuilder public static SerializedCommand Add(RedisKey key, RedisValue item) { - return new(CF.ADD, key, item); + return new(CommandCategories.WriteAccumulating, CF.ADD, key, item); } public static SerializedCommand AddNX(RedisKey key, RedisValue item) { - return new(CF.ADDNX, key, item); + return new(CommandCategories.WriteChecked, CF.ADDNX, key, item); } public static SerializedCommand Count(RedisKey key, RedisValue item) { - return new(CF.COUNT, key, item); + return new(CommandCategories.ReadOnly, CF.COUNT, key, item); } public static SerializedCommand Del(RedisKey key, RedisValue item) { - return new(CF.DEL, key, item); + return new(CommandCategories.WriteAccumulating, CF.DEL, key, item); } public static SerializedCommand Exists(RedisKey key, RedisValue item) { - return new(CF.EXISTS, key, item); + return new(CommandCategories.ReadOnly, CF.EXISTS, key, item); } public static SerializedCommand Info(RedisKey key) { - var info = new SerializedCommand(CF.INFO, key); + var info = new SerializedCommand(CommandCategories.ReadOnly, CF.INFO, key); return info; } @@ -61,7 +61,7 @@ public static SerializedCommand Insert(RedisKey key, RedisValue[] items, int? ca args.Add(item); } - return new(CF.INSERT, args); + return new(CommandCategories.WriteAccumulating, CF.INSERT, args); } public static SerializedCommand InsertNX(RedisKey key, RedisValue[] items, int? capacity = null, bool nocreate = false) @@ -88,12 +88,12 @@ public static SerializedCommand InsertNX(RedisKey key, RedisValue[] items, int? args.Add(item); } - return new(CF.INSERTNX, args); + return new(CommandCategories.WriteChecked, CF.INSERTNX, args); } public static SerializedCommand LoadChunk(RedisKey key, long iterator, Byte[] data) { - return new(CF.LOADCHUNK, key, iterator, data); + return new(CommandCategories.WriteAccumulating, CF.LOADCHUNK, key, iterator, data); } public static SerializedCommand MExists(RedisKey key, params RedisValue[] items) @@ -108,7 +108,7 @@ public static SerializedCommand MExists(RedisKey key, params RedisValue[] items) args.Add(item); } - return new(CF.MEXISTS, args); + return new(CommandCategories.ReadOnly, CF.MEXISTS, args); } public static SerializedCommand Reserve(RedisKey key, long capacity, @@ -134,11 +134,11 @@ public static SerializedCommand Reserve(RedisKey key, long capacity, args.Add(expansion); } - return new(CF.RESERVE, args); + return new(CommandCategories.WriteAccumulating, CF.RESERVE, args); } public static SerializedCommand ScanDump(RedisKey key, long iterator) { - return new(CF.SCANDUMP, key, iterator); + return new(CommandCategories.ReadOnly | CommandCategories.ServerSpecific, CF.SCANDUMP, key, iterator); } } \ No newline at end of file diff --git a/src/NRedisStack/Json/JsonCommandBuilder.cs b/src/NRedisStack/Json/JsonCommandBuilder.cs index ae41775a..d38da1ed 100644 --- a/src/NRedisStack/Json/JsonCommandBuilder.cs +++ b/src/NRedisStack/Json/JsonCommandBuilder.cs @@ -14,8 +14,8 @@ public static class JsonCommandBuilder public static SerializedCommand Resp(RedisKey key, string? path = null) { return string.IsNullOrEmpty(path) - ? new(JSON.RESP, key) - : new SerializedCommand(JSON.RESP, key, path!); + ? new(CommandCategories.ReadOnly, JSON.RESP, key) + : new SerializedCommand(CommandCategories.ReadOnly, JSON.RESP, key, path!); } #if DEBUG // avoid internal use @@ -52,7 +52,10 @@ public static SerializedCommand Set(RedisKey key, RedisValue path, RedisValue js }; } Debug.Assert(i == count, $"Arg count mismatch; check {nameof(JsonCommandBuilder)}.{nameof(Set)}"); - return new(JSON.SET, args); + // NX/XX make this conditional, so a replay is rejected rather than overwriting + var category = when is When.Exists or When.NotExists + ? CommandCategories.WriteChecked : CommandCategories.WriteLastWins; + return new(category, JSON.SET, args); } public static SerializedCommand MSet(KeyPathValue[] KeyPathValueList) @@ -61,47 +64,47 @@ public static SerializedCommand MSet(KeyPathValue[] KeyPathValueList) throw new ArgumentOutOfRangeException(nameof(KeyPathValueList)); var args = KeyPathValueList.SelectMany(x => x.ToArray()).ToArray(); - return new(JSON.MSET, args); + return new(CommandCategories.WriteLastWins, JSON.MSET, args); } public static SerializedCommand Merge(RedisKey key, RedisValue path, RedisValue json) { - return new(JSON.MERGE, key, path, json); + return new(CommandCategories.WriteLastWins, JSON.MERGE, key, path, json); } public static SerializedCommand StrAppend(RedisKey key, string value, string? path = null) { return path == null - ? new(JSON.STRAPPEND, key, JsonSerializer.Serialize(value)) - : new SerializedCommand(JSON.STRAPPEND, key, path, JsonSerializer.Serialize(value)); + ? new(CommandCategories.WriteAccumulating, JSON.STRAPPEND, key, JsonSerializer.Serialize(value)) + : new SerializedCommand(CommandCategories.WriteAccumulating, JSON.STRAPPEND, key, path, JsonSerializer.Serialize(value)); } public static SerializedCommand StrLen(RedisKey key, string? path = null) { return path != null - ? new(JSON.STRLEN, key, path) - : new SerializedCommand(JSON.STRLEN, key); + ? new(CommandCategories.ReadOnly, JSON.STRLEN, key, path) + : new SerializedCommand(CommandCategories.ReadOnly, JSON.STRLEN, key); } public static SerializedCommand Toggle(RedisKey key, string? path = null) { return path != null - ? new(JSON.TOGGLE, key, path) - : new SerializedCommand(JSON.TOGGLE, key, "$"); + ? new(CommandCategories.WriteAccumulating, JSON.TOGGLE, key, path) + : new SerializedCommand(CommandCategories.WriteAccumulating, JSON.TOGGLE, key, "$"); } public static SerializedCommand Type(RedisKey key, string? path = null) { return (path != null) - ? new(JSON.TYPE, key, path) - : new SerializedCommand(JSON.TYPE, key); + ? new(CommandCategories.ReadOnly, JSON.TYPE, key, path) + : new SerializedCommand(CommandCategories.ReadOnly, JSON.TYPE, key); } public static SerializedCommand DebugMemory(string key, string? path = null) { return (path != null) - ? new(JSON.DEBUG, JSON.MEMORY, (RedisKey)key, path) - : new SerializedCommand(JSON.DEBUG, JSON.MEMORY, (RedisKey)key); + ? new(CommandCategories.ReadOnly, JSON.DEBUG, JSON.MEMORY, (RedisKey)key, path) + : new SerializedCommand(CommandCategories.ReadOnly, JSON.DEBUG, JSON.MEMORY, (RedisKey)key); } public static SerializedCommand ArrAppend(RedisKey key, string? path = null, params object[] values) @@ -117,7 +120,7 @@ public static SerializedCommand ArrAppend(RedisKey key, string? path = null, par args.AddRange(values.Select(x => JsonSerializer.Serialize(x))); - return new(JSON.ARRAPPEND, args.ToArray()); + return new(CommandCategories.WriteAccumulating, JSON.ARRAPPEND, args.ToArray()); } public static SerializedCommand ArrIndex(RedisKey key, string path, object value, long? start = null, @@ -127,7 +130,7 @@ public static SerializedCommand ArrIndex(RedisKey key, string path, object value throw new ArgumentException("stop cannot be defined without start"); var args = AssembleNonNullArguments(key, path, JsonSerializer.Serialize(value), start, stop); - return new(JSON.ARRINDEX, args); + return new(CommandCategories.ReadOnly, JSON.ARRINDEX, args); } public static SerializedCommand ArrInsert(RedisKey key, string path, long index, params object[] values) @@ -137,13 +140,13 @@ public static SerializedCommand ArrInsert(RedisKey key, string path, long index, var args = new List { key, path, index }; args.AddRange(values.Select(val => JsonSerializer.Serialize(val))); - return new(JSON.ARRINSERT, args); + return new(CommandCategories.WriteAccumulating, JSON.ARRINSERT, args); } public static SerializedCommand ArrLen(RedisKey key, string? path = null) { var args = AssembleNonNullArguments(key, path); - return new(JSON.ARRLEN, args); + return new(CommandCategories.ReadOnly, JSON.ARRLEN, args); } public static SerializedCommand ArrPop(RedisKey key, string? path = null, long? index = null) @@ -152,22 +155,22 @@ public static SerializedCommand ArrPop(RedisKey key, string? path = null, long? throw new ArgumentException("index cannot be defined without path"); var args = AssembleNonNullArguments(key, path, index); - return new(JSON.ARRPOP, args); + return new(CommandCategories.WriteAccumulating, JSON.ARRPOP, args); } public static SerializedCommand ArrTrim(RedisKey key, string path, long start, long stop) => - new(JSON.ARRTRIM, key, path, start, stop); + new(CommandCategories.WriteAccumulating, JSON.ARRTRIM, key, path, start, stop); public static SerializedCommand Clear(RedisKey key, string? path = null) { var args = AssembleNonNullArguments(key, path); - return new(JSON.CLEAR, args); + return new(CommandCategories.WriteLastWins, JSON.CLEAR, args); } public static SerializedCommand Del(RedisKey key, string? path = null) { var args = AssembleNonNullArguments(key, path); - return new(JSON.DEL, args); + return new(CommandCategories.WriteLastWins, JSON.DEL, args); } public static SerializedCommand Get(RedisKey key, RedisValue? indent = null, RedisValue? newLine = null, @@ -198,7 +201,7 @@ public static SerializedCommand Get(RedisKey key, RedisValue? indent = null, Red args.Add(path); } - return new(JSON.GET, args); + return new(CommandCategories.ReadOnly, JSON.GET, args); } public static SerializedCommand Get(RedisKey key, string[] paths, RedisValue? indent = null, @@ -226,12 +229,12 @@ public static SerializedCommand Get(RedisKey key, string[] paths, RedisValue? in args.AddRange(paths); - return new(JSON.GET, args); + return new(CommandCategories.ReadOnly, JSON.GET, args); } public static SerializedCommand Get(RedisKey key, string path = "$") { - return new(JSON.GET, key, path); + return new(CommandCategories.ReadOnly, JSON.GET, key, path); } public static SerializedCommand MGet(RedisKey[] keys, string path) @@ -239,23 +242,23 @@ public static SerializedCommand MGet(RedisKey[] keys, string path) var args = keys.Cast().ToList(); args.Add(path); - return new(JSON.MGET, args); + return new(CommandCategories.ReadOnly, JSON.MGET, args); } public static SerializedCommand NumIncrby(RedisKey key, string path, double value) { - return new(JSON.NUMINCRBY, key, path, value); + return new(CommandCategories.WriteAccumulating, JSON.NUMINCRBY, key, path, value); } public static SerializedCommand ObjKeys(RedisKey key, string? path = null) { var args = AssembleNonNullArguments(key, path); - return new(JSON.OBJKEYS, args); + return new(CommandCategories.ReadOnly, JSON.OBJKEYS, args); } public static SerializedCommand ObjLen(RedisKey key, string? path = null) { var args = AssembleNonNullArguments(key, path); - return new(JSON.OBJLEN, args); + return new(CommandCategories.ReadOnly, JSON.OBJLEN, args); } } \ No newline at end of file diff --git a/src/NRedisStack/NRedisStack.csproj b/src/NRedisStack/NRedisStack.csproj index ac8f1577..4374d056 100644 --- a/src/NRedisStack/NRedisStack.csproj +++ b/src/NRedisStack/NRedisStack.csproj @@ -9,6 +9,10 @@ $(Nowarn);RS0026 + + $(WarningsAsErrors);CS0618 + 1.0.0-beta1 1.0.0-beta1 1.0.0-beta1 diff --git a/src/NRedisStack/PublicAPI/PublicAPI.Unshipped.txt b/src/NRedisStack/PublicAPI/PublicAPI.Unshipped.txt index 7dc5c581..3d2ca7cc 100644 --- a/src/NRedisStack/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/NRedisStack/PublicAPI/PublicAPI.Unshipped.txt @@ -1 +1,6 @@ #nullable enable +NRedisStack.RedisStackCommands.SerializedCommand.CommandCategory.get -> StackExchange.Redis.CommandFlags +NRedisStack.RedisStackCommands.SerializedCommand.SerializedCommand(StackExchange.Redis.CommandFlags category, string! command, System.Collections.Generic.ICollection! args) -> void +NRedisStack.RedisStackCommands.SerializedCommand.SerializedCommand(StackExchange.Redis.CommandFlags category, string! command, params object![]! args) -> void +static NRedisStack.Auxiliary.ExecuteBroadcast(this StackExchange.Redis.IDatabase! db, StackExchange.Redis.CommandFlags category, string! command) -> System.Collections.Generic.List! +static NRedisStack.Auxiliary.ExecuteBroadcastAsync(this StackExchange.Redis.IDatabaseAsync! db, StackExchange.Redis.CommandFlags category, string! command) -> System.Threading.Tasks.Task!>! diff --git a/src/NRedisStack/RedisStackCommands/CommandCategories.cs b/src/NRedisStack/RedisStackCommands/CommandCategories.cs new file mode 100644 index 00000000..f8e95af5 --- /dev/null +++ b/src/NRedisStack/RedisStackCommands/CommandCategories.cs @@ -0,0 +1,91 @@ +using StackExchange.Redis; + +namespace NRedisStack.RedisStackCommands; + +/// +/// Command side-effect categories, used to tell StackExchange.Redis whether (and how aggressively) a +/// command may be replayed by WithRetry. +/// +/// +/// +/// These mirror the CommandFlags.CommandRetry* members added in StackExchange.Redis 3.1.0, but are +/// declared here as casts so that NRedisStack can keep a 3.0.x floor: on older versions the bits fall +/// outside the library's user-selectable mask and are silently discarded, which reproduces today's +/// behaviour exactly. ServerSpecific is accepted by 3.1.0 but is not named on its public enum. +/// +/// +/// The ladder runs from least to most side-effecting; a retry policy specifies the most side-effecting +/// category it is willing to replay, so the numeric ordering is load-bearing. ServerSpecific is a +/// single orthogonal bit, combinable with any rung. +/// +/// +internal static class CommandCategories +{ + /// Always safe to replay, regardless of connection or server state. + internal const CommandFlags Always = (CommandFlags)(1 << 13); + + /// Connection-level metadata, e.g. CLIENT SETINFO. + internal const CommandFlags Connection = (CommandFlags)(4 << 13); + + /// Pure read; replay observes state but does not change it. + internal const CommandFlags ReadOnly = (CommandFlags)(8 << 13); + + /// Conditional write; a replay is checked against server state and rejected. + internal const CommandFlags WriteChecked = (CommandFlags)(12 << 13); + + /// Unconditional overwrite; a replay lands on the same value (last-writer-wins). + internal const CommandFlags WriteLastWins = (CommandFlags)(16 << 13); + + /// Cumulative write; a replay double-applies and changes the result. + /// + /// Also carries a second, less obvious class of command: one whose replay leaves state *correct* but + /// returns an error, because the server rejects the repeat ("Index already exists", "key already + /// exists", "compaction rule does not exist"). Most module DDL behaves this way. That is not literally + /// cumulative, but the ladder has no rung for "idempotent, yet errors on replay", and the alternatives + /// are worse: and both sit at or below the + /// default policy ceiling, so a lost reply would be replayed *by default* and report failure for an + /// operation that actually succeeded. Sitting here keeps that closed unless the caller deliberately + /// raises the ceiling, while still allowing the known-never-sent replay (see ), + /// which is the case that matters for riding out -LOADING during setup. + /// + internal const CommandFlags WriteAccumulating = (CommandFlags)(20 << 13); + + /// Server administration, e.g. FT.CONFIG SET. + internal const CommandFlags ServerAdmin = (CommandFlags)(24 << 13); + + /// Never replay, under any policy. + /// + /// The only category a retry policy cannot override. StackExchange.Redis tests for it before both the + /// MaxCommandRetryCategory comparison and the known-never-sent (NotApplied) bypass, so + /// unlike the rungs below it this holds even when a caller raises the ceiling - which they may do as + /// far as Never itself. Use it where a replay would corrupt state or report a spurious error + /// even though the original attempt succeeded, and where forgoing retry entirely is the lesser cost. + /// + internal const CommandFlags Never = (CommandFlags)(31 << 13); + + /// + /// The command is bound to a particular endpoint (typically because it carries a server-side cursor + /// or iterator, or reads node-local state), so it must not be replayed against a different one. + /// Combine with the rung describing the command's own side-effects. + /// + /// + /// This narrows rather than vetoes: StackExchange.Redis strips failover from the permitted retry + /// targets but still allows a same-server retry. It is also checked *after* the category ladder, so + /// it cannot rescue a command whose rung already exceeds the policy. + /// + internal const CommandFlags ServerSpecific = (CommandFlags)(1 << 18); + + /// + /// The rung bits (13-17): the severity ladder on its own, without . A + /// category is only meaningful if it names a rung, since alone leaves + /// StackExchange.Redis to substitute a default rung. + /// + internal const CommandFlags LadderMask = Never; + + /// + /// The bits a caller-supplied category may occupy: the retry-category region (13-17) plus + /// (18). Anything else is masked off before dispatch, so a stray + /// FireAndForget or replica preference cannot leak in through the category parameter. + /// + internal const CommandFlags Mask = LadderMask | ServerSpecific; +} diff --git a/src/NRedisStack/RedisStackCommands/SerializedCommand.cs b/src/NRedisStack/RedisStackCommands/SerializedCommand.cs index 0de351d8..dd24ae6b 100644 --- a/src/NRedisStack/RedisStackCommands/SerializedCommand.cs +++ b/src/NRedisStack/RedisStackCommands/SerializedCommand.cs @@ -1,16 +1,96 @@ +using StackExchange.Redis; + namespace NRedisStack.RedisStackCommands; -public class SerializedCommand(string command, params object[] args) +public class SerializedCommand { - public string Command { get; } = command; - public object[] Args { get; } = args; + public string Command { get; } + public object[] Args { get; } + + /// + /// The side-effect category of this command, telling StackExchange.Redis whether it may be replayed + /// by WithRetry. Only the retry-category bits and the server-specific bit are honoured; other + /// values are masked off before dispatch. + /// + /// + /// means "uncategorized", which StackExchange.Redis treats as + /// never-retry. + /// + public CommandFlags CommandCategory { get; } + +#if DEBUG + private const CommandFlags BaseFlags = CommandFlags.NoRedirect; // disable redirect, so we spot -MOVED in tests +#else + private const CommandFlags BaseFlags = CommandFlags.None; +#endif + + /// + /// The flags to dispatch this command with: the library-wide defaults combined with + /// . + /// + internal CommandFlags EffectiveFlags => BaseFlags | CommandCategory; + + // the unvalidated path: the obsolete ctors and Uncategorized() deliberately yield None, which the + // validating ctors reject + private SerializedCommand(string command, object[] args, CommandFlags category) + { + CommandCategory = category & CommandCategories.Mask; + Command = command; + Args = args; + } + + /// + /// Builds a command with no declared category, which StackExchange.Redis will never retry. Only for + /// entry points that take a bare command string and so have nothing to categorize. + /// + internal static SerializedCommand Uncategorized(string command, params object[] args) + => new(command, args, CommandFlags.None); + + [Obsolete("Specify the command's side-effect category, so that it can participate in WithRetry; uncategorized commands are never retried.")] + public SerializedCommand(string command, params object[] args) + : this(command, args, CommandFlags.None) + { + } + + [Obsolete("Specify the command's side-effect category, so that it can participate in WithRetry; uncategorized commands are never retried.")] + public SerializedCommand(string command, ICollection args) + : this(command, args.ToArray(), CommandFlags.None) + { + } - public SerializedCommand(string command, ICollection args) : this(command, args.ToArray()) + /// + /// The command's side-effect category; see the CommandFlags.CommandRetry* values. Must contain + /// at least one category bit. + /// + /// The command name. + /// The command arguments. + /// + /// If contains no category bits. + /// + public SerializedCommand(CommandFlags category, string command, params object[] args) + : this(command, args, Validate(category)) { } + /// + public SerializedCommand(CommandFlags category, string command, ICollection args) + : this(command, args.ToArray(), Validate(category)) + { + } + + // A category naming no rung is a mistake worth surfacing rather than silently accepting: it means the + // caller passed default/None, or only flags that do not belong here (e.g. FireAndForget), or only the + // server-specific bit - and the result would quietly be "never retry" wearing the costume of a + // declaration. Tested against the ladder rather than the full mask precisely so that the + // server-specific bit on its own does not satisfy it. + private static CommandFlags Validate(CommandFlags category) + => (category & CommandCategories.LadderMask) != 0 + ? category + : throw new ArgumentOutOfRangeException(nameof(category), category, + "No command category specified; expected one of the CommandFlags.CommandRetry* values."); + /// public override string ToString() => Args is { Length: > 0 } ? (Command + " " + string.Join(" ", Args)) : Command; -} \ No newline at end of file +} diff --git a/src/NRedisStack/Search/SearchCommandBuilder.cs b/src/NRedisStack/Search/SearchCommandBuilder.cs index a1fa5242..92a9e8df 100644 --- a/src/NRedisStack/Search/SearchCommandBuilder.cs +++ b/src/NRedisStack/Search/SearchCommandBuilder.cs @@ -8,7 +8,7 @@ public static class SearchCommandBuilder { public static SerializedCommand _List() { - return new(FT._LIST); + return new(CommandCategories.ReadOnly, FT._LIST); } public static SerializedCommand Aggregate(string index, AggregationRequest query) @@ -16,27 +16,42 @@ public static SerializedCommand Aggregate(string index, AggregationRequest query List args = [index]; query.SerializeRedisArgs(); args.AddRange(query.GetArgs()); - return new(FT.AGGREGATE, args); + return new(AggregateCategory(query), FT.AGGREGATE, args); } + /// + /// A plain aggregate is a pure read, but WITHCURSOR allocates cursor state on the serving node, so + /// every replay leaks another cursor (held until its idle timeout) and returns an id the caller's + /// follow-up FT.CURSOR calls will not be using. + /// + /// + /// Never, not merely a high rung. ServerSpecific is not enough, because it only strips failover and a + /// same-server replay orphans a cursor just as effectively. Nor is WriteAccumulating: that relies on + /// the policy's default ceiling, and a caller may legitimately raise MaxCommandRetryCategory - which + /// says "I accept double-applied writes", not "I accept leaked cursors". Never is the only category + /// no policy can override, since the ceiling itself may be set as high as Never. + /// + private static CommandFlags AggregateCategory(AggregationRequest query) + => query.IsWithCursor() ? CommandCategories.Never : CommandCategories.ReadOnly; + public static SerializedCommand AliasAdd(string alias, string index) { - return new(FT.ALIASADD, alias, index); + return new(CommandCategories.WriteAccumulating, FT.ALIASADD, alias, index); } public static SerializedCommand AliasDel(string alias) { - return new(FT.ALIASDEL, alias); + return new(CommandCategories.WriteAccumulating, FT.ALIASDEL, alias); } public static SerializedCommand AliasUpdate(string alias, string index) { - return new(FT.ALIASUPDATE, alias, index); + return new(CommandCategories.WriteLastWins, FT.ALIASUPDATE, alias, index); } public static SerializedCommand AliasList(string index) { - return new(FT.ALIASLIST, index); + return new(CommandCategories.ReadOnly, FT.ALIASLIST, index); } public static SerializedCommand Alter(string index, Schema schema, bool skipInitialScan = false) { @@ -48,19 +63,21 @@ public static SerializedCommand Alter(string index, Schema schema, bool skipInit { f.AddSchemaArgs(args); } - return new(FT.ALTER, args); + return new(CommandCategories.WriteAccumulating, FT.ALTER, args); } [Obsolete("Starting from Redis 8.0, use db.ConfigGet instead")] public static SerializedCommand ConfigGet(string option) { - return new(FT.CONFIG, "GET", option); + // matches how SE.Redis categorizes plain CONFIG: node-local, and GET is not meaningfully safer, + // since a different member can be configured differently + return new(CommandCategories.ServerAdmin | CommandCategories.ServerSpecific, FT.CONFIG, "GET", option); } [Obsolete("Starting from Redis 8.0, use db.ConfigSet instead")] public static SerializedCommand ConfigSet(string option, string value) { - return new(FT.CONFIG, "SET", option, value); + return new(CommandCategories.ServerAdmin | CommandCategories.ServerSpecific, FT.CONFIG, "SET", option, value); } public static SerializedCommand Create(string indexName, FTCreateParams parameters, Schema schema) @@ -75,18 +92,26 @@ public static SerializedCommand Create(string indexName, FTCreateParams paramete f.AddSchemaArgs(args); } - return new(FT.CREATE, args); + return new(CommandCategories.WriteAccumulating, FT.CREATE, args); } public static SerializedCommand CursorDel(string indexName, long cursorId) { - return new(FT.CURSOR, "DEL", indexName, cursorId); + // RediSearch errors ("Cursor does not exist") rather than treating a repeat delete as an OK no-op, + // so replaying a delete whose reply was merely lost reports a failure for cleanup that actually + // succeeded. Never rather than a high rung, since the policy ceiling is caller-settable and + // forgoing retry costs little here - an undeleted cursor expires by idle timeout anyway. + return new(CommandCategories.Never, FT.CURSOR, "DEL", indexName, cursorId); } public static SerializedCommand CursorRead(string indexName, long cursorId, int? count = null) { - return ((count == null) ? new(FT.CURSOR, "READ", indexName, cursorId) - : new SerializedCommand(FT.CURSOR, "READ", indexName, cursorId, "COUNT", count)); + // Reading advances the cursor, so a replay silently skips a page rather than re-reading one - i.e. + // data loss, not just a wasted round trip. Never for the same reason as CursorDel: no lower rung + // survives a caller-raised MaxCommandRetryCategory. + const CommandFlags Category = CommandCategories.Never; + return ((count == null) ? new(Category, FT.CURSOR, "READ", indexName, cursorId) + : new SerializedCommand(Category, FT.CURSOR, "READ", indexName, cursorId, "COUNT", count)); } public static SerializedCommand DictAdd(string dict, params string[] terms) @@ -102,7 +127,7 @@ public static SerializedCommand DictAdd(string dict, params string[] terms) args.Add(t); } - return new(FT.DICTADD, args); + return new(CommandCategories.WriteLastWins, FT.DICTADD, args); } public static SerializedCommand DictDel(string dict, params string[] terms) @@ -118,18 +143,18 @@ public static SerializedCommand DictDel(string dict, params string[] terms) args.Add(t); } - return new(FT.DICTDEL, args); + return new(CommandCategories.WriteLastWins, FT.DICTDEL, args); } public static SerializedCommand DictDump(string dict) { - return new(FT.DICTDUMP, dict); + return new(CommandCategories.ReadOnly, FT.DICTDUMP, dict); } public static SerializedCommand DropIndex(string indexName, bool dd = false) { - return ((dd) ? new(FT.DROPINDEX, indexName, "DD") - : new SerializedCommand(FT.DROPINDEX, indexName)); + return ((dd) ? new(CommandCategories.WriteAccumulating, FT.DROPINDEX, indexName, "DD") + : new SerializedCommand(CommandCategories.WriteAccumulating, FT.DROPINDEX, indexName)); } public static SerializedCommand Explain(string indexName, string query, int? dialect) @@ -140,7 +165,7 @@ public static SerializedCommand Explain(string indexName, string query, int? dia args.Add("DIALECT"); args.Add(dialect); } - return new(FT.EXPLAIN, args); + return new(CommandCategories.ReadOnly, FT.EXPLAIN, args); } public static SerializedCommand ExplainCli(string indexName, string query, int? dialect) @@ -151,17 +176,17 @@ public static SerializedCommand ExplainCli(string indexName, string query, int? args.Add("DIALECT"); args.Add(dialect); } - return new(FT.EXPLAINCLI, args); + return new(CommandCategories.ReadOnly, FT.EXPLAINCLI, args); } - public static SerializedCommand Info(RedisValue index) => new(FT.INFO, index); + public static SerializedCommand Info(RedisValue index) => new(CommandCategories.ReadOnly, FT.INFO, index); public static SerializedCommand Search(string indexName, Query q) { var args = new List { indexName }; q.SerializeRedisArgs(args); - return new(FT.SEARCH, args); + return new(CommandCategories.ReadOnly, FT.SEARCH, args); } public static SerializedCommand ProfileSearch(string IndexName, Query q, bool limited = false) @@ -172,7 +197,7 @@ public static SerializedCommand ProfileSearch(string IndexName, Query q, bool li : new List() { IndexName, SearchArgs.SEARCH, SearchArgs.QUERY }; q.SerializeRedisArgs(args); - return new(FT.PROFILE, args); + return new(CommandCategories.ReadOnly, FT.PROFILE, args); } public static SerializedCommand ProfileAggregate(string IndexName, AggregationRequest query, bool limited = false) @@ -183,7 +208,8 @@ public static SerializedCommand ProfileAggregate(string IndexName, AggregationRe query.SerializeRedisArgs(); args.AddRange(query.GetArgs()); - return new(FT.PROFILE, args); + // profiling a cursored aggregate still allocates the cursor, so it carries the same category + return new(AggregateCategory(query), FT.PROFILE, args); } public static SerializedCommand SpellCheck(string indexName, string query, FTSpellCheckParams? spellCheckParams = null) @@ -193,10 +219,10 @@ public static SerializedCommand SpellCheck(string indexName, string query, FTSpe spellCheckParams.SerializeRedisArgs(); var args = new List(spellCheckParams.GetArgs().Count + 2) { indexName, query }; // TODO: check if this improves performance (create a list with exact size) args.AddRange(spellCheckParams.GetArgs()); - return new(FT.SPELLCHECK, args); + return new(CommandCategories.ReadOnly, FT.SPELLCHECK, args); } - return new(FT.SPELLCHECK, indexName, query); + return new(CommandCategories.ReadOnly, FT.SPELLCHECK, indexName, query); } public static SerializedCommand SugAdd(string key, string str, double score, bool increment = false, string? payload = null) @@ -204,12 +230,13 @@ public static SerializedCommand SugAdd(string key, string str, double score, boo var args = new List { (RedisKey)key, str, score }; if (increment) { args.Add(SearchArgs.INCR); } if (payload != null) { args.Add(SearchArgs.PAYLOAD); args.Add(payload); } - return new(FT.SUGADD, args); + // INCR adds to the existing score, so a replay inflates it; otherwise the score is just set + return new(increment ? CommandCategories.WriteAccumulating : CommandCategories.WriteLastWins, FT.SUGADD, args); } public static SerializedCommand SugDel(string key, string str) { - return new(FT.SUGDEL, (RedisKey)key, str); + return new(CommandCategories.WriteLastWins, FT.SUGDEL, (RedisKey)key, str); } public static SerializedCommand SugGet(string key, string prefix, bool fuzzy = false, bool withScores = false, bool withPayloads = false, int? max = null) @@ -219,17 +246,17 @@ public static SerializedCommand SugGet(string key, string prefix, bool fuzzy = f if (withScores) { args.Add(SearchArgs.WITHSCORES); } if (withPayloads) { args.Add(SearchArgs.WITHPAYLOADS); } if (max != null) { args.Add(SearchArgs.MAX); args.Add(max); } - return new(FT.SUGGET, args); + return new(CommandCategories.ReadOnly, FT.SUGGET, args); } public static SerializedCommand SugLen(string key) { - return new(FT.SUGLEN, (RedisKey)key); + return new(CommandCategories.ReadOnly, FT.SUGLEN, (RedisKey)key); } public static SerializedCommand SynDump(string indexName) { - return new(FT.SYNDUMP, indexName); + return new(CommandCategories.ReadOnly, FT.SYNDUMP, indexName); } public static SerializedCommand SynUpdate(string indexName, string synonymGroupId, bool skipInitialScan = false, params string[] terms) @@ -241,9 +268,9 @@ public static SerializedCommand SynUpdate(string indexName, string synonymGroupI var args = new List { indexName, synonymGroupId }; if (skipInitialScan) { args.Add(SearchArgs.SKIPINITIALSCAN); } args.AddRange(terms); - return new(FT.SYNUPDATE, args); + return new(CommandCategories.WriteLastWins, FT.SYNUPDATE, args); } public static SerializedCommand TagVals(string indexName, string fieldName) => //TODO: consider return Set - new(FT.TAGVALS, indexName, fieldName); + new(CommandCategories.ReadOnly, FT.TAGVALS, indexName, fieldName); } \ No newline at end of file diff --git a/src/NRedisStack/Tdigest/TdigestCommandBuilder.cs b/src/NRedisStack/Tdigest/TdigestCommandBuilder.cs index bfc480ad..eac1ed67 100644 --- a/src/NRedisStack/Tdigest/TdigestCommandBuilder.cs +++ b/src/NRedisStack/Tdigest/TdigestCommandBuilder.cs @@ -15,34 +15,34 @@ public static SerializedCommand Add(RedisKey key, params double[] values) args[i + 1] = values[i]; } - return new(TDIGEST.ADD, args); + return new(CommandCategories.WriteAccumulating, TDIGEST.ADD, args); } public static SerializedCommand CDF(RedisKey key, params double[] values) { var args = new List(values.Length + 1) { key }; foreach (var value in values) args.Add(value); - return new(TDIGEST.CDF, args); + return new(CommandCategories.ReadOnly, TDIGEST.CDF, args); } public static SerializedCommand Create(RedisKey key, long compression = 100) { - return new(TDIGEST.CREATE, key, TdigestArgs.COMPRESSION, compression); + return new(CommandCategories.WriteAccumulating, TDIGEST.CREATE, key, TdigestArgs.COMPRESSION, compression); } public static SerializedCommand Info(RedisKey key) { - return new(TDIGEST.INFO, key); + return new(CommandCategories.ReadOnly, TDIGEST.INFO, key); } public static SerializedCommand Max(RedisKey key) { - return new(TDIGEST.MAX, key); + return new(CommandCategories.ReadOnly, TDIGEST.MAX, key); } public static SerializedCommand Min(RedisKey key) { - return new(TDIGEST.MIN, key); + return new(CommandCategories.ReadOnly, TDIGEST.MIN, key); } public static SerializedCommand Merge(RedisKey destinationKey, long compression = default(long), bool overide = false, params RedisKey[] sourceKeys) @@ -67,7 +67,7 @@ public static SerializedCommand Min(RedisKey key) args.Add("OVERRIDE"); } - return new(TDIGEST.MERGE, args); + return new(CommandCategories.WriteAccumulating, TDIGEST.MERGE, args); } public static SerializedCommand Quantile(RedisKey key, params double[] quantile) @@ -77,7 +77,7 @@ public static SerializedCommand Quantile(RedisKey key, params double[] quantile) var args = new List { key }; foreach (var q in quantile) args.Add(q); - return new(TDIGEST.QUANTILE, args); + return new(CommandCategories.ReadOnly, TDIGEST.QUANTILE, args); } public static SerializedCommand Rank(RedisKey key, params long[] values) @@ -86,7 +86,7 @@ public static SerializedCommand Rank(RedisKey key, params long[] values) var args = new List(values.Length + 1) { key }; foreach (var v in values) args.Add(v); - return new(TDIGEST.RANK, args); + return new(CommandCategories.ReadOnly, TDIGEST.RANK, args); } public static SerializedCommand RevRank(RedisKey key, params long[] values) @@ -95,7 +95,7 @@ public static SerializedCommand RevRank(RedisKey key, params long[] values) var args = new List(values.Length + 1) { key }; foreach (var v in values) args.Add(v); - return new(TDIGEST.REVRANK, args); + return new(CommandCategories.ReadOnly, TDIGEST.REVRANK, args); } public static SerializedCommand ByRank(RedisKey key, params long[] ranks) @@ -104,7 +104,7 @@ public static SerializedCommand ByRank(RedisKey key, params long[] ranks) var args = new List(ranks.Length + 1) { key }; foreach (var v in ranks) args.Add(v); - return new(TDIGEST.BYRANK, args); + return new(CommandCategories.ReadOnly, TDIGEST.BYRANK, args); } public static SerializedCommand ByRevRank(RedisKey key, params long[] ranks) @@ -113,16 +113,16 @@ public static SerializedCommand ByRevRank(RedisKey key, params long[] ranks) var args = new List(ranks.Length + 1) { key }; foreach (var v in ranks) args.Add(v); - return new(TDIGEST.BYREVRANK, args); + return new(CommandCategories.ReadOnly, TDIGEST.BYREVRANK, args); } public static SerializedCommand Reset(RedisKey key) { - return new(TDIGEST.RESET, key); + return new(CommandCategories.WriteLastWins, TDIGEST.RESET, key); } public static SerializedCommand TrimmedMean(RedisKey key, double lowCutQuantile, double highCutQuantile) { - return new(TDIGEST.TRIMMED_MEAN, key, lowCutQuantile, highCutQuantile); + return new(CommandCategories.ReadOnly, TDIGEST.TRIMMED_MEAN, key, lowCutQuantile, highCutQuantile); } } \ No newline at end of file diff --git a/src/NRedisStack/TimeSeries/DataTypes/TSParameters.cs b/src/NRedisStack/TimeSeries/DataTypes/TSParameters.cs index 7cdd5394..4ef0c226 100644 --- a/src/NRedisStack/TimeSeries/DataTypes/TSParameters.cs +++ b/src/NRedisStack/TimeSeries/DataTypes/TSParameters.cs @@ -1,5 +1,6 @@ using NRedisStack.DataTypes; using NRedisStack.Literals.Enums; +using NRedisStack.RedisStackCommands; using StackExchange.Redis; namespace NRedisStack; @@ -55,7 +56,13 @@ internal TsAlterParams(long? retentionTime, long? chunkSizeBytes, TsDuplicatePol public class TsAddParams : TsBaseParams { - internal TsAddParams(IList parameters) : base(parameters) { } + /// + /// The side-effect category of the TS.ADD this describes; see . + /// + internal CommandFlags Category { get; } + + internal TsAddParams(IList parameters, CommandFlags category) : base(parameters) + => Category = category; internal TsAddParams(TimeStamp timestamp, double value, long? retentionTime, IReadOnlyCollection? labels, bool? uncompressed, long? chunkSizeBytes, TsDuplicatePolicy? policy) { @@ -66,6 +73,38 @@ internal TsAddParams(TimeStamp timestamp, double value, long? retentionTime, IRe parameters.AddLabels(labels); parameters.AddUncompressed(uncompressed); parameters.AddOnDuplicate(policy); + Category = ResolveCategory(timestamp, policy); + } + + /// + /// How a replayed TS.ADD behaves, which depends on the timestamp and on the *effective* duplicate + /// policy for the sample. + /// + /// + /// Only an explicit ON_DUPLICATE lets us reason about this, because it overrides whatever the + /// series was created with. Absent one, the series' stored DUPLICATE_POLICY (or the + /// database-wide default) decides, and that is server-side state we cannot see from here - it could + /// be SUM - so we have to assume the worst. + /// + internal static CommandFlags ResolveCategory(TimeStamp timestamp, TsDuplicatePolicy? policy) + { + // "*" means the server assigns the timestamp, so each attempt appends a *new* sample + if (timestamp.IsStar) return CommandCategories.WriteAccumulating; + + return policy switch + { + // re-applying the same value at the same timestamp is a no-op under all of these + TsDuplicatePolicy.LAST or TsDuplicatePolicy.FIRST + or TsDuplicatePolicy.MIN or TsDuplicatePolicy.MAX => CommandCategories.WriteLastWins, + + // BLOCK deliberately stays out of WriteLastWins: it does not double-apply, but it *errors* on + // a duplicate, so replaying a write that actually succeeded (and whose reply was merely lost) + // would surface a spurious error rather than succeeding idempotently. That is worse for the + // caller than not retrying at all. + // + // SUM adds the value again, and null means the series' stored policy governs - which may be SUM. + _ => CommandCategories.WriteAccumulating, + }; } } diff --git a/src/NRedisStack/TimeSeries/DataTypes/TimeStamp.cs b/src/NRedisStack/TimeSeries/DataTypes/TimeStamp.cs index 34e8916a..3853ada4 100644 --- a/src/NRedisStack/TimeSeries/DataTypes/TimeStamp.cs +++ b/src/NRedisStack/TimeSeries/DataTypes/TimeStamp.cs @@ -39,6 +39,11 @@ private enum WellKnownTimestamp : byte _ => _value.ToString(), }; + /// + /// Whether this is the server-assigned "*" timestamp, rather than an explicit instant. + /// + internal bool IsStar => _constant == WellKnownTimestamp.Star; + private readonly WellKnownTimestamp _constant; private readonly long _value; diff --git a/src/NRedisStack/TimeSeries/TimeSeriesCommandsBuilder.cs b/src/NRedisStack/TimeSeries/TimeSeriesCommandsBuilder.cs index 074da601..753dba34 100644 --- a/src/NRedisStack/TimeSeries/TimeSeriesCommandsBuilder.cs +++ b/src/NRedisStack/TimeSeries/TimeSeriesCommandsBuilder.cs @@ -16,12 +16,12 @@ public static class TimeSeriesCommandsBuilder public static SerializedCommand Create(string key, long? retentionTime = null, IReadOnlyCollection? labels = null, bool? uncompressed = null, long? chunkSizeBytes = null, TsDuplicatePolicy? duplicatePolicy = null) { var parameters = new TsCreateParams(retentionTime, labels, uncompressed, chunkSizeBytes, duplicatePolicy); - return new(TS.CREATE, parameters.ToArray(key)); + return new(CommandCategories.WriteAccumulating, TS.CREATE, parameters.ToArray(key)); } public static SerializedCommand Create(string key, TsCreateParams parameters) { - return new(TS.CREATE, parameters.ToArray(key)); + return new(CommandCategories.WriteAccumulating, TS.CREATE, parameters.ToArray(key)); } #endregion @@ -31,12 +31,12 @@ public static SerializedCommand Create(string key, TsCreateParams parameters) public static SerializedCommand Alter(string key, long? retentionTime = null, long? chunkSizeBytes = null, TsDuplicatePolicy? duplicatePolicy = null, IReadOnlyCollection? labels = null) { var parameters = new TsAlterParams(retentionTime, chunkSizeBytes, duplicatePolicy, labels); - return new(TS.ALTER, parameters.ToArray(key)); + return new(CommandCategories.WriteLastWins, TS.ALTER, parameters.ToArray(key)); } public static SerializedCommand Alter(string key, TsAlterParams parameters) { - return new(TS.ALTER, parameters.ToArray(key)); + return new(CommandCategories.WriteLastWins, TS.ALTER, parameters.ToArray(key)); } [Obsolete()] @@ -45,48 +45,51 @@ public static SerializedCommand Add(string key, TimeStamp timestamp, double valu long? chunkSizeBytes = null, TsDuplicatePolicy? duplicatePolicy = null) { var parameters = new TsAddParams(timestamp, value, retentionTime, labels, uncompressed, chunkSizeBytes, duplicatePolicy); - return new(TS.ADD, parameters.ToArray(key)); + return new(parameters.Category, TS.ADD, parameters.ToArray(key)); } public static SerializedCommand Add(string key, TsAddParams parameters) { - return new(TS.ADD, parameters.ToArray(key)); + return new(parameters.Category, TS.ADD, parameters.ToArray(key)); } + // TS.MADD has no per-sample ON_DUPLICATE, so the effective duplicate policy is always whatever each + // series was created with - server-side state we cannot see, and possibly SUM. Unlike TS.ADD there is + // therefore no call shape we can narrow this to; see TsAddParams.ResolveCategory. public static SerializedCommand MAdd(IReadOnlyCollection<(string key, TimeStamp timestamp, double value)> sequence) { var args = TimeSeriesAux.BuildTsMaddArgs(sequence); - return new(TS.MADD, args); + return new(CommandCategories.WriteAccumulating, TS.MADD, args); } [Obsolete()] public static SerializedCommand IncrBy(string key, double value, TimeStamp? timestamp = null, long? retentionTime = null, IReadOnlyCollection? labels = null, bool? uncompressed = null, long? chunkSizeBytes = null) { var parameters = new TsIncrByParams(value, timestamp, retentionTime, labels, uncompressed, chunkSizeBytes); - return new(TS.INCRBY, parameters.ToArray(key)); + return new(CommandCategories.WriteAccumulating, TS.INCRBY, parameters.ToArray(key)); } public static SerializedCommand IncrBy(string key, TsIncrByParams parameters) { - return new(TS.INCRBY, parameters.ToArray(key)); + return new(CommandCategories.WriteAccumulating, TS.INCRBY, parameters.ToArray(key)); } [Obsolete()] public static SerializedCommand DecrBy(string key, double value, TimeStamp? timestamp = null, long? retentionTime = null, IReadOnlyCollection? labels = null, bool? uncompressed = null, long? chunkSizeBytes = null) { var parameters = new TsDecrByParams(value, timestamp, retentionTime, labels, uncompressed, chunkSizeBytes); - return new(TS.DECRBY, parameters.ToArray(key)); + return new(CommandCategories.WriteAccumulating, TS.DECRBY, parameters.ToArray(key)); } public static SerializedCommand DecrBy(string key, TsDecrByParams parameters) { - return new(TS.DECRBY, parameters.ToArray(key)); + return new(CommandCategories.WriteAccumulating, TS.DECRBY, parameters.ToArray(key)); } public static SerializedCommand Del(string key, TimeStamp fromTimeStamp, TimeStamp toTimeStamp) { var args = TimeSeriesAux.BuildTsDelArgs(key, fromTimeStamp, toTimeStamp); - return new(TS.DEL, args); + return new(CommandCategories.WriteLastWins, TS.DEL, args); } #endregion @@ -98,13 +101,13 @@ public static SerializedCommand CreateRule(string sourceKey, TimeSeriesRule rule var args = new List { (RedisKey)sourceKey }; args.AddRule(rule); args.Add(alignTimestamp); - return new(TS.CREATERULE, args); + return new(CommandCategories.WriteAccumulating, TS.CREATERULE, args); } public static SerializedCommand DeleteRule(string sourceKey, string destKey) { var args = new List { (RedisKey)sourceKey, (RedisKey)destKey }; - return new(TS.DELETERULE, args); + return new(CommandCategories.WriteAccumulating, TS.DELETERULE, args); } #endregion @@ -113,15 +116,15 @@ public static SerializedCommand DeleteRule(string sourceKey, string destKey) public static SerializedCommand Get(string key, bool latest = false) { - return (latest) ? new(TS.GET, (RedisKey)key, TimeSeriesArgs.LATEST) - : new SerializedCommand(TS.GET, (RedisKey)key); + return (latest) ? new(CommandCategories.ReadOnly, TS.GET, (RedisKey)key, TimeSeriesArgs.LATEST) + : new SerializedCommand(CommandCategories.ReadOnly, TS.GET, (RedisKey)key); } public static SerializedCommand MGet(IReadOnlyCollection filter, bool latest = false, bool? withLabels = null, IReadOnlyCollection? selectedLabels = null) { var args = TimeSeriesAux.BuildTsMgetArgs(latest, filter, withLabels, selectedLabels); - return new(TS.MGET, args); + return new(CommandCategories.ReadOnly, TS.MGET, args); } [OverloadResolutionPriority(1)] @@ -142,7 +145,7 @@ public static SerializedCommand Range(string key, latest, filterByTs, filterByValue, count, align, aggregation, timeBucket, bt, empty); - return new(TS.RANGE, args); + return new(CommandCategories.ReadOnly, TS.RANGE, args); } // retained for binary compatibility with 1.4.0-1.6.0 (filterByValue was (long, long)); de-prioritised @@ -201,7 +204,7 @@ public static SerializedCommand RevRange(string key, latest, filterByTs, filterByValue, count, align, aggregation, timeBucket, bt, empty); - return new(TS.REVRANGE, args); + return new(CommandCategories.ReadOnly, TS.REVRANGE, args); } // retained for binary compatibility with 1.4.0-1.6.0 (filterByValue was (long, long)); de-prioritised @@ -260,7 +263,7 @@ public static SerializedCommand MRange( { var args = TimeSeriesAux.BuildMultiRangeArgs(fromTimeStamp, toTimeStamp, filter, flags, filterByTs, filterByValue, selectLabels, count, align, aggregation, timeBucket, bt, groupbyTuple); - return new(TS.MRANGE, args); + return new(CommandCategories.ReadOnly, TS.MRANGE, args); } [OverloadResolutionPriority(1)] @@ -349,7 +352,7 @@ public static SerializedCommand MRevRange( { var args = TimeSeriesAux.BuildMultiRangeArgs(fromTimeStamp, toTimeStamp, filter, flags, filterByTs, filterByValue, selectLabels, count, align, aggregation, timeBucket, bt, groupbyTuple); - return new(TS.MREVRANGE, args); + return new(CommandCategories.ReadOnly, TS.MREVRANGE, args); } [OverloadResolutionPriority(1)] @@ -426,28 +429,28 @@ public static SerializedCommand MRevRange( public static SerializedCommand Info(string key, bool debug = false) { - return (debug) ? new(TS.INFO, (RedisKey)key, TimeSeriesArgs.DEBUG) - : new SerializedCommand(TS.INFO, (RedisKey)key); + return (debug) ? new(CommandCategories.ReadOnly, TS.INFO, (RedisKey)key, TimeSeriesArgs.DEBUG) + : new SerializedCommand(CommandCategories.ReadOnly, TS.INFO, (RedisKey)key); } public static SerializedCommand QueryIndex(IReadOnlyCollection filter) { var args = new List(filter); - return new(TS.QUERYINDEX, args); + return new(CommandCategories.ReadOnly, TS.QUERYINDEX, args); } public static SerializedCommand QueryLabelNames(IReadOnlyCollection? filter = null) { var args = new List { TimeSeriesArgs.LABELS }; AddQueryLabelsFilter(args, filter); - return new(TS.QUERYLABELS, args); + return new(CommandCategories.ReadOnly, TS.QUERYLABELS, args); } public static SerializedCommand QueryLabelValues(string label, IReadOnlyCollection? filter = null) { var args = new List { TimeSeriesArgs.VALUES, label }; AddQueryLabelsFilter(args, filter); - return new(TS.QUERYLABELS, args); + return new(CommandCategories.ReadOnly, TS.QUERYLABELS, args); } // FILTER is optional for TS.QUERYLABELS (omitting it queries all indexed series); an empty collection is @@ -476,7 +479,7 @@ public static SerializedCommand NRange( { var args = TimeSeriesAux.BuildNRangeArgs(keys, fromTimeStamp, toTimeStamp, flags, filterByTs, filterByValue, count, align, aggregations, timeBucket, bt); - return new(TS.NRANGE, args); + return new(CommandCategories.ReadOnly, TS.NRANGE, args); } public static SerializedCommand NRevRange( @@ -494,7 +497,7 @@ public static SerializedCommand NRevRange( { var args = TimeSeriesAux.BuildNRangeArgs(keys, fromTimeStamp, toTimeStamp, flags, filterByTs, filterByValue, count, align, aggregations, timeBucket, bt); - return new(TS.NREVRANGE, args); + return new(CommandCategories.ReadOnly, TS.NREVRANGE, args); } // Note: the server's BLOCK group is intentionally not exposed - blocking does not compose with the @@ -507,7 +510,7 @@ public static SerializedCommand Read(string key, TimeStamp timestamp, long? maxC args.Add(TimeSeriesArgs.MAX_COUNT); args.Add(maxCount.Value); } - return new(TS.READ, args); + return new(CommandCategories.ReadOnly, TS.READ, args); } #endregion diff --git a/src/NRedisStack/TimeSeries/TimeSeriesParamsBuilder.cs b/src/NRedisStack/TimeSeries/TimeSeriesParamsBuilder.cs index 0b732eee..291b1376 100644 --- a/src/NRedisStack/TimeSeries/TimeSeriesParamsBuilder.cs +++ b/src/NRedisStack/TimeSeries/TimeSeriesParamsBuilder.cs @@ -190,7 +190,7 @@ public TsAddParams build() args.AddUncompressed(uncompressed); args.AddOnDuplicate(duplicatePolicy); args.AddIgnoreValues(ignoreMaxTimeDiff, ignoreMaxValDiff); - return new(args); + return new(args, TsAddParams.ResolveCategory(timestamp.Value, duplicatePolicy)); } public new TsAddParamsBuilder AddValue(double value) => base.AddValue(value); diff --git a/src/NRedisStack/TopK/TopKCommandBuilder.cs b/src/NRedisStack/TopK/TopKCommandBuilder.cs index 2707950c..cd669269 100644 --- a/src/NRedisStack/TopK/TopKCommandBuilder.cs +++ b/src/NRedisStack/TopK/TopKCommandBuilder.cs @@ -12,7 +12,7 @@ public static SerializedCommand Add(RedisKey key, params RedisValue[] items) throw new ArgumentOutOfRangeException(nameof(items)); var args = Auxiliary.MergeArgs(key, items); - return new(TOPK.ADD, args); + return new(CommandCategories.WriteAccumulating, TOPK.ADD, args); } public static SerializedCommand Count(RedisKey key, params RedisValue[] items) @@ -21,7 +21,7 @@ public static SerializedCommand Count(RedisKey key, params RedisValue[] items) throw new ArgumentOutOfRangeException(nameof(items)); var args = Auxiliary.MergeArgs(key, items); - return new(TOPK.COUNT, args); + return new(CommandCategories.ReadOnly, TOPK.COUNT, args); } public static SerializedCommand IncrBy(RedisKey key, params Tuple[] itemIncrements) @@ -35,18 +35,18 @@ public static SerializedCommand IncrBy(RedisKey key, params Tupleruntime; build; native; contentfiles; analyzers; buildtransitive all - + + diff --git a/tests/NRedisStack.Tests/CommandCategoryTests.cs b/tests/NRedisStack.Tests/CommandCategoryTests.cs new file mode 100644 index 00000000..40315b26 --- /dev/null +++ b/tests/NRedisStack.Tests/CommandCategoryTests.cs @@ -0,0 +1,216 @@ +using NRedisStack.DataTypes; +using NRedisStack.Literals.Enums; +using NRedisStack.RedisStackCommands; +using NRedisStack.Search; +using StackExchange.Redis; +using Xunit; + +namespace NRedisStack.Tests; + +/// +/// Unit tests (no server) for the command side-effect categories that let NRedisStack commands take part in +/// StackExchange.Redis' WithRetry. +/// +public class CommandCategoryTests +{ + // NRedisStack keeps a StackExchange.Redis 3.0.x floor, so CommandCategories declares these as casts + // rather than referencing the (3.1.0+) named members. That only stays correct while the numbers agree, + // and a silent renumbering upstream would silently re-categorize every command we issue - so pin it. + // The test project deliberately overrides to 3.1.0 so the named members are available here. + [Theory] + [InlineData(CommandCategories.Always, CommandFlags.CommandRetryAlways)] + [InlineData(CommandCategories.Connection, CommandFlags.CommandRetryConnection)] + [InlineData(CommandCategories.ReadOnly, CommandFlags.CommandRetryReadOnly)] + [InlineData(CommandCategories.WriteChecked, CommandFlags.CommandRetryWriteChecked)] + [InlineData(CommandCategories.WriteLastWins, CommandFlags.CommandRetryWriteLastWins)] + [InlineData(CommandCategories.WriteAccumulating, CommandFlags.CommandRetryWriteAccumulating)] + [InlineData(CommandCategories.ServerAdmin, CommandFlags.CommandRetryServerAdmin)] + [InlineData(CommandCategories.Never, CommandFlags.CommandRetryNever)] + public void CategoryMatchesStackExchangeRedis(CommandFlags ours, CommandFlags theirs) + => Assert.Equal(theirs, ours); + + // Message.CommandServerSpecific is internal to StackExchange.Redis, so there is no named member to + // compare against; assert the bit position instead, which is what their mask actually tests. + [Fact] + public void ServerSpecificIsBit18() => Assert.Equal(1 << 18, (int)CommandCategories.ServerSpecific); + + [Fact] + public void MaskCoversTheCategoryRegionAndServerSpecificOnly() + => Assert.Equal((31 << 13) | (1 << 18), (int)CommandCategories.Mask); + + // the category parameter is typed as CommandFlags for convenience, but it is not a general-purpose + // flags channel: anything outside the category region is dropped rather than silently altering routing + [Theory] + [InlineData(CommandFlags.FireAndForget)] + [InlineData(CommandFlags.DemandReplica)] + [InlineData(CommandFlags.NoScriptCache)] + public void UnrelatedFlagsCannotLeakThroughTheCategory(CommandFlags smuggled) + { + var command = new SerializedCommand(CommandCategories.ReadOnly | smuggled, "FT.SEARCH", "idx", "*"); + Assert.Equal(CommandCategories.ReadOnly, command.CommandCategory); + } + + // a category that names no rung is almost certainly a mistake, and silently accepting it would mean + // "never retry" dressed up as a declaration + [Theory] + [InlineData(CommandFlags.None)] + [InlineData(CommandFlags.FireAndForget)] // real flag, wrong parameter + [InlineData(CommandFlags.DemandReplica)] + [InlineData(CommandCategories.ServerSpecific)] // sticky bit, but no rung + public void ConstructingWithNoCategoryThrows(CommandFlags category) + { + Assert.Throws( + () => new SerializedCommand(category, "FT.SEARCH", "idx", "*")); + Assert.Throws( + () => new SerializedCommand(category, "FT.SEARCH", new List { "idx", "*" })); + } + + [Fact] + public void ARungPlusServerSpecificIsAccepted() + { + var command = new SerializedCommand( + CommandCategories.ReadOnly | CommandCategories.ServerSpecific, "BF.SCANDUMP", "k", 0); + Assert.Equal(CommandCategories.ReadOnly | CommandCategories.ServerSpecific, command.CommandCategory); + } + + // the obsolete ctors must keep working and keep meaning "uncategorized", or every existing caller + // would go from a compile-time warning to a runtime exception + [Fact] + public void UncategorizedCommandsReportNone() + { +#pragma warning disable CS0618 // exercising the obsolete, uncategorized ctor is the point + var command = new SerializedCommand("FT.SEARCH", "idx", "*"); +#pragma warning restore CS0618 + Assert.Equal(CommandFlags.None, command.CommandCategory); + } + + // Pins the categorizations that are argument-dependent, i.e. the ones a reader is most likely to get + // wrong when editing the builders. The uniform cases are enforced by the compiler instead: the + // uncategorized SerializedCommand ctors are [Obsolete] and CS0618 is an error inside the library. + public static TheoryData ArgumentDependentCases() => new() + { + // JSON.SET is a blind overwrite unless NX/XX make it conditional + { "JSON.SET", CommandCategories.WriteLastWins, JsonCommandBuilder.Set("k", "$", "1") }, + { "JSON.SET NX", CommandCategories.WriteChecked, JsonCommandBuilder.Set("k", "$", "1", When.NotExists, JsonNumericArrayStorage.NotSpecified) }, + { "JSON.SET XX", CommandCategories.WriteChecked, JsonCommandBuilder.Set("k", "$", "1", When.Exists, JsonNumericArrayStorage.NotSpecified) }, + + // FT.SUGADD INCR adds to the existing score; without it the score is simply set + { "FT.SUGADD", CommandCategories.WriteLastWins, SearchCommandBuilder.SugAdd("k", "s", 1d) }, + { "FT.SUGADD INCR", CommandCategories.WriteAccumulating, SearchCommandBuilder.SugAdd("k", "s", 1d, increment: true) }, + + // A cursored aggregate allocates cursor state, so every replay leaks another cursor. Never rather + // than a high rung: ServerSpecific only stops failover, and any rung below Never can be re-enabled + // by a caller raising MaxCommandRetryCategory. + { "FT.AGGREGATE", CommandCategories.ReadOnly, SearchCommandBuilder.Aggregate("idx", new AggregationRequest("*")) }, + { + "FT.AGGREGATE WITHCURSOR", + CommandCategories.Never, + SearchCommandBuilder.Aggregate("idx", new AggregationRequest("*").Cursor(10)) + }, + { "FT.PROFILE SEARCH", CommandCategories.ReadOnly, SearchCommandBuilder.ProfileSearch("idx", new Query("*")) }, + { + "FT.PROFILE AGGREGATE", + CommandCategories.ReadOnly, + SearchCommandBuilder.ProfileAggregate("idx", new AggregationRequest("*")) + }, + { + // must match FT.AGGREGATE WITHCURSOR: profiling still allocates the cursor + "FT.PROFILE AGGREGATE WITHCURSOR", + CommandCategories.Never, + SearchCommandBuilder.ProfileAggregate("idx", new AggregationRequest("*").Cursor(10)) + }, + + // reading a cursor advances it, so a replay skips a page rather than repeating one + { + "FT.CURSOR READ", + CommandCategories.Never, + SearchCommandBuilder.CursorRead("idx", 1) + }, + { + "FT.CURSOR READ COUNT", + CommandCategories.Never, + SearchCommandBuilder.CursorRead("idx", 1, 10) + }, + { + // RediSearch errors on a repeat delete rather than treating it as an OK no-op, so a replay + // reports failure for cleanup that succeeded + "FT.CURSOR DEL", + CommandCategories.Never, + SearchCommandBuilder.CursorDel("idx", 1) + }, + + // Module DDL leaves state correct on replay but *errors* ("Index already exists", "Index not + // found"), so it must sit above the default ceiling or a lost reply gets replayed by default and + // reports failure for something that succeeded. Verified against a live server; see + // CommandCategories.WriteAccumulating. + { + "FT.CREATE", + CommandCategories.WriteAccumulating, + SearchCommandBuilder.Create("idx", new FTCreateParams(), new Schema().AddTextField("n")) + }, + { "FT.DROPINDEX", CommandCategories.WriteAccumulating, SearchCommandBuilder.DropIndex("idx") }, + { "FT.ALIASADD", CommandCategories.WriteAccumulating, SearchCommandBuilder.AliasAdd("al", "idx") }, + { "FT.ALIASDEL", CommandCategories.WriteAccumulating, SearchCommandBuilder.AliasDel("al") }, + { "BF.RESERVE", CommandCategories.WriteAccumulating, BloomCommandBuilder.Reserve("k", 0.01, 100) }, + { "TS.CREATE", CommandCategories.WriteAccumulating, TimeSeriesCommandsBuilder.Create("k", new TsCreateParamsBuilder().build()) }, + + // by contrast, the true SETNX analogues return 0 rather than erroring, so they stay put + { "BF.ADD", CommandCategories.WriteChecked, BloomCommandBuilder.Add("k", "item") }, + { "CF.ADDNX", CommandCategories.WriteChecked, CuckooCommandBuilder.AddNX("k", "item") }, + + // ...and the deletes that report 0 rather than erroring stay idempotent overwrites + { "FT.DICTDEL", CommandCategories.WriteLastWins, SearchCommandBuilder.DictDel("d", "term") }, + { "FT.SUGDEL", CommandCategories.WriteLastWins, SearchCommandBuilder.SugDel("k", "s") }, + + // TS.ADD: only an explicit, non-SUM ON_DUPLICATE makes a replay provably idempotent + { "TS.ADD ON_DUPLICATE LAST", CommandCategories.WriteLastWins, TsAdd(1L, TsDuplicatePolicy.LAST) }, + { "TS.ADD ON_DUPLICATE FIRST", CommandCategories.WriteLastWins, TsAdd(1L, TsDuplicatePolicy.FIRST) }, + { "TS.ADD ON_DUPLICATE MIN", CommandCategories.WriteLastWins, TsAdd(1L, TsDuplicatePolicy.MIN) }, + { "TS.ADD ON_DUPLICATE MAX", CommandCategories.WriteLastWins, TsAdd(1L, TsDuplicatePolicy.MAX) }, + + // SUM adds the value again + { "TS.ADD ON_DUPLICATE SUM", CommandCategories.WriteAccumulating, TsAdd(1L, TsDuplicatePolicy.SUM) }, + + // BLOCK errors on a duplicate, so replaying a write whose reply was merely lost would surface a + // spurious error rather than succeeding idempotently - worse for the caller than not retrying + { "TS.ADD ON_DUPLICATE BLOCK", CommandCategories.WriteAccumulating, TsAdd(1L, TsDuplicatePolicy.BLOCK) }, + + // no ON_DUPLICATE: the series' stored DUPLICATE_POLICY governs, and it may be SUM + { "TS.ADD no ON_DUPLICATE", CommandCategories.WriteAccumulating, TsAdd(1L, policy: null) }, + + // "*" appends a new sample on every attempt, whatever the policy says + { "TS.ADD * ON_DUPLICATE LAST", CommandCategories.WriteAccumulating, TsAdd("*", TsDuplicatePolicy.LAST) }, + + // TS.MADD has no per-sample ON_DUPLICATE, so it can never be narrowed + { + "TS.MADD", + CommandCategories.WriteAccumulating, + TimeSeriesCommandsBuilder.MAdd([("k", new TimeStamp(1L), 1d)]) + }, + }; + + // goes via TsAddParamsBuilder, i.e. the path that flattens to an argument list before the command + // builder sees it - the reason the category has to be resolved and carried on TsAddParams + private static SerializedCommand TsAdd(TimeStamp timestamp, TsDuplicatePolicy? policy) + { + var builder = new TsAddParamsBuilder().AddTimestamp(timestamp).AddValue(1d); + if (policy is { } p) builder = builder.AddOnDuplicate(p); + return TimeSeriesCommandsBuilder.Add("k", builder.build()); + } + + [Theory] + [MemberData(nameof(ArgumentDependentCases))] + public void ArgumentDependentCategoriesAreCorrect(string description, CommandFlags expected, SerializedCommand command) + { + _ = description; // present so a failure names the case + Assert.Equal(expected, command.CommandCategory); + } + + [Fact] + public void EffectiveFlagsIncludeTheCategory() + { + var command = SearchCommandBuilder.Search("idx", new Query("*")); + Assert.Equal(CommandCategories.ReadOnly, command.CommandCategory); + Assert.Equal(CommandCategories.ReadOnly, command.EffectiveFlags & CommandCategories.Mask); + } +} diff --git a/tests/NRedisStack.Tests/Core Commands/CoreTests.cs b/tests/NRedisStack.Tests/Core Commands/CoreTests.cs index 1e6cf8aa..8e8cbf1e 100644 --- a/tests/NRedisStack.Tests/Core Commands/CoreTests.cs +++ b/tests/NRedisStack.Tests/Core Commands/CoreTests.cs @@ -44,7 +44,7 @@ public void TestSetInfoDefaultValue(string endpointId) ResetInfoDefaults(); // demonstrate first connection IDatabase db = GetCleanDatabase(endpointId); - db.Execute(new SerializedCommand("PING")); // only the extension method of Execute (which is used for all the commands of Redis Stack) will set the library name and version. + db.Execute(new SerializedCommand(CommandCategories.Always, "PING")); // only the extension method of Execute (which is used for all the commands of Redis Stack) will set the library name and version. var info = db.Execute("CLIENT", "INFO").ToString(); Assert.Contains($"lib-name=NRedisStack(.NET_v{Environment.Version}) lib-ver={GetNRedisStackVersion()}", info); @@ -57,7 +57,7 @@ public async Task TestSetInfoDefaultValueAsync(string endpointId) ResetInfoDefaults(); // demonstrate first connection IDatabase db = GetCleanDatabase(endpointId); - await db.ExecuteAsync(new SerializedCommand("PING")); // only the extension method of Execute (which is used for all the commands of Redis Stack) will set the library name and version. + await db.ExecuteAsync(new SerializedCommand(CommandCategories.Always, "PING")); // only the extension method of Execute (which is used for all the commands of Redis Stack) will set the library name and version. var info = (await db.ExecuteAsync("CLIENT", "INFO")).ToString(); Assert.Contains($"lib-name=NRedisStack(.NET_v{Environment.Version}) lib-ver={GetNRedisStackVersion()}", info); @@ -70,7 +70,7 @@ public void TestSetInfoWithValue(string endpointId) ResetInfoDefaults(); // demonstrate first connection var db = GetConnection(endpointId).GetDatabase("MyLibraryName;v1.0.0"); - db.Execute(new SerializedCommand("PING")); // only the extension method of Execute (which is used for all the commands of Redis Stack) will set the library name and version. + db.Execute(new SerializedCommand(CommandCategories.Always, "PING")); // only the extension method of Execute (which is used for all the commands of Redis Stack) will set the library name and version. var info = db.Execute("CLIENT", "INFO").ToString(); Assert.Contains($"NRedisStack(MyLibraryName;v1.0.0;.NET_v{Environment.Version}) lib-ver={GetNRedisStackVersion()}", info); @@ -83,7 +83,7 @@ public async Task TestSetInfoWithValueAsync(string endpointId) ResetInfoDefaults(); // demonstrate first connection var db = GetConnection(endpointId).GetDatabase("MyLibraryName;v1.0.0"); - await db.ExecuteAsync(new SerializedCommand("PING")); // only the extension method of Execute (which is used for all the commands of Redis Stack) will set the library name and version. + await db.ExecuteAsync(new SerializedCommand(CommandCategories.Always, "PING")); // only the extension method of Execute (which is used for all the commands of Redis Stack) will set the library name and version. var info = (await db.ExecuteAsync("CLIENT", "INFO")).ToString(); Assert.Contains($"NRedisStack(MyLibraryName;v1.0.0;.NET_v{Environment.Version}) lib-ver={GetNRedisStackVersion()}", info); @@ -97,7 +97,7 @@ public void TestSetInfoNull(string endpointId) var db = GetConnection(endpointId).GetDatabase(null); var infoBefore = db.Execute("CLIENT", "INFO").ToString(); - db.Execute(new SerializedCommand("PING")); // only the extension method of Execute (which is used for all the commands of Redis Stack) will set the library name and version. + db.Execute(new SerializedCommand(CommandCategories.Always, "PING")); // only the extension method of Execute (which is used for all the commands of Redis Stack) will set the library name and version. var infoAfter = db.Execute("CLIENT", "INFO").ToString(); // Find the indices of "lib-name=" in the strings @@ -125,7 +125,7 @@ public async Task TestSetInfoNullAsync(string endpointId) var db = GetConnection(endpointId).GetDatabase(null); var infoBefore = (await db.ExecuteAsync("CLIENT", "INFO")).ToString(); - await db.ExecuteAsync(new SerializedCommand("PING")); // only the extension method of Execute (which is used for all the commands of Redis Stack) will set the library name and version. + await db.ExecuteAsync(new SerializedCommand(CommandCategories.Always, "PING")); // only the extension method of Execute (which is used for all the commands of Redis Stack) will set the library name and version. var infoAfter = (await db.ExecuteAsync("CLIENT", "INFO")).ToString(); // Find the indices of "lib-name=" in the strings diff --git a/tests/NRedisStack.Tests/NRedisStack.Tests.csproj b/tests/NRedisStack.Tests/NRedisStack.Tests.csproj index c2db6c90..be03fb14 100644 --- a/tests/NRedisStack.Tests/NRedisStack.Tests.csproj +++ b/tests/NRedisStack.Tests/NRedisStack.Tests.csproj @@ -9,6 +9,10 @@ instead of the current (double, double) API, the build fails. Existing tests that deliberately exercise obsolete members opt in per-file with `#pragma warning disable CS0612, CS0618`. --> $(WarningsAsErrors);CS0612;CS0618 + + + $(NoWarn);SER007 @@ -32,7 +36,10 @@ runtime; build; native; contentfiles; analyzers; buildtransitive all - + + diff --git a/tests/NRedisStack.Tests/TimeSeries/TestAPI/TimeSeriesHelper.cs b/tests/NRedisStack.Tests/TimeSeries/TestAPI/TimeSeriesHelper.cs index 97ba38b4..b6823f43 100644 --- a/tests/NRedisStack.Tests/TimeSeries/TestAPI/TimeSeriesHelper.cs +++ b/tests/NRedisStack.Tests/TimeSeries/TestAPI/TimeSeriesHelper.cs @@ -7,7 +7,7 @@ public class TimeSeriesHelper { public static RedisResult getInfo(IDatabase db, string key, out int j, out int k) { - var cmd = new SerializedCommand("TS.INFO", (RedisKey)key); + var cmd = new SerializedCommand(CommandCategories.ReadOnly, "TS.INFO", (RedisKey)key); RedisResult info = db.Execute(cmd); j = -1;