From 33de0eb4f0ade56e0061bb75608d9d4510a77bbf Mon Sep 17 00:00:00 2001 From: Ivan Tikhonov Date: Mon, 31 Aug 2026 16:51:26 +0300 Subject: [PATCH 01/17] Lua memory (#6) * add RedisKeyOrValue * add operators and override to RedisKeyOrValue * add ThrowInvalidCast * add IScriptRequestDisposer * ScriptEvaluateMemory * ScriptEvaluateMemoryReadOnlyAsync * ScriptEvalMemoryMessage * fix bug ScriptUnavailable ScriptEvaluateReadOnlyAsync * add Lease ScriptEvaluateMemory * add Prefixed * FromKey FromValue * undo Disposer --- .../Interfaces/IDatabase.cs | 56 +++++ .../Interfaces/IDatabaseAsync.cs | 12 + .../KeyspaceIsolation/KeyPrefixed.cs | 38 +++ .../KeyspaceIsolation/KeyPrefixedDatabase.cs | 16 ++ .../PublicAPI/PublicAPI.Unshipped.txt | 31 +++ src/StackExchange.Redis/RedisDatabase.cs | 227 ++++++++++++++++- src/StackExchange.Redis/RedisKeyOrValue.cs | 232 ++++++++++++++++++ .../ResultProcessor.Lease.cs | 3 + 8 files changed, 613 insertions(+), 2 deletions(-) create mode 100644 src/StackExchange.Redis/RedisKeyOrValue.cs diff --git a/src/StackExchange.Redis/Interfaces/IDatabase.cs b/src/StackExchange.Redis/Interfaces/IDatabase.cs index 96ab6142e..681a434ed 100644 --- a/src/StackExchange.Redis/Interfaces/IDatabase.cs +++ b/src/StackExchange.Redis/Interfaces/IDatabase.cs @@ -1627,6 +1627,34 @@ public partial interface IDatabase : IRedis, IDatabaseAsync /// This API should be considered an advanced feature; inappropriate use can be harmful. RedisResult Execute(string command, ICollection args, CommandFlags flags = CommandFlags.None); + /// + /// Execute a Lua script against the server. + /// + /// The script to execute. + /// The args to execute against. + /// The flags to use for this operation. + /// A dynamic representation of the script's result. + /// + /// See + /// , + /// . + /// + RedisResult ScriptEvaluateMemory(string script, ReadOnlyMemory args, CommandFlags flags = CommandFlags.None); + + /// + /// Execute a Lua script against the server. + /// + /// The script to execute. + /// The args to execute against. + /// The flags to use for this operation. + /// A dynamic representation of the script's scalar result. + /// + /// See + /// , + /// . + /// + Lease? ScriptEvaluateMemoryLease(string script, ReadOnlyMemory args, CommandFlags flags = CommandFlags.None); + /// /// Execute a Lua script against the server. /// @@ -1680,6 +1708,34 @@ public partial interface IDatabase : IRedis, IDatabaseAsync /// RedisResult ScriptEvaluate(LoadedLuaScript script, object? parameters = null, CommandFlags flags = CommandFlags.None); + /// + /// Read-only variant of the EVAL command that cannot execute commands that modify data, Execute a Lua script against the server. + /// + /// The script to execute. + /// The args to execute against. + /// The flags to use for this operation. + /// A dynamic representation of the script's result. + /// + /// See + /// , + /// . + /// + RedisResult ScriptEvaluateMemoryReadOnly(string script, ReadOnlyMemory args, CommandFlags flags = CommandFlags.None); + + /// + /// Read-only variant of the EVAL command that cannot execute commands that modify data, Execute a Lua script against the server. + /// + /// The script to execute. + /// The args to execute against. + /// The flags to use for this operation. + /// A dynamic representation of the script's result. + /// + /// See + /// , + /// . + /// + Lease? ScriptEvaluateMemoryReadOnlyLease(string script, ReadOnlyMemory args, CommandFlags flags = CommandFlags.None); + /// /// Read-only variant of the EVAL command that cannot execute commands that modify data, Execute a Lua script against the server. /// diff --git a/src/StackExchange.Redis/Interfaces/IDatabaseAsync.cs b/src/StackExchange.Redis/Interfaces/IDatabaseAsync.cs index b172375ab..391f1b199 100644 --- a/src/StackExchange.Redis/Interfaces/IDatabaseAsync.cs +++ b/src/StackExchange.Redis/Interfaces/IDatabaseAsync.cs @@ -402,6 +402,12 @@ public partial interface IDatabaseAsync : IRedisAsync /// Task ExecuteAsync(string command, ICollection? args, CommandFlags flags = CommandFlags.None); + /// + Task ScriptEvaluateMemoryAsync(string script, ReadOnlyMemory args, CommandFlags flags = CommandFlags.None); + + /// + Task?> ScriptEvaluateMemoryLeaseAsync(string script, ReadOnlyMemory args, CommandFlags flags = CommandFlags.None); + /// Task ScriptEvaluateAsync(string script, RedisKey[]? keys = null, RedisValue[]? values = null, CommandFlags flags = CommandFlags.None); @@ -415,6 +421,12 @@ public partial interface IDatabaseAsync : IRedisAsync /// Task ScriptEvaluateAsync(LoadedLuaScript script, object? parameters = null, CommandFlags flags = CommandFlags.None); + /// + Task ScriptEvaluateMemoryReadOnlyAsync(string script, ReadOnlyMemory args, CommandFlags flags = CommandFlags.None); + + /// + Task?> ScriptEvaluateMemoryReadOnlyLeaseAsync(string script, ReadOnlyMemory args, CommandFlags flags = CommandFlags.None); + /// Task ScriptEvaluateReadOnlyAsync(string script, RedisKey[]? keys = null, RedisValue[]? values = null, CommandFlags flags = CommandFlags.None); diff --git a/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixed.cs b/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixed.cs index 5dee9cda3..ae8e758f7 100644 --- a/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixed.cs +++ b/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixed.cs @@ -419,6 +419,14 @@ public Task ScriptEvaluateAsync(byte[] hash, RedisKey[]? keys = nul // TODO: The return value could contain prefixed keys. It might make sense to 'unprefix' those? Inner.ScriptEvaluateAsync(hash, ToInner(keys), values, flags); + public Task ScriptEvaluateMemoryAsync(string script, ReadOnlyMemory args, CommandFlags flags = CommandFlags.None) => + // TODO: rented args? + Inner.ScriptEvaluateMemoryAsync(script, ToInner(args), flags); + + public Task?> ScriptEvaluateMemoryLeaseAsync(string script, ReadOnlyMemory args, CommandFlags flags = CommandFlags.None) => + // TODO: rented args? + Inner.ScriptEvaluateMemoryLeaseAsync(script, ToInner(args), flags); + public Task ScriptEvaluateAsync(string script, RedisKey[]? keys = null, RedisValue[]? values = null, CommandFlags flags = CommandFlags.None) => // TODO: The return value could contain prefixed keys. It might make sense to 'unprefix' those? Inner.ScriptEvaluateAsync(script: script, keys: ToInner(keys), values: values, flags: flags); @@ -435,6 +443,14 @@ public Task ScriptEvaluateReadOnlyAsync(byte[] hash, RedisKey[]? ke // TODO: The return value could contain prefixed keys. It might make sense to 'unprefix' those? Inner.ScriptEvaluateAsync(hash, ToInner(keys), values, flags); + public Task ScriptEvaluateMemoryReadOnlyAsync(string script, ReadOnlyMemory args, CommandFlags flags = CommandFlags.None) => + // TODO: rented args? + Inner.ScriptEvaluateMemoryReadOnlyAsync(script, ToInner(args), flags); + + public Task?> ScriptEvaluateMemoryReadOnlyLeaseAsync(string script, ReadOnlyMemory args, CommandFlags flags = CommandFlags.None) => + // TODO: rented args? + Inner.ScriptEvaluateMemoryReadOnlyLeaseAsync(script, ToInner(args), flags); + public Task ScriptEvaluateReadOnlyAsync(string script, RedisKey[]? keys = null, RedisValue[]? values = null, CommandFlags flags = CommandFlags.None) => // TODO: The return value could contain prefixed keys. It might make sense to 'unprefix' those? Inner.ScriptEvaluateAsync(script: script, keys: ToInner(keys), values: values, flags: flags); @@ -947,6 +963,28 @@ protected RedisKey ToInnerOrDefault(RedisKey outer) => return args; } + protected ReadOnlyMemory ToInner(ReadOnlyMemory outer) + { + if (outer.Length == 0) + { + return outer; + } + else + { + // TODO: add rent + RedisKeyOrValue[] inner = new RedisKeyOrValue[outer.Length]; + + var i = 0; + foreach (ref readonly var item in outer.Span) + { + var key = item.Key; + inner[i++] = key.IsNull ? item : ToInner(key); + } + + return inner; + } + } + [return: NotNullIfNotNull("outer")] protected RedisKey[]? ToInner(RedisKey[]? outer) { diff --git a/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixedDatabase.cs b/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixedDatabase.cs index fa7c43849..9403bc2a3 100644 --- a/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixedDatabase.cs +++ b/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixedDatabase.cs @@ -391,6 +391,14 @@ public RedisResult ScriptEvaluate(byte[] hash, RedisKey[]? keys = null, RedisVal // TODO: The return value could contain prefixed keys. It might make sense to 'unprefix' those? Inner.ScriptEvaluate(hash, ToInner(keys), values, flags); + public RedisResult ScriptEvaluateMemory(string script, ReadOnlyMemory args, CommandFlags flags = CommandFlags.None) => + // TODO: rented args? + Inner.ScriptEvaluateMemory(script, ToInner(args), flags); + + public Lease? ScriptEvaluateMemoryLease(string script, ReadOnlyMemory args, CommandFlags flags = CommandFlags.None) => + // TODO: rented args? + Inner.ScriptEvaluateMemoryLease(script, ToInner(args), flags); + public RedisResult ScriptEvaluate(string script, RedisKey[]? keys = null, RedisValue[]? values = null, CommandFlags flags = CommandFlags.None) => // TODO: The return value could contain prefixed keys. It might make sense to 'unprefix' those? Inner.ScriptEvaluate(script: script, keys: ToInner(keys), values: values, flags: flags); @@ -407,6 +415,14 @@ public RedisResult ScriptEvaluateReadOnly(byte[] hash, RedisKey[]? keys = null, // TODO: The return value could contain prefixed keys. It might make sense to 'unprefix' those? Inner.ScriptEvaluateReadOnly(hash, ToInner(keys), values, flags); + public RedisResult ScriptEvaluateMemoryReadOnly(string script, ReadOnlyMemory args, CommandFlags flags = CommandFlags.None) => + // TODO: rented args? + Inner.ScriptEvaluateMemoryReadOnly(script, ToInner(args), flags); + + public Lease? ScriptEvaluateMemoryReadOnlyLease(string script, ReadOnlyMemory args, CommandFlags flags = CommandFlags.None) => + // TODO: rented args? + Inner.ScriptEvaluateMemoryReadOnlyLease(script, ToInner(args), flags); + public RedisResult ScriptEvaluateReadOnly(string script, RedisKey[]? keys = null, RedisValue[]? values = null, CommandFlags flags = CommandFlags.None) => // TODO: The return value could contain prefixed keys. It might make sense to 'unprefix' those? Inner.ScriptEvaluateReadOnly(script, ToInner(keys), values, flags); diff --git a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt index 419c74dc2..26cbd3a04 100644 --- a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt @@ -23,6 +23,37 @@ StackExchange.Redis.ClusterSlotAssignment.Primary.get -> StackExchange.Redis.Clu StackExchange.Redis.ClusterSlotAssignment.Replicas.get -> System.Collections.Generic.IReadOnlyList! StackExchange.Redis.ClusterSlotAssignment.Slots.get -> StackExchange.Redis.SlotRange StackExchange.Redis.ClusterSlotNode +StackExchange.Redis.IDatabase.ScriptEvaluateMemory(string! script, System.ReadOnlyMemory args, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> StackExchange.Redis.RedisResult! +StackExchange.Redis.IDatabaseAsync.ScriptEvaluateMemoryAsync(string! script, System.ReadOnlyMemory args, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.Task! +StackExchange.Redis.IDatabase.ScriptEvaluateMemoryReadOnly(string! script, System.ReadOnlyMemory args, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> StackExchange.Redis.RedisResult! +StackExchange.Redis.IDatabaseAsync.ScriptEvaluateMemoryReadOnlyAsync(string! script, System.ReadOnlyMemory args, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.Task! +StackExchange.Redis.IDatabase.ScriptEvaluateMemoryLease(string! script, System.ReadOnlyMemory args, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> StackExchange.Redis.Lease? +StackExchange.Redis.IDatabaseAsync.ScriptEvaluateMemoryLeaseAsync(string! script, System.ReadOnlyMemory args, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.Task?>! +StackExchange.Redis.IDatabase.ScriptEvaluateMemoryReadOnlyLease(string! script, System.ReadOnlyMemory args, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> StackExchange.Redis.Lease? +StackExchange.Redis.IDatabaseAsync.ScriptEvaluateMemoryReadOnlyLeaseAsync(string! script, System.ReadOnlyMemory args, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.Task?>! +StackExchange.Redis.RedisKeyOrValue +StackExchange.Redis.RedisKeyOrValue.RedisKeyOrValue() -> void +StackExchange.Redis.RedisKeyOrValue.RedisKeyOrValue(in StackExchange.Redis.RedisKey key) -> void +StackExchange.Redis.RedisKeyOrValue.RedisKeyOrValue(in StackExchange.Redis.RedisValue value) -> void +StackExchange.Redis.RedisKeyOrValue.IsNull.get -> bool +StackExchange.Redis.RedisKeyOrValue.IsKey.get -> bool +StackExchange.Redis.RedisKeyOrValue.Key.get -> StackExchange.Redis.RedisKey +StackExchange.Redis.RedisKeyOrValue.IsValue.get -> bool +StackExchange.Redis.RedisKeyOrValue.Value.get -> StackExchange.Redis.RedisValue +StackExchange.Redis.RedisKeyOrValue.Equals(StackExchange.Redis.RedisKeyOrValue other) -> bool +StackExchange.Redis.RedisKeyOrValue.Equals(StackExchange.Redis.RedisKey other) -> bool +StackExchange.Redis.RedisKeyOrValue.Equals(StackExchange.Redis.RedisValue other) -> bool +override StackExchange.Redis.RedisKeyOrValue.GetHashCode() -> int +override StackExchange.Redis.RedisKeyOrValue.Equals(object? obj) -> bool +override StackExchange.Redis.RedisKeyOrValue.ToString() -> string! +static StackExchange.Redis.RedisKeyOrValue.FromKey(StackExchange.Redis.RedisKey key) -> StackExchange.Redis.RedisKeyOrValue +static StackExchange.Redis.RedisKeyOrValue.FromValue(StackExchange.Redis.RedisValue value) -> StackExchange.Redis.RedisKeyOrValue +static StackExchange.Redis.RedisKeyOrValue.operator ==(StackExchange.Redis.RedisKeyOrValue x, StackExchange.Redis.RedisKeyOrValue y) -> bool +static StackExchange.Redis.RedisKeyOrValue.operator !=(StackExchange.Redis.RedisKeyOrValue x, StackExchange.Redis.RedisKeyOrValue y) -> bool +static StackExchange.Redis.RedisKeyOrValue.implicit operator StackExchange.Redis.RedisKeyOrValue(StackExchange.Redis.RedisKey key) -> StackExchange.Redis.RedisKeyOrValue +static StackExchange.Redis.RedisKeyOrValue.implicit operator StackExchange.Redis.RedisKeyOrValue(StackExchange.Redis.RedisValue value) -> StackExchange.Redis.RedisKeyOrValue +static StackExchange.Redis.RedisKeyOrValue.explicit operator StackExchange.Redis.RedisKey(StackExchange.Redis.RedisKeyOrValue value) -> StackExchange.Redis.RedisKey +static StackExchange.Redis.RedisKeyOrValue.explicit operator StackExchange.Redis.RedisValue(StackExchange.Redis.RedisKeyOrValue value) -> StackExchange.Redis.RedisValue StackExchange.Redis.ClusterSlotNode.AnnouncedEndpoint.get -> string? StackExchange.Redis.ClusterSlotNode.EndPoint.get -> System.Net.EndPoint? StackExchange.Redis.ClusterSlotNode.Hostname.get -> string? diff --git a/src/StackExchange.Redis/RedisDatabase.cs b/src/StackExchange.Redis/RedisDatabase.cs index 011c38c80..644bd57ea 100644 --- a/src/StackExchange.Redis/RedisDatabase.cs +++ b/src/StackExchange.Redis/RedisDatabase.cs @@ -2024,6 +2024,36 @@ public Task ExecuteAsync(string command, ICollection? args, return ExecuteAsync(msg, ResultProcessor.ScriptResult, defaultValue: RedisResult.NullSingle); } + public RedisResult ScriptEvaluateMemory(string script, ReadOnlyMemory args, CommandFlags flags = CommandFlags.None) + { + var command = ResultProcessor.ScriptLoadProcessor.IsSHA1(script) ? RedisCommand.EVALSHA : RedisCommand.EVAL; + var msg = new ScriptEvalMemoryMessage(Database, flags, command, script, args); + try + { + return ExecuteSync(msg, ResultProcessor.ScriptResult, defaultValue: RedisResult.NullSingle); + } + catch (RedisServerException) when (msg.IsScriptUnavailable) + { + // could be a NOSCRIPT; for a sync call, we can re-issue that without problem + return ExecuteSync(msg, ResultProcessor.ScriptResult, defaultValue: RedisResult.NullSingle); + } + } + + public Lease? ScriptEvaluateMemoryLease(string script, ReadOnlyMemory args, CommandFlags flags = CommandFlags.None) + { + var command = ResultProcessor.ScriptLoadProcessor.IsSHA1(script) ? RedisCommand.EVALSHA : RedisCommand.EVAL; + var msg = new ScriptEvalMemoryMessage(Database, flags, command, script, args); + try + { + return ExecuteSync(msg, ResultProcessor.LeaseScript); + } + catch (RedisServerException) when (msg.IsScriptUnavailable) + { + // could be a NOSCRIPT; for a sync call, we can re-issue that without problem + return ExecuteSync(msg, ResultProcessor.LeaseScript); + } + } + public RedisResult ScriptEvaluate(string script, RedisKey[]? keys = null, RedisValue[]? values = null, CommandFlags flags = CommandFlags.None) { var command = ResultProcessor.ScriptLoadProcessor.IsSHA1(script) ? RedisCommand.EVALSHA : RedisCommand.EVAL; @@ -2055,6 +2085,38 @@ public RedisResult ScriptEvaluate(LoadedLuaScript script, object? parameters = n return script.Evaluate(this, parameters, withKeyPrefix: null, flags); } + public async Task ScriptEvaluateMemoryAsync(string script, ReadOnlyMemory args, CommandFlags flags = CommandFlags.None) + { + var command = ResultProcessor.ScriptLoadProcessor.IsSHA1(script) ? RedisCommand.EVALSHA : RedisCommand.EVAL; + var msg = new ScriptEvalMemoryMessage(Database, flags, command, script, args); + + try + { + return await ExecuteAsync(msg, ResultProcessor.ScriptResult, defaultValue: RedisResult.NullSingle).ForAwait(); + } + catch (RedisServerException) when (msg.IsScriptUnavailable) + { + // could be a NOSCRIPT; for a sync call, we can re-issue that without problem + return await ExecuteAsync(msg, ResultProcessor.ScriptResult, defaultValue: RedisResult.NullSingle).ForAwait(); + } + } + + public async Task?> ScriptEvaluateMemoryLeaseAsync(string script, ReadOnlyMemory args, CommandFlags flags = CommandFlags.None) + { + var command = ResultProcessor.ScriptLoadProcessor.IsSHA1(script) ? RedisCommand.EVALSHA : RedisCommand.EVAL; + var msg = new ScriptEvalMemoryMessage(Database, flags, command, script, args); + + try + { + return await ExecuteAsync(msg, ResultProcessor.LeaseScript).ForAwait(); + } + catch (RedisServerException) when (msg.IsScriptUnavailable) + { + // could be a NOSCRIPT; for a sync call, we can re-issue that without problem + return await ExecuteAsync(msg, ResultProcessor.LeaseScript).ForAwait(); + } + } + public async Task ScriptEvaluateAsync(string script, RedisKey[]? keys = null, RedisValue[]? values = null, CommandFlags flags = CommandFlags.None) { var command = ResultProcessor.ScriptLoadProcessor.IsSHA1(script) ? RedisCommand.EVALSHA : RedisCommand.EVAL; @@ -2087,6 +2149,36 @@ public Task ScriptEvaluateAsync(LoadedLuaScript script, object? par return script.EvaluateAsync(this, parameters, withKeyPrefix: null, flags); } + public RedisResult ScriptEvaluateMemoryReadOnly(string script, ReadOnlyMemory args, CommandFlags flags = CommandFlags.None) + { + var command = ResultProcessor.ScriptLoadProcessor.IsSHA1(script) ? RedisCommand.EVALSHA_RO : RedisCommand.EVAL_RO; + var msg = new ScriptEvalMemoryMessage(Database, flags, command, script, args); + try + { + return ExecuteSync(msg, ResultProcessor.ScriptResult, defaultValue: RedisResult.NullSingle); + } + catch (RedisServerException) when (msg.IsScriptUnavailable) + { + // could be a NOSCRIPT; for a sync call, we can re-issue that without problem + return ExecuteSync(msg, ResultProcessor.ScriptResult, defaultValue: RedisResult.NullSingle); + } + } + + public Lease? ScriptEvaluateMemoryReadOnlyLease(string script, ReadOnlyMemory args, CommandFlags flags = CommandFlags.None) + { + var command = ResultProcessor.ScriptLoadProcessor.IsSHA1(script) ? RedisCommand.EVALSHA_RO : RedisCommand.EVAL_RO; + var msg = new ScriptEvalMemoryMessage(Database, flags, command, script, args); + try + { + return ExecuteSync(msg, ResultProcessor.LeaseScript); + } + catch (RedisServerException) when (msg.IsScriptUnavailable) + { + // could be a NOSCRIPT; for a sync call, we can re-issue that without problem + return ExecuteSync(msg, ResultProcessor.LeaseScript); + } + } + public RedisResult ScriptEvaluateReadOnly(string script, RedisKey[]? keys = null, RedisValue[]? values = null, CommandFlags flags = CommandFlags.None) { var command = ResultProcessor.ScriptLoadProcessor.IsSHA1(script) ? RedisCommand.EVALSHA_RO : RedisCommand.EVAL_RO; @@ -2108,11 +2200,49 @@ public RedisResult ScriptEvaluateReadOnly(byte[] hash, RedisKey[]? keys = null, return ExecuteSync(msg, ResultProcessor.ScriptResult, defaultValue: RedisResult.NullSingle); } - public Task ScriptEvaluateReadOnlyAsync(string script, RedisKey[]? keys = null, RedisValue[]? values = null, CommandFlags flags = CommandFlags.None) + public async Task ScriptEvaluateMemoryReadOnlyAsync(string script, ReadOnlyMemory args, CommandFlags flags = CommandFlags.None) + { + var command = ResultProcessor.ScriptLoadProcessor.IsSHA1(script) ? RedisCommand.EVALSHA_RO : RedisCommand.EVAL_RO; + var msg = new ScriptEvalMemoryMessage(Database, flags, command, script, args); + try + { + return await ExecuteAsync(msg, ResultProcessor.ScriptResult, defaultValue: RedisResult.NullSingle).ForAwait(); + } + catch (RedisServerException) when (msg.IsScriptUnavailable) + { + // could be a NOSCRIPT; for a sync call, we can re-issue that without problem + return await ExecuteAsync(msg, ResultProcessor.ScriptResult, defaultValue: RedisResult.NullSingle).ForAwait(); + } + } + + public async Task?> ScriptEvaluateMemoryReadOnlyLeaseAsync(string script, ReadOnlyMemory args, CommandFlags flags = CommandFlags.None) + { + var command = ResultProcessor.ScriptLoadProcessor.IsSHA1(script) ? RedisCommand.EVALSHA_RO : RedisCommand.EVAL_RO; + var msg = new ScriptEvalMemoryMessage(Database, flags, command, script, args); + try + { + return await ExecuteAsync(msg, ResultProcessor.LeaseScript).ForAwait(); + } + catch (RedisServerException) when (msg.IsScriptUnavailable) + { + // could be a NOSCRIPT; for a sync call, we can re-issue that without problem + return await ExecuteAsync(msg, ResultProcessor.LeaseScript).ForAwait(); + } + } + + public async Task ScriptEvaluateReadOnlyAsync(string script, RedisKey[]? keys = null, RedisValue[]? values = null, CommandFlags flags = CommandFlags.None) { var command = ResultProcessor.ScriptLoadProcessor.IsSHA1(script) ? RedisCommand.EVALSHA_RO : RedisCommand.EVAL_RO; var msg = new ScriptEvalMessage(Database, flags, command, script, keys, values); - return ExecuteAsync(msg, ResultProcessor.ScriptResult, defaultValue: RedisResult.NullSingle); + try + { + return await ExecuteAsync(msg, ResultProcessor.ScriptResult, defaultValue: RedisResult.NullSingle).ForAwait(); + } + catch (RedisServerException) when (msg.IsScriptUnavailable) + { + // could be a NOSCRIPT; for a sync call, we can re-issue that without problem + return await ExecuteAsync(msg, ResultProcessor.ScriptResult, defaultValue: RedisResult.NullSingle).ForAwait(); + } } public Task ScriptEvaluateReadOnlyAsync(byte[] hash, RedisKey[]? keys = null, RedisValue[]? values = null, CommandFlags flags = CommandFlags.None) @@ -6039,6 +6169,99 @@ protected override bool TryGetSubCommand(out SubCommand subCommand) } } + private sealed class ScriptEvalMemoryMessage : Message, IMultiMessage + { + private readonly ReadOnlyMemory _args; + private readonly int _keysCount; + private readonly string _script; + private byte[]? asciiHash; + public ScriptEvalMemoryMessage(int db, CommandFlags flags, RedisCommand command, string script, ReadOnlyMemory args) + : base(db, flags, command) + { + _script = script ?? throw new ArgumentNullException(nameof(script)); + + int keysCount = 0; + foreach (ref readonly var arg in args.Span) + { + if (arg.IsNull) throw new ArgumentException("A null key or value is not valid in this context", nameof(args)); + if (arg.IsKey) keysCount++; + } + + _args = args; + _keysCount = keysCount; + } + + public override int GetHashSlot(ServerSelectionStrategy serverSelectionStrategy) + { + int slot = ServerSelectionStrategy.NoSlot; + foreach (ref readonly var arg in _args.Span) + { + var key = arg.Key; + if (!key.IsNull) + { + slot = serverSelectionStrategy.CombineSlot(slot, key); + } + } + return slot; + } + + public IEnumerable GetMessages(PhysicalConnection connection) + { + PhysicalBridge? bridge; + if ((bridge = connection.BridgeCouldBeNull) != null + && bridge.Multiplexer.CommandMap.IsAvailable(RedisCommand.SCRIPT) + && (Flags & CommandFlags.NoScriptCache) == 0) + { + // a script was provided (rather than a hash); check it is known and supported + asciiHash = bridge.ServerEndPoint.GetScriptHash(_script, command); + + if (asciiHash == null) + { + var msg = new ScriptLoadMessage(Flags, _script); + msg.SetInternalCall(); + msg.SetSource(ResultProcessor.ScriptLoad, null); + yield return msg; + } + } + yield return this; + } + + protected override void WriteImpl(in MessageWriter writer) + { + if (asciiHash != null) + { + writer.WriteHeader(RedisCommand.EVALSHA, 2 + _args.Length); + writer.WriteBulkString(asciiHash); + } + else + { + writer.WriteHeader(RedisCommand.EVAL, 2 + _args.Length); + writer.WriteBulkString(_script); + } + + writer.WriteBulkString(_keysCount); + + foreach (ref readonly var arg in _args.Span) + { + var key = arg.Key; + if (!key.IsNull) + { + writer.Write(key); + } + } + + foreach (ref readonly var arg in _args.Span) + { + var value = arg.Value; + if (!value.IsNull) + { + writer.WriteBulkString(value); + } + } + } + public override int ArgCount => 2 + _args.Length; + } + private sealed class ScriptEvalMessage : Message, IMultiMessage { private readonly RedisKey[] keys; diff --git a/src/StackExchange.Redis/RedisKeyOrValue.cs b/src/StackExchange.Redis/RedisKeyOrValue.cs new file mode 100644 index 000000000..f5437a58a --- /dev/null +++ b/src/StackExchange.Redis/RedisKeyOrValue.cs @@ -0,0 +1,232 @@ +using System; +using System.Diagnostics; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; + +namespace StackExchange.Redis; + +/// +/// Represents a key or value that can be stored in redis. +/// +[StructLayout(LayoutKind.Explicit)] +public readonly struct RedisKeyOrValue : IEquatable, IEquatable, IEquatable +{ + private enum StorageType + { + Null, + Key, + Value, + } + +#pragma warning disable SA1134 + [FieldOffset(0)] private readonly int _index; + [FieldOffset(4)] private readonly int _length; + [FieldOffset(8)] private readonly object? _obj; +#pragma warning restore SA1134 + + private StorageType Type + { + get + { + var obj = _obj; + if (obj == null) return StorageType.Null; + if ((obj is byte[] || obj is string) && _index < 0) return StorageType.Key; + return StorageType.Value; + } + } + + private RedisKey UnsafeKey + { + get + { + Debug.Assert(IsKey); + + return new RedisKey(null, _obj); + } + } + + private RedisValue UnsafeValue + { + get + { + Debug.Assert(IsValue); + + var copy = this; + return Unsafe.As(ref copy); + } + } + + /// + /// IsNull. + /// + public bool IsNull => _obj is null; + + /// + /// IsKey. + /// + public bool IsKey => Type == StorageType.Key; + + /// + /// Key. + /// + public RedisKey Key => Type == StorageType.Key ? new RedisKey(null, _obj) : default; + + /// + /// IsValue. + /// + public bool IsValue => Type == StorageType.Value; + + /// + /// Value. + /// + public RedisValue Value + { + get + { + if (Type != StorageType.Value) return default; + + var copy = this; + return Unsafe.As(ref copy); + } + } + + /// + /// Key. + /// + /// key. + public RedisKeyOrValue(in RedisKey key) + { + var keyValue = key.KeyValue; + var keyPrefix = key.KeyPrefix; + if (keyPrefix != null) + { + if (keyValue != null) + keyPrefix = (byte[]?)key ?? throw new InvalidOperationException("keyPrefix is null"); + + _obj = keyPrefix; + _index = -1; + _length = keyPrefix.Length; + } + else if (keyValue == null) + { + this = default; + } + else if (keyValue is byte[] bytes) + { + _obj = bytes; + _index = -1; + _length = bytes.Length; + } + else if (keyValue is string str) + { + _obj = str; + _index = -1; + _length = str.Length; + } + else + { + throw new ArgumentException("Unrecognized key type", nameof(key)); + } + } + + /// + /// Value. + /// + /// value. + public RedisKeyOrValue(in RedisValue value) + { + var copy = value; + this = Unsafe.As(ref copy); + } + + /// + public override int GetHashCode() => Type switch + { + StorageType.Key => UnsafeKey.GetHashCode(), + StorageType.Value => UnsafeValue.GetHashCode(), + _ => 0, + }; + + /// + public override bool Equals(object? obj) => obj switch + { + RedisKeyOrValue other => Equals(other), + RedisKey key => Type == StorageType.Key && UnsafeKey.Equals(key), + RedisValue value => Type == StorageType.Value && UnsafeValue.Equals(value), + _ => false, + }; + + /// + public override string ToString() => Type switch + { + StorageType.Key => UnsafeKey.ToString(), + StorageType.Value => UnsafeValue.ToString(), + _ => "(null)", + }; + + /// + public bool Equals(RedisKeyOrValue other) => Type switch + { + StorageType.Key => other.Type == StorageType.Key && UnsafeKey.Equals(other.UnsafeKey), + StorageType.Value => other.Type == StorageType.Value && UnsafeValue.Equals(other.UnsafeValue), + _ => other.Type == Type, + }; + + /// + public bool Equals(RedisKey other) => Type == StorageType.Key && UnsafeKey.Equals(other); + + /// + public bool Equals(RedisValue other) => Type == StorageType.Value && UnsafeValue.Equals(other); + + /// Create a new instance representing a key. + /// key. + public static RedisKeyOrValue FromKey(RedisKey key) => new RedisKeyOrValue(in key); + + /// Create a new instance representing a value. + /// value. + public static RedisKeyOrValue FromValue(RedisValue value) => new RedisKeyOrValue(in value); + + /// + /// Compares two values for equality. + /// + /// The first keyOrValue to compare. + /// The second keyOrValue to compare. + public static bool operator ==(RedisKeyOrValue x, RedisKeyOrValue y) => x.Equals(y); + + /// + /// Compares two values for non-equality. + /// + /// The first keyOrValue to compare. + /// The second keyOrValue to compare. + public static bool operator !=(RedisKeyOrValue x, RedisKeyOrValue y) => x.Equals(y); + + /// Create a new instance representing a key. + /// key. + public static implicit operator RedisKeyOrValue(RedisKey key) => new RedisKeyOrValue(in key); + + /// Create a new instance representing a value. + /// value. + public static implicit operator RedisKeyOrValue(RedisValue value) => new RedisKeyOrValue(in value); + + /// Obtains the underlying payload as a key. + /// value. + public static explicit operator RedisKey(RedisKeyOrValue value) + { + if (value.Type != StorageType.Key) + ThrowInvalidCast(value.Type); + + return value.UnsafeKey; + } + + /// Obtains the underlying payload as a value. + /// value. + public static explicit operator RedisValue(RedisKeyOrValue value) + { + if (value.Type != StorageType.Value) + ThrowInvalidCast(value.Type); + + return value.UnsafeValue; + } + + private static void ThrowInvalidCast(StorageType type) => throw new InvalidCastException($"Operation not valid on {type} value."); +} diff --git a/src/StackExchange.Redis/ResultProcessor.Lease.cs b/src/StackExchange.Redis/ResultProcessor.Lease.cs index 919c8f42f..12edbb971 100644 --- a/src/StackExchange.Redis/ResultProcessor.Lease.cs +++ b/src/StackExchange.Redis/ResultProcessor.Lease.cs @@ -19,6 +19,9 @@ public static readonly ResultProcessor> public static readonly ResultProcessor> LeaseFromArray = new LeaseFromArrayProcessor(); + public static readonly ResultProcessor> + LeaseScript = new LeaseProcessor(); + private abstract class LeaseProcessor : ResultProcessor?> { protected override bool SetResultCore(PhysicalConnection connection, Message message, ref RespReader reader) From 348f27493da5155ef78dd1f14a2b2b56a8b9c115 Mon Sep 17 00:00:00 2001 From: Ivan Tikhonov Date: Mon, 31 Aug 2026 17:14:22 +0300 Subject: [PATCH 02/17] typo != --- src/StackExchange.Redis/RedisKeyOrValue.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/StackExchange.Redis/RedisKeyOrValue.cs b/src/StackExchange.Redis/RedisKeyOrValue.cs index f5437a58a..4f023a97d 100644 --- a/src/StackExchange.Redis/RedisKeyOrValue.cs +++ b/src/StackExchange.Redis/RedisKeyOrValue.cs @@ -198,7 +198,7 @@ public RedisKeyOrValue(in RedisValue value) /// /// The first keyOrValue to compare. /// The second keyOrValue to compare. - public static bool operator !=(RedisKeyOrValue x, RedisKeyOrValue y) => x.Equals(y); + public static bool operator !=(RedisKeyOrValue x, RedisKeyOrValue y) => !x.Equals(y); /// Create a new instance representing a key. /// key. From 8fc9da6cc013d7e010ba462038d5cd1fe8b0c4a3 Mon Sep 17 00:00:00 2001 From: Ivan Tikhonov Date: Mon, 31 Aug 2026 17:48:40 +0300 Subject: [PATCH 03/17] Keys must come before values. --- src/StackExchange.Redis/RedisDatabase.cs | 53 ++++++++++++++---------- 1 file changed, 32 insertions(+), 21 deletions(-) diff --git a/src/StackExchange.Redis/RedisDatabase.cs b/src/StackExchange.Redis/RedisDatabase.cs index 644bd57ea..75421baee 100644 --- a/src/StackExchange.Redis/RedisDatabase.cs +++ b/src/StackExchange.Redis/RedisDatabase.cs @@ -6181,12 +6181,28 @@ public ScriptEvalMemoryMessage(int db, CommandFlags flags, RedisCommand command, _script = script ?? throw new ArgumentNullException(nameof(script)); int keysCount = 0; - foreach (ref readonly var arg in args.Span) + var span = args.Span; + foreach (ref readonly var arg in span) { - if (arg.IsNull) throw new ArgumentException("A null key or value is not valid in this context", nameof(args)); - if (arg.IsKey) keysCount++; + if (arg.IsNull) throw new ArgumentException("A null is not valid in this context", nameof(args)); + if (arg.IsKey) + { + keysCount++; + } + else + { + Debug.Assert(arg.IsValue); + break; + } } + if (span.Length > keysCount + 1) + { + foreach (ref readonly var arg in span.Slice(keysCount + 1)) + { + if (!arg.IsValue) throw new ArgumentException("A null or key is not valid in this context. Keys must come before values.", nameof(args)); + } + } _args = args; _keysCount = keysCount; } @@ -6194,13 +6210,11 @@ public ScriptEvalMemoryMessage(int db, CommandFlags flags, RedisCommand command, public override int GetHashSlot(ServerSelectionStrategy serverSelectionStrategy) { int slot = ServerSelectionStrategy.NoSlot; - foreach (ref readonly var arg in _args.Span) + foreach (ref readonly var arg in _args.Span.Slice(0, _keysCount)) { - var key = arg.Key; - if (!key.IsNull) - { - slot = serverSelectionStrategy.CombineSlot(slot, key); - } + Debug.Assert(arg.IsKey); + + slot = serverSelectionStrategy.CombineSlot(slot, arg.Key); } return slot; } @@ -6241,22 +6255,19 @@ protected override void WriteImpl(in MessageWriter writer) writer.WriteBulkString(_keysCount); - foreach (ref readonly var arg in _args.Span) + var span = _args.Span; + foreach (ref readonly var arg in span.Slice(0, _keysCount)) { - var key = arg.Key; - if (!key.IsNull) - { - writer.Write(key); - } + Debug.Assert(arg.IsKey); + + writer.Write(arg.Key); } - foreach (ref readonly var arg in _args.Span) + foreach (ref readonly var arg in span.Slice(_keysCount)) { - var value = arg.Value; - if (!value.IsNull) - { - writer.WriteBulkString(value); - } + Debug.Assert(arg.IsValue); + + writer.WriteBulkString(arg.Value); } } public override int ArgCount => 2 + _args.Length; From a8a31b97348a913442a78a8c37148651d438a17f Mon Sep 17 00:00:00 2001 From: Ivan Tikhonov Date: Mon, 31 Aug 2026 19:21:15 +0300 Subject: [PATCH 04/17] Exception message fix --- src/StackExchange.Redis/RedisDatabase.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/StackExchange.Redis/RedisDatabase.cs b/src/StackExchange.Redis/RedisDatabase.cs index 75421baee..6260f3cb2 100644 --- a/src/StackExchange.Redis/RedisDatabase.cs +++ b/src/StackExchange.Redis/RedisDatabase.cs @@ -6200,7 +6200,8 @@ public ScriptEvalMemoryMessage(int db, CommandFlags flags, RedisCommand command, { foreach (ref readonly var arg in span.Slice(keysCount + 1)) { - if (!arg.IsValue) throw new ArgumentException("A null or key is not valid in this context. Keys must come before values.", nameof(args)); + if (!arg.IsValue) + throw new ArgumentException(arg.IsNull ? "A null is not valid in this context" : "A key is not valid in this context. Keys must come before values.", nameof(args)); } } _args = args; From 429dac8a12ab2b302ff7aef6df625b0810d7608c Mon Sep 17 00:00:00 2001 From: Ivan Tikhonov Date: Mon, 31 Aug 2026 19:53:33 +0300 Subject: [PATCH 05/17] ToInnerCopy if hasKeys --- .../KeyspaceIsolation/KeyPrefixed.cs | 45 ++++++++++++------- .../KeyspaceIsolation/KeyPrefixedDatabase.cs | 8 ++-- 2 files changed, 34 insertions(+), 19 deletions(-) diff --git a/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixed.cs b/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixed.cs index ae8e758f7..679402c7d 100644 --- a/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixed.cs +++ b/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixed.cs @@ -421,11 +421,11 @@ public Task ScriptEvaluateAsync(byte[] hash, RedisKey[]? keys = nul public Task ScriptEvaluateMemoryAsync(string script, ReadOnlyMemory args, CommandFlags flags = CommandFlags.None) => // TODO: rented args? - Inner.ScriptEvaluateMemoryAsync(script, ToInner(args), flags); + Inner.ScriptEvaluateMemoryAsync(script, ToInnerCopy(args), flags); public Task?> ScriptEvaluateMemoryLeaseAsync(string script, ReadOnlyMemory args, CommandFlags flags = CommandFlags.None) => // TODO: rented args? - Inner.ScriptEvaluateMemoryLeaseAsync(script, ToInner(args), flags); + Inner.ScriptEvaluateMemoryLeaseAsync(script, ToInnerCopy(args), flags); public Task ScriptEvaluateAsync(string script, RedisKey[]? keys = null, RedisValue[]? values = null, CommandFlags flags = CommandFlags.None) => // TODO: The return value could contain prefixed keys. It might make sense to 'unprefix' those? @@ -445,11 +445,11 @@ public Task ScriptEvaluateReadOnlyAsync(byte[] hash, RedisKey[]? ke public Task ScriptEvaluateMemoryReadOnlyAsync(string script, ReadOnlyMemory args, CommandFlags flags = CommandFlags.None) => // TODO: rented args? - Inner.ScriptEvaluateMemoryReadOnlyAsync(script, ToInner(args), flags); + Inner.ScriptEvaluateMemoryReadOnlyAsync(script, ToInnerCopy(args), flags); public Task?> ScriptEvaluateMemoryReadOnlyLeaseAsync(string script, ReadOnlyMemory args, CommandFlags flags = CommandFlags.None) => // TODO: rented args? - Inner.ScriptEvaluateMemoryReadOnlyLeaseAsync(script, ToInner(args), flags); + Inner.ScriptEvaluateMemoryReadOnlyLeaseAsync(script, ToInnerCopy(args), flags); public Task ScriptEvaluateReadOnlyAsync(string script, RedisKey[]? keys = null, RedisValue[]? values = null, CommandFlags flags = CommandFlags.None) => // TODO: The return value could contain prefixed keys. It might make sense to 'unprefix' those? @@ -963,26 +963,41 @@ protected RedisKey ToInnerOrDefault(RedisKey outer) => return args; } - protected ReadOnlyMemory ToInner(ReadOnlyMemory outer) + protected ReadOnlyMemory ToInnerCopy(ReadOnlyMemory outer) { - if (outer.Length == 0) + if (outer.Length > 0) { - return outer; - } - else - { - // TODO: add rent - RedisKeyOrValue[] inner = new RedisKeyOrValue[outer.Length]; + RedisKeyOrValue[] inner = []; + var span = outer.Span; var i = 0; - foreach (ref readonly var item in outer.Span) + foreach (ref readonly var item in span) { var key = item.Key; - inner[i++] = key.IsNull ? item : ToInner(key); + if (!key.IsNull) + { + inner = new RedisKeyOrValue[outer.Length]; + inner[i] = ToInner(key); + span.Slice(0, i).CopyTo(inner); + break; + } + i++; } - return inner; + if (inner.Length > 0) + { + i++; + foreach (ref readonly var item in span.Slice(i)) + { + var key = item.Key; + inner[i++] = key.IsNull ? item : ToInner(key); + } + + return inner; + } } + + return outer; } [return: NotNullIfNotNull("outer")] diff --git a/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixedDatabase.cs b/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixedDatabase.cs index 9403bc2a3..d40c2e1f5 100644 --- a/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixedDatabase.cs +++ b/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixedDatabase.cs @@ -393,11 +393,11 @@ public RedisResult ScriptEvaluate(byte[] hash, RedisKey[]? keys = null, RedisVal public RedisResult ScriptEvaluateMemory(string script, ReadOnlyMemory args, CommandFlags flags = CommandFlags.None) => // TODO: rented args? - Inner.ScriptEvaluateMemory(script, ToInner(args), flags); + Inner.ScriptEvaluateMemory(script, ToInnerCopy(args), flags); public Lease? ScriptEvaluateMemoryLease(string script, ReadOnlyMemory args, CommandFlags flags = CommandFlags.None) => // TODO: rented args? - Inner.ScriptEvaluateMemoryLease(script, ToInner(args), flags); + Inner.ScriptEvaluateMemoryLease(script, ToInnerCopy(args), flags); public RedisResult ScriptEvaluate(string script, RedisKey[]? keys = null, RedisValue[]? values = null, CommandFlags flags = CommandFlags.None) => // TODO: The return value could contain prefixed keys. It might make sense to 'unprefix' those? @@ -417,11 +417,11 @@ public RedisResult ScriptEvaluateReadOnly(byte[] hash, RedisKey[]? keys = null, public RedisResult ScriptEvaluateMemoryReadOnly(string script, ReadOnlyMemory args, CommandFlags flags = CommandFlags.None) => // TODO: rented args? - Inner.ScriptEvaluateMemoryReadOnly(script, ToInner(args), flags); + Inner.ScriptEvaluateMemoryReadOnly(script, ToInnerCopy(args), flags); public Lease? ScriptEvaluateMemoryReadOnlyLease(string script, ReadOnlyMemory args, CommandFlags flags = CommandFlags.None) => // TODO: rented args? - Inner.ScriptEvaluateMemoryReadOnlyLease(script, ToInner(args), flags); + Inner.ScriptEvaluateMemoryReadOnlyLease(script, ToInnerCopy(args), flags); public RedisResult ScriptEvaluateReadOnly(string script, RedisKey[]? keys = null, RedisValue[]? values = null, CommandFlags flags = CommandFlags.None) => // TODO: The return value could contain prefixed keys. It might make sense to 'unprefix' those? From 422e74840794facb172236b2beb2eb1a3fcd79ec Mon Sep 17 00:00:00 2001 From: Ivan Tikhonov Date: Mon, 31 Aug 2026 21:02:08 +0300 Subject: [PATCH 06/17] add rent args for key-prefix path --- .../KeyspaceIsolation/KeyPrefixed.cs | 89 ++++++++++++++++--- .../KeyspaceIsolation/KeyPrefixedDatabase.cs | 53 ++++++++--- 2 files changed, 118 insertions(+), 24 deletions(-) diff --git a/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixed.cs b/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixed.cs index 679402c7d..b23e9c938 100644 --- a/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixed.cs +++ b/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixed.cs @@ -1,4 +1,5 @@ using System; +using System.Buffers; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Linq; @@ -419,13 +420,23 @@ public Task ScriptEvaluateAsync(byte[] hash, RedisKey[]? keys = nul // TODO: The return value could contain prefixed keys. It might make sense to 'unprefix' those? Inner.ScriptEvaluateAsync(hash, ToInner(keys), values, flags); - public Task ScriptEvaluateMemoryAsync(string script, ReadOnlyMemory args, CommandFlags flags = CommandFlags.None) => - // TODO: rented args? - Inner.ScriptEvaluateMemoryAsync(script, ToInnerCopy(args), flags); + public Task ScriptEvaluateMemoryAsync(string script, ReadOnlyMemory args, CommandFlags flags = CommandFlags.None) + { + if ((flags & CommandFlags.FireAndForget) != 0) + return Inner.ScriptEvaluateMemoryAsync(script, ToInnerCopy(args), flags); + + var result = Inner.ScriptEvaluateMemoryAsync(script, ToInnerLease(args, out var lease), flags); + return lease != null ? ReturnAfterResult(result, lease) : result; + } - public Task?> ScriptEvaluateMemoryLeaseAsync(string script, ReadOnlyMemory args, CommandFlags flags = CommandFlags.None) => - // TODO: rented args? - Inner.ScriptEvaluateMemoryLeaseAsync(script, ToInnerCopy(args), flags); + public Task?> ScriptEvaluateMemoryLeaseAsync(string script, ReadOnlyMemory args, CommandFlags flags = CommandFlags.None) + { + if ((flags & CommandFlags.FireAndForget) != 0) + return Inner.ScriptEvaluateMemoryLeaseAsync(script, ToInnerCopy(args), flags); + + var result = Inner.ScriptEvaluateMemoryLeaseAsync(script, ToInnerLease(args, out var lease), flags); + return lease != null ? ReturnAfterResult(result, lease) : result; + } public Task ScriptEvaluateAsync(string script, RedisKey[]? keys = null, RedisValue[]? values = null, CommandFlags flags = CommandFlags.None) => // TODO: The return value could contain prefixed keys. It might make sense to 'unprefix' those? @@ -443,13 +454,23 @@ public Task ScriptEvaluateReadOnlyAsync(byte[] hash, RedisKey[]? ke // TODO: The return value could contain prefixed keys. It might make sense to 'unprefix' those? Inner.ScriptEvaluateAsync(hash, ToInner(keys), values, flags); - public Task ScriptEvaluateMemoryReadOnlyAsync(string script, ReadOnlyMemory args, CommandFlags flags = CommandFlags.None) => - // TODO: rented args? - Inner.ScriptEvaluateMemoryReadOnlyAsync(script, ToInnerCopy(args), flags); + public Task ScriptEvaluateMemoryReadOnlyAsync(string script, ReadOnlyMemory args, CommandFlags flags = CommandFlags.None) + { + if ((flags & CommandFlags.FireAndForget) != 0) + return Inner.ScriptEvaluateMemoryReadOnlyAsync(script, ToInnerCopy(args), flags); - public Task?> ScriptEvaluateMemoryReadOnlyLeaseAsync(string script, ReadOnlyMemory args, CommandFlags flags = CommandFlags.None) => - // TODO: rented args? - Inner.ScriptEvaluateMemoryReadOnlyLeaseAsync(script, ToInnerCopy(args), flags); + var result = Inner.ScriptEvaluateMemoryReadOnlyAsync(script, ToInnerLease(args, out var lease), flags); + return lease != null ? ReturnAfterResult(result, lease) : result; + } + + public Task?> ScriptEvaluateMemoryReadOnlyLeaseAsync(string script, ReadOnlyMemory args, CommandFlags flags = CommandFlags.None) + { + if ((flags & CommandFlags.FireAndForget) != 0) + return Inner.ScriptEvaluateMemoryReadOnlyLeaseAsync(script, ToInnerCopy(args), flags); + + var result = Inner.ScriptEvaluateMemoryReadOnlyLeaseAsync(script, ToInnerLease(args, out var lease), flags); + return lease != null ? ReturnAfterResult(result, lease) : result; + } public Task ScriptEvaluateReadOnlyAsync(string script, RedisKey[]? keys = null, RedisValue[]? values = null, CommandFlags flags = CommandFlags.None) => // TODO: The return value could contain prefixed keys. It might make sense to 'unprefix' those? @@ -1000,6 +1021,50 @@ protected ReadOnlyMemory ToInnerCopy(ReadOnlyMemory ToInnerLease(ReadOnlyMemory outer, out RedisKeyOrValue[]? lease) + { + lease = null; + var length = outer.Length; + if (length > 0) + { + var span = outer.Span; + var i = 0; + foreach (ref readonly var item in span) + { + var key = item.Key; + if (!key.IsNull) + { + lease = ArrayPool.Shared.Rent(length); + lease[i] = ToInner(key); + span.Slice(0, i).CopyTo(lease); + break; + } + i++; + } + + if (lease != null) + { + i++; + foreach (ref readonly var item in span.Slice(i)) + { + var key = item.Key; + lease[i++] = key.IsNull ? item : ToInner(key); + } + + return lease.AsMemory(0, length); + } + } + + return outer; + } + + private static async Task ReturnAfterResult(Task task, RedisKeyOrValue[] lease) + { + var result = await task; + ArrayPool.Shared.Return(lease, clearArray: true); + return result; + } + [return: NotNullIfNotNull("outer")] protected RedisKey[]? ToInner(RedisKey[]? outer) { diff --git a/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixedDatabase.cs b/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixedDatabase.cs index d40c2e1f5..8ec3dfcfb 100644 --- a/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixedDatabase.cs +++ b/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixedDatabase.cs @@ -1,4 +1,5 @@ using System; +using System.Buffers; using System.Collections.Generic; using System.Net; using StackExchange.Redis.Interfaces; @@ -391,13 +392,27 @@ public RedisResult ScriptEvaluate(byte[] hash, RedisKey[]? keys = null, RedisVal // TODO: The return value could contain prefixed keys. It might make sense to 'unprefix' those? Inner.ScriptEvaluate(hash, ToInner(keys), values, flags); - public RedisResult ScriptEvaluateMemory(string script, ReadOnlyMemory args, CommandFlags flags = CommandFlags.None) => - // TODO: rented args? - Inner.ScriptEvaluateMemory(script, ToInnerCopy(args), flags); + public RedisResult ScriptEvaluateMemory(string script, ReadOnlyMemory args, CommandFlags flags = CommandFlags.None) + { + if ((flags & CommandFlags.FireAndForget) != 0) + return Inner.ScriptEvaluateMemory(script, ToInnerCopy(args), flags); + + var result = Inner.ScriptEvaluateMemory(script, ToInnerLease(args, out var lease), flags); + if (lease != null) ArrayPool.Shared.Return(lease, clearArray: true); + + return result; + } - public Lease? ScriptEvaluateMemoryLease(string script, ReadOnlyMemory args, CommandFlags flags = CommandFlags.None) => - // TODO: rented args? - Inner.ScriptEvaluateMemoryLease(script, ToInnerCopy(args), flags); + public Lease? ScriptEvaluateMemoryLease(string script, ReadOnlyMemory args, CommandFlags flags = CommandFlags.None) + { + if ((flags & CommandFlags.FireAndForget) != 0) + return Inner.ScriptEvaluateMemoryLease(script, ToInnerCopy(args), flags); + + var result = Inner.ScriptEvaluateMemoryLease(script, ToInnerLease(args, out var lease), flags); + if (lease != null) ArrayPool.Shared.Return(lease, clearArray: true); + + return result; + } public RedisResult ScriptEvaluate(string script, RedisKey[]? keys = null, RedisValue[]? values = null, CommandFlags flags = CommandFlags.None) => // TODO: The return value could contain prefixed keys. It might make sense to 'unprefix' those? @@ -415,13 +430,27 @@ public RedisResult ScriptEvaluateReadOnly(byte[] hash, RedisKey[]? keys = null, // TODO: The return value could contain prefixed keys. It might make sense to 'unprefix' those? Inner.ScriptEvaluateReadOnly(hash, ToInner(keys), values, flags); - public RedisResult ScriptEvaluateMemoryReadOnly(string script, ReadOnlyMemory args, CommandFlags flags = CommandFlags.None) => - // TODO: rented args? - Inner.ScriptEvaluateMemoryReadOnly(script, ToInnerCopy(args), flags); + public RedisResult ScriptEvaluateMemoryReadOnly(string script, ReadOnlyMemory args, CommandFlags flags = CommandFlags.None) + { + if ((flags & CommandFlags.FireAndForget) != 0) + return Inner.ScriptEvaluateMemoryReadOnly(script, ToInnerCopy(args), flags); + + var result = Inner.ScriptEvaluateMemoryReadOnly(script, ToInnerLease(args, out var lease), flags); + if (lease != null) ArrayPool.Shared.Return(lease, clearArray: true); + + return result; + } - public Lease? ScriptEvaluateMemoryReadOnlyLease(string script, ReadOnlyMemory args, CommandFlags flags = CommandFlags.None) => - // TODO: rented args? - Inner.ScriptEvaluateMemoryReadOnlyLease(script, ToInnerCopy(args), flags); + public Lease? ScriptEvaluateMemoryReadOnlyLease(string script, ReadOnlyMemory args, CommandFlags flags = CommandFlags.None) + { + if ((flags & CommandFlags.FireAndForget) != 0) + return Inner.ScriptEvaluateMemoryReadOnlyLease(script, ToInnerCopy(args), flags); + + var result = Inner.ScriptEvaluateMemoryReadOnlyLease(script, ToInnerLease(args, out var lease), flags); + if (lease != null) ArrayPool.Shared.Return(lease, clearArray: true); + + return result; + } public RedisResult ScriptEvaluateReadOnly(string script, RedisKey[]? keys = null, RedisValue[]? values = null, CommandFlags flags = CommandFlags.None) => // TODO: The return value could contain prefixed keys. It might make sense to 'unprefix' those? From 10fb7ad0376f1f44fe3eec81ea6df3f0b8066f41 Mon Sep 17 00:00:00 2001 From: Ivan Tikhonov Date: Mon, 31 Aug 2026 22:59:12 +0300 Subject: [PATCH 07/17] renaming ScriptEval --- .../Interfaces/IDatabase.cs | 8 +-- .../Interfaces/IDatabaseAsync.cs | 16 ++--- .../KeyspaceIsolation/KeyPrefixed.cs | 24 ++++---- .../KeyspaceIsolation/KeyPrefixedDatabase.cs | 24 ++++---- .../PublicAPI/PublicAPI.Unshipped.txt | 16 ++--- src/StackExchange.Redis/RedisDatabase.cs | 60 +++++++++---------- 6 files changed, 74 insertions(+), 74 deletions(-) diff --git a/src/StackExchange.Redis/Interfaces/IDatabase.cs b/src/StackExchange.Redis/Interfaces/IDatabase.cs index 681a434ed..36d3131b2 100644 --- a/src/StackExchange.Redis/Interfaces/IDatabase.cs +++ b/src/StackExchange.Redis/Interfaces/IDatabase.cs @@ -1639,7 +1639,7 @@ public partial interface IDatabase : IRedis, IDatabaseAsync /// , /// . /// - RedisResult ScriptEvaluateMemory(string script, ReadOnlyMemory args, CommandFlags flags = CommandFlags.None); + RedisResult ScriptEval(string script, ReadOnlyMemory args, CommandFlags flags = CommandFlags.None); /// /// Execute a Lua script against the server. @@ -1653,7 +1653,7 @@ public partial interface IDatabase : IRedis, IDatabaseAsync /// , /// . /// - Lease? ScriptEvaluateMemoryLease(string script, ReadOnlyMemory args, CommandFlags flags = CommandFlags.None); + Lease? ScriptEvalLease(string script, ReadOnlyMemory args, CommandFlags flags = CommandFlags.None); /// /// Execute a Lua script against the server. @@ -1720,7 +1720,7 @@ public partial interface IDatabase : IRedis, IDatabaseAsync /// , /// . /// - RedisResult ScriptEvaluateMemoryReadOnly(string script, ReadOnlyMemory args, CommandFlags flags = CommandFlags.None); + RedisResult ScriptEvalReadOnly(string script, ReadOnlyMemory args, CommandFlags flags = CommandFlags.None); /// /// Read-only variant of the EVAL command that cannot execute commands that modify data, Execute a Lua script against the server. @@ -1734,7 +1734,7 @@ public partial interface IDatabase : IRedis, IDatabaseAsync /// , /// . /// - Lease? ScriptEvaluateMemoryReadOnlyLease(string script, ReadOnlyMemory args, CommandFlags flags = CommandFlags.None); + Lease? ScriptEvalReadOnlyLease(string script, ReadOnlyMemory args, CommandFlags flags = CommandFlags.None); /// /// Read-only variant of the EVAL command that cannot execute commands that modify data, Execute a Lua script against the server. diff --git a/src/StackExchange.Redis/Interfaces/IDatabaseAsync.cs b/src/StackExchange.Redis/Interfaces/IDatabaseAsync.cs index 391f1b199..6c33d7cb3 100644 --- a/src/StackExchange.Redis/Interfaces/IDatabaseAsync.cs +++ b/src/StackExchange.Redis/Interfaces/IDatabaseAsync.cs @@ -402,11 +402,11 @@ public partial interface IDatabaseAsync : IRedisAsync /// Task ExecuteAsync(string command, ICollection? args, CommandFlags flags = CommandFlags.None); - /// - Task ScriptEvaluateMemoryAsync(string script, ReadOnlyMemory args, CommandFlags flags = CommandFlags.None); + /// + Task ScriptEvalAsync(string script, ReadOnlyMemory args, CommandFlags flags = CommandFlags.None); - /// - Task?> ScriptEvaluateMemoryLeaseAsync(string script, ReadOnlyMemory args, CommandFlags flags = CommandFlags.None); + /// + Task?> ScriptEvalLeaseAsync(string script, ReadOnlyMemory args, CommandFlags flags = CommandFlags.None); /// Task ScriptEvaluateAsync(string script, RedisKey[]? keys = null, RedisValue[]? values = null, CommandFlags flags = CommandFlags.None); @@ -421,11 +421,11 @@ public partial interface IDatabaseAsync : IRedisAsync /// Task ScriptEvaluateAsync(LoadedLuaScript script, object? parameters = null, CommandFlags flags = CommandFlags.None); - /// - Task ScriptEvaluateMemoryReadOnlyAsync(string script, ReadOnlyMemory args, CommandFlags flags = CommandFlags.None); + /// + Task ScriptEvalReadOnlyAsync(string script, ReadOnlyMemory args, CommandFlags flags = CommandFlags.None); - /// - Task?> ScriptEvaluateMemoryReadOnlyLeaseAsync(string script, ReadOnlyMemory args, CommandFlags flags = CommandFlags.None); + /// + Task?> ScriptEvalReadOnlyLeaseAsync(string script, ReadOnlyMemory args, CommandFlags flags = CommandFlags.None); /// Task ScriptEvaluateReadOnlyAsync(string script, RedisKey[]? keys = null, RedisValue[]? values = null, CommandFlags flags = CommandFlags.None); diff --git a/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixed.cs b/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixed.cs index b23e9c938..cf7f31425 100644 --- a/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixed.cs +++ b/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixed.cs @@ -420,21 +420,21 @@ public Task ScriptEvaluateAsync(byte[] hash, RedisKey[]? keys = nul // TODO: The return value could contain prefixed keys. It might make sense to 'unprefix' those? Inner.ScriptEvaluateAsync(hash, ToInner(keys), values, flags); - public Task ScriptEvaluateMemoryAsync(string script, ReadOnlyMemory args, CommandFlags flags = CommandFlags.None) + public Task ScriptEvalAsync(string script, ReadOnlyMemory args, CommandFlags flags = CommandFlags.None) { if ((flags & CommandFlags.FireAndForget) != 0) - return Inner.ScriptEvaluateMemoryAsync(script, ToInnerCopy(args), flags); + return Inner.ScriptEvalAsync(script, ToInnerCopy(args), flags); - var result = Inner.ScriptEvaluateMemoryAsync(script, ToInnerLease(args, out var lease), flags); + var result = Inner.ScriptEvalAsync(script, ToInnerLease(args, out var lease), flags); return lease != null ? ReturnAfterResult(result, lease) : result; } - public Task?> ScriptEvaluateMemoryLeaseAsync(string script, ReadOnlyMemory args, CommandFlags flags = CommandFlags.None) + public Task?> ScriptEvalLeaseAsync(string script, ReadOnlyMemory args, CommandFlags flags = CommandFlags.None) { if ((flags & CommandFlags.FireAndForget) != 0) - return Inner.ScriptEvaluateMemoryLeaseAsync(script, ToInnerCopy(args), flags); + return Inner.ScriptEvalLeaseAsync(script, ToInnerCopy(args), flags); - var result = Inner.ScriptEvaluateMemoryLeaseAsync(script, ToInnerLease(args, out var lease), flags); + var result = Inner.ScriptEvalLeaseAsync(script, ToInnerLease(args, out var lease), flags); return lease != null ? ReturnAfterResult(result, lease) : result; } @@ -454,21 +454,21 @@ public Task ScriptEvaluateReadOnlyAsync(byte[] hash, RedisKey[]? ke // TODO: The return value could contain prefixed keys. It might make sense to 'unprefix' those? Inner.ScriptEvaluateAsync(hash, ToInner(keys), values, flags); - public Task ScriptEvaluateMemoryReadOnlyAsync(string script, ReadOnlyMemory args, CommandFlags flags = CommandFlags.None) + public Task ScriptEvalReadOnlyAsync(string script, ReadOnlyMemory args, CommandFlags flags = CommandFlags.None) { if ((flags & CommandFlags.FireAndForget) != 0) - return Inner.ScriptEvaluateMemoryReadOnlyAsync(script, ToInnerCopy(args), flags); + return Inner.ScriptEvalReadOnlyAsync(script, ToInnerCopy(args), flags); - var result = Inner.ScriptEvaluateMemoryReadOnlyAsync(script, ToInnerLease(args, out var lease), flags); + var result = Inner.ScriptEvalReadOnlyAsync(script, ToInnerLease(args, out var lease), flags); return lease != null ? ReturnAfterResult(result, lease) : result; } - public Task?> ScriptEvaluateMemoryReadOnlyLeaseAsync(string script, ReadOnlyMemory args, CommandFlags flags = CommandFlags.None) + public Task?> ScriptEvalReadOnlyLeaseAsync(string script, ReadOnlyMemory args, CommandFlags flags = CommandFlags.None) { if ((flags & CommandFlags.FireAndForget) != 0) - return Inner.ScriptEvaluateMemoryReadOnlyLeaseAsync(script, ToInnerCopy(args), flags); + return Inner.ScriptEvalReadOnlyLeaseAsync(script, ToInnerCopy(args), flags); - var result = Inner.ScriptEvaluateMemoryReadOnlyLeaseAsync(script, ToInnerLease(args, out var lease), flags); + var result = Inner.ScriptEvalReadOnlyLeaseAsync(script, ToInnerLease(args, out var lease), flags); return lease != null ? ReturnAfterResult(result, lease) : result; } diff --git a/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixedDatabase.cs b/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixedDatabase.cs index 8ec3dfcfb..5832f7f7a 100644 --- a/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixedDatabase.cs +++ b/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixedDatabase.cs @@ -392,23 +392,23 @@ public RedisResult ScriptEvaluate(byte[] hash, RedisKey[]? keys = null, RedisVal // TODO: The return value could contain prefixed keys. It might make sense to 'unprefix' those? Inner.ScriptEvaluate(hash, ToInner(keys), values, flags); - public RedisResult ScriptEvaluateMemory(string script, ReadOnlyMemory args, CommandFlags flags = CommandFlags.None) + public RedisResult ScriptEval(string script, ReadOnlyMemory args, CommandFlags flags = CommandFlags.None) { if ((flags & CommandFlags.FireAndForget) != 0) - return Inner.ScriptEvaluateMemory(script, ToInnerCopy(args), flags); + return Inner.ScriptEval(script, ToInnerCopy(args), flags); - var result = Inner.ScriptEvaluateMemory(script, ToInnerLease(args, out var lease), flags); + var result = Inner.ScriptEval(script, ToInnerLease(args, out var lease), flags); if (lease != null) ArrayPool.Shared.Return(lease, clearArray: true); return result; } - public Lease? ScriptEvaluateMemoryLease(string script, ReadOnlyMemory args, CommandFlags flags = CommandFlags.None) + public Lease? ScriptEvalLease(string script, ReadOnlyMemory args, CommandFlags flags = CommandFlags.None) { if ((flags & CommandFlags.FireAndForget) != 0) - return Inner.ScriptEvaluateMemoryLease(script, ToInnerCopy(args), flags); + return Inner.ScriptEvalLease(script, ToInnerCopy(args), flags); - var result = Inner.ScriptEvaluateMemoryLease(script, ToInnerLease(args, out var lease), flags); + var result = Inner.ScriptEvalLease(script, ToInnerLease(args, out var lease), flags); if (lease != null) ArrayPool.Shared.Return(lease, clearArray: true); return result; @@ -430,23 +430,23 @@ public RedisResult ScriptEvaluateReadOnly(byte[] hash, RedisKey[]? keys = null, // TODO: The return value could contain prefixed keys. It might make sense to 'unprefix' those? Inner.ScriptEvaluateReadOnly(hash, ToInner(keys), values, flags); - public RedisResult ScriptEvaluateMemoryReadOnly(string script, ReadOnlyMemory args, CommandFlags flags = CommandFlags.None) + public RedisResult ScriptEvalReadOnly(string script, ReadOnlyMemory args, CommandFlags flags = CommandFlags.None) { if ((flags & CommandFlags.FireAndForget) != 0) - return Inner.ScriptEvaluateMemoryReadOnly(script, ToInnerCopy(args), flags); + return Inner.ScriptEvalReadOnly(script, ToInnerCopy(args), flags); - var result = Inner.ScriptEvaluateMemoryReadOnly(script, ToInnerLease(args, out var lease), flags); + var result = Inner.ScriptEvalReadOnly(script, ToInnerLease(args, out var lease), flags); if (lease != null) ArrayPool.Shared.Return(lease, clearArray: true); return result; } - public Lease? ScriptEvaluateMemoryReadOnlyLease(string script, ReadOnlyMemory args, CommandFlags flags = CommandFlags.None) + public Lease? ScriptEvalReadOnlyLease(string script, ReadOnlyMemory args, CommandFlags flags = CommandFlags.None) { if ((flags & CommandFlags.FireAndForget) != 0) - return Inner.ScriptEvaluateMemoryReadOnlyLease(script, ToInnerCopy(args), flags); + return Inner.ScriptEvalReadOnlyLease(script, ToInnerCopy(args), flags); - var result = Inner.ScriptEvaluateMemoryReadOnlyLease(script, ToInnerLease(args, out var lease), flags); + var result = Inner.ScriptEvalReadOnlyLease(script, ToInnerLease(args, out var lease), flags); if (lease != null) ArrayPool.Shared.Return(lease, clearArray: true); return result; diff --git a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt index 26cbd3a04..1ebabe499 100644 --- a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt @@ -23,14 +23,14 @@ StackExchange.Redis.ClusterSlotAssignment.Primary.get -> StackExchange.Redis.Clu StackExchange.Redis.ClusterSlotAssignment.Replicas.get -> System.Collections.Generic.IReadOnlyList! StackExchange.Redis.ClusterSlotAssignment.Slots.get -> StackExchange.Redis.SlotRange StackExchange.Redis.ClusterSlotNode -StackExchange.Redis.IDatabase.ScriptEvaluateMemory(string! script, System.ReadOnlyMemory args, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> StackExchange.Redis.RedisResult! -StackExchange.Redis.IDatabaseAsync.ScriptEvaluateMemoryAsync(string! script, System.ReadOnlyMemory args, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.Task! -StackExchange.Redis.IDatabase.ScriptEvaluateMemoryReadOnly(string! script, System.ReadOnlyMemory args, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> StackExchange.Redis.RedisResult! -StackExchange.Redis.IDatabaseAsync.ScriptEvaluateMemoryReadOnlyAsync(string! script, System.ReadOnlyMemory args, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.Task! -StackExchange.Redis.IDatabase.ScriptEvaluateMemoryLease(string! script, System.ReadOnlyMemory args, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> StackExchange.Redis.Lease? -StackExchange.Redis.IDatabaseAsync.ScriptEvaluateMemoryLeaseAsync(string! script, System.ReadOnlyMemory args, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.Task?>! -StackExchange.Redis.IDatabase.ScriptEvaluateMemoryReadOnlyLease(string! script, System.ReadOnlyMemory args, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> StackExchange.Redis.Lease? -StackExchange.Redis.IDatabaseAsync.ScriptEvaluateMemoryReadOnlyLeaseAsync(string! script, System.ReadOnlyMemory args, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.Task?>! +StackExchange.Redis.IDatabase.ScriptEval(string! script, System.ReadOnlyMemory args, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> StackExchange.Redis.RedisResult! +StackExchange.Redis.IDatabaseAsync.ScriptEvalAsync(string! script, System.ReadOnlyMemory args, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.Task! +StackExchange.Redis.IDatabase.ScriptEvalReadOnly(string! script, System.ReadOnlyMemory args, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> StackExchange.Redis.RedisResult! +StackExchange.Redis.IDatabaseAsync.ScriptEvalReadOnlyAsync(string! script, System.ReadOnlyMemory args, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.Task! +StackExchange.Redis.IDatabase.ScriptEvalLease(string! script, System.ReadOnlyMemory args, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> StackExchange.Redis.Lease? +StackExchange.Redis.IDatabaseAsync.ScriptEvalLeaseAsync(string! script, System.ReadOnlyMemory args, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.Task?>! +StackExchange.Redis.IDatabase.ScriptEvalReadOnlyLease(string! script, System.ReadOnlyMemory args, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> StackExchange.Redis.Lease? +StackExchange.Redis.IDatabaseAsync.ScriptEvalReadOnlyLeaseAsync(string! script, System.ReadOnlyMemory args, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.Task?>! StackExchange.Redis.RedisKeyOrValue StackExchange.Redis.RedisKeyOrValue.RedisKeyOrValue() -> void StackExchange.Redis.RedisKeyOrValue.RedisKeyOrValue(in StackExchange.Redis.RedisKey key) -> void diff --git a/src/StackExchange.Redis/RedisDatabase.cs b/src/StackExchange.Redis/RedisDatabase.cs index 6260f3cb2..6ab467e15 100644 --- a/src/StackExchange.Redis/RedisDatabase.cs +++ b/src/StackExchange.Redis/RedisDatabase.cs @@ -2024,10 +2024,10 @@ public Task ExecuteAsync(string command, ICollection? args, return ExecuteAsync(msg, ResultProcessor.ScriptResult, defaultValue: RedisResult.NullSingle); } - public RedisResult ScriptEvaluateMemory(string script, ReadOnlyMemory args, CommandFlags flags = CommandFlags.None) + public RedisResult ScriptEval(string script, ReadOnlyMemory args, CommandFlags flags = CommandFlags.None) { var command = ResultProcessor.ScriptLoadProcessor.IsSHA1(script) ? RedisCommand.EVALSHA : RedisCommand.EVAL; - var msg = new ScriptEvalMemoryMessage(Database, flags, command, script, args); + var msg = new ScriptEvalMessage(Database, flags, command, script, args); try { return ExecuteSync(msg, ResultProcessor.ScriptResult, defaultValue: RedisResult.NullSingle); @@ -2039,10 +2039,10 @@ public RedisResult ScriptEvaluateMemory(string script, ReadOnlyMemory? ScriptEvaluateMemoryLease(string script, ReadOnlyMemory args, CommandFlags flags = CommandFlags.None) + public Lease? ScriptEvalLease(string script, ReadOnlyMemory args, CommandFlags flags = CommandFlags.None) { var command = ResultProcessor.ScriptLoadProcessor.IsSHA1(script) ? RedisCommand.EVALSHA : RedisCommand.EVAL; - var msg = new ScriptEvalMemoryMessage(Database, flags, command, script, args); + var msg = new ScriptEvalMessage(Database, flags, command, script, args); try { return ExecuteSync(msg, ResultProcessor.LeaseScript); @@ -2057,7 +2057,7 @@ public RedisResult ScriptEvaluateMemory(string script, ReadOnlyMemory ScriptEvaluateMemoryAsync(string script, ReadOnlyMemory args, CommandFlags flags = CommandFlags.None) + public async Task ScriptEvalAsync(string script, ReadOnlyMemory args, CommandFlags flags = CommandFlags.None) { var command = ResultProcessor.ScriptLoadProcessor.IsSHA1(script) ? RedisCommand.EVALSHA : RedisCommand.EVAL; - var msg = new ScriptEvalMemoryMessage(Database, flags, command, script, args); + var msg = new ScriptEvalMessage(Database, flags, command, script, args); try { @@ -2101,10 +2101,10 @@ public async Task ScriptEvaluateMemoryAsync(string script, ReadOnly } } - public async Task?> ScriptEvaluateMemoryLeaseAsync(string script, ReadOnlyMemory args, CommandFlags flags = CommandFlags.None) + public async Task?> ScriptEvalLeaseAsync(string script, ReadOnlyMemory args, CommandFlags flags = CommandFlags.None) { var command = ResultProcessor.ScriptLoadProcessor.IsSHA1(script) ? RedisCommand.EVALSHA : RedisCommand.EVAL; - var msg = new ScriptEvalMemoryMessage(Database, flags, command, script, args); + var msg = new ScriptEvalMessage(Database, flags, command, script, args); try { @@ -2120,7 +2120,7 @@ public async Task ScriptEvaluateMemoryAsync(string script, ReadOnly public async Task ScriptEvaluateAsync(string script, RedisKey[]? keys = null, RedisValue[]? values = null, CommandFlags flags = CommandFlags.None) { var command = ResultProcessor.ScriptLoadProcessor.IsSHA1(script) ? RedisCommand.EVALSHA : RedisCommand.EVAL; - var msg = new ScriptEvalMessage(Database, flags, command, script, keys, values); + var msg = new ScriptEvaluateMessage(Database, flags, command, script, keys, values); try { @@ -2135,7 +2135,7 @@ public async Task ScriptEvaluateAsync(string script, RedisKey[]? ke public Task ScriptEvaluateAsync(byte[] hash, RedisKey[]? keys = null, RedisValue[]? values = null, CommandFlags flags = CommandFlags.None) { - var msg = new ScriptEvalMessage(Database, flags, RedisCommand.EVALSHA, hash, keys, values); + var msg = new ScriptEvaluateMessage(Database, flags, RedisCommand.EVALSHA, hash, keys, values); return ExecuteAsync(msg, ResultProcessor.ScriptResult, defaultValue: RedisResult.NullSingle); } @@ -2149,10 +2149,10 @@ public Task ScriptEvaluateAsync(LoadedLuaScript script, object? par return script.EvaluateAsync(this, parameters, withKeyPrefix: null, flags); } - public RedisResult ScriptEvaluateMemoryReadOnly(string script, ReadOnlyMemory args, CommandFlags flags = CommandFlags.None) + public RedisResult ScriptEvalReadOnly(string script, ReadOnlyMemory args, CommandFlags flags = CommandFlags.None) { var command = ResultProcessor.ScriptLoadProcessor.IsSHA1(script) ? RedisCommand.EVALSHA_RO : RedisCommand.EVAL_RO; - var msg = new ScriptEvalMemoryMessage(Database, flags, command, script, args); + var msg = new ScriptEvalMessage(Database, flags, command, script, args); try { return ExecuteSync(msg, ResultProcessor.ScriptResult, defaultValue: RedisResult.NullSingle); @@ -2164,10 +2164,10 @@ public RedisResult ScriptEvaluateMemoryReadOnly(string script, ReadOnlyMemory? ScriptEvaluateMemoryReadOnlyLease(string script, ReadOnlyMemory args, CommandFlags flags = CommandFlags.None) + public Lease? ScriptEvalReadOnlyLease(string script, ReadOnlyMemory args, CommandFlags flags = CommandFlags.None) { var command = ResultProcessor.ScriptLoadProcessor.IsSHA1(script) ? RedisCommand.EVALSHA_RO : RedisCommand.EVAL_RO; - var msg = new ScriptEvalMemoryMessage(Database, flags, command, script, args); + var msg = new ScriptEvalMessage(Database, flags, command, script, args); try { return ExecuteSync(msg, ResultProcessor.LeaseScript); @@ -2182,7 +2182,7 @@ public RedisResult ScriptEvaluateMemoryReadOnly(string script, ReadOnlyMemory ScriptEvaluateMemoryReadOnlyAsync(string script, ReadOnlyMemory args, CommandFlags flags = CommandFlags.None) + public async Task ScriptEvalReadOnlyAsync(string script, ReadOnlyMemory args, CommandFlags flags = CommandFlags.None) { var command = ResultProcessor.ScriptLoadProcessor.IsSHA1(script) ? RedisCommand.EVALSHA_RO : RedisCommand.EVAL_RO; - var msg = new ScriptEvalMemoryMessage(Database, flags, command, script, args); + var msg = new ScriptEvalMessage(Database, flags, command, script, args); try { return await ExecuteAsync(msg, ResultProcessor.ScriptResult, defaultValue: RedisResult.NullSingle).ForAwait(); @@ -2215,10 +2215,10 @@ public async Task ScriptEvaluateMemoryReadOnlyAsync(string script, } } - public async Task?> ScriptEvaluateMemoryReadOnlyLeaseAsync(string script, ReadOnlyMemory args, CommandFlags flags = CommandFlags.None) + public async Task?> ScriptEvalReadOnlyLeaseAsync(string script, ReadOnlyMemory args, CommandFlags flags = CommandFlags.None) { var command = ResultProcessor.ScriptLoadProcessor.IsSHA1(script) ? RedisCommand.EVALSHA_RO : RedisCommand.EVAL_RO; - var msg = new ScriptEvalMemoryMessage(Database, flags, command, script, args); + var msg = new ScriptEvalMessage(Database, flags, command, script, args); try { return await ExecuteAsync(msg, ResultProcessor.LeaseScript).ForAwait(); @@ -2233,7 +2233,7 @@ public async Task ScriptEvaluateMemoryReadOnlyAsync(string script, public async Task ScriptEvaluateReadOnlyAsync(string script, RedisKey[]? keys = null, RedisValue[]? values = null, CommandFlags flags = CommandFlags.None) { var command = ResultProcessor.ScriptLoadProcessor.IsSHA1(script) ? RedisCommand.EVALSHA_RO : RedisCommand.EVAL_RO; - var msg = new ScriptEvalMessage(Database, flags, command, script, keys, values); + var msg = new ScriptEvaluateMessage(Database, flags, command, script, keys, values); try { return await ExecuteAsync(msg, ResultProcessor.ScriptResult, defaultValue: RedisResult.NullSingle).ForAwait(); @@ -2247,7 +2247,7 @@ public async Task ScriptEvaluateReadOnlyAsync(string script, RedisK public Task ScriptEvaluateReadOnlyAsync(byte[] hash, RedisKey[]? keys = null, RedisValue[]? values = null, CommandFlags flags = CommandFlags.None) { - var msg = new ScriptEvalMessage(Database, flags, RedisCommand.EVALSHA_RO, hash, keys, values); + var msg = new ScriptEvaluateMessage(Database, flags, RedisCommand.EVALSHA_RO, hash, keys, values); return ExecuteAsync(msg, ResultProcessor.ScriptResult, defaultValue: RedisResult.NullSingle); } @@ -6169,13 +6169,13 @@ protected override bool TryGetSubCommand(out SubCommand subCommand) } } - private sealed class ScriptEvalMemoryMessage : Message, IMultiMessage + private sealed class ScriptEvalMessage : Message, IMultiMessage { private readonly ReadOnlyMemory _args; private readonly int _keysCount; private readonly string _script; private byte[]? asciiHash; - public ScriptEvalMemoryMessage(int db, CommandFlags flags, RedisCommand command, string script, ReadOnlyMemory args) + public ScriptEvalMessage(int db, CommandFlags flags, RedisCommand command, string script, ReadOnlyMemory args) : base(db, flags, command) { _script = script ?? throw new ArgumentNullException(nameof(script)); @@ -6274,7 +6274,7 @@ protected override void WriteImpl(in MessageWriter writer) public override int ArgCount => 2 + _args.Length; } - private sealed class ScriptEvalMessage : Message, IMultiMessage + private sealed class ScriptEvaluateMessage : Message, IMultiMessage { private readonly RedisKey[] keys; private readonly string? script; @@ -6282,20 +6282,20 @@ private sealed class ScriptEvalMessage : Message, IMultiMessage private byte[]? asciiHash; private readonly byte[]? hexHash; - public ScriptEvalMessage(int db, CommandFlags flags, RedisCommand command, string script, RedisKey[]? keys, RedisValue[]? values) + public ScriptEvaluateMessage(int db, CommandFlags flags, RedisCommand command, string script, RedisKey[]? keys, RedisValue[]? values) : this(db, flags, command, script, null, keys, values) { if (script == null) throw new ArgumentNullException(nameof(script)); } - public ScriptEvalMessage(int db, CommandFlags flags, RedisCommand command, byte[] hash, RedisKey[]? keys, RedisValue[]? values) + public ScriptEvaluateMessage(int db, CommandFlags flags, RedisCommand command, byte[] hash, RedisKey[]? keys, RedisValue[]? values) : this(db, flags, command, null, hash, keys, values) { if (hash == null) throw new ArgumentNullException(nameof(hash)); if (hash.Length != ResultProcessor.ScriptLoadProcessor.Sha1HashLength) throw new ArgumentOutOfRangeException(nameof(hash), "Invalid hash length"); } - private ScriptEvalMessage(int db, CommandFlags flags, RedisCommand command, string? script, byte[]? hexHash, RedisKey[]? keys, RedisValue[]? values) + private ScriptEvaluateMessage(int db, CommandFlags flags, RedisCommand command, string? script, byte[]? hexHash, RedisKey[]? keys, RedisValue[]? values) : base(db, flags, command) { this.script = script; From aaf4bdee1afe152fe9e42b0be213e0a0389ec616 Mon Sep 17 00:00:00 2001 From: Ivan Tikhonov Date: Mon, 31 Aug 2026 23:44:22 +0300 Subject: [PATCH 08/17] add IRequestDisposer, Exec, ExecLease --- .../Interfaces/IDatabase.cs | 24 +++ .../Interfaces/IDatabaseAsync.cs | 6 + .../Interfaces/IRequestDisposer.cs | 15 ++ .../KeyspaceIsolation/KeyPrefixed.cs | 18 +++ .../KeyspaceIsolation/KeyPrefixedDatabase.cs | 22 +++ .../PublicAPI/PublicAPI.Unshipped.txt | 6 + src/StackExchange.Redis/RedisDatabase.cs | 146 +++++++++++++++++- 7 files changed, 236 insertions(+), 1 deletion(-) create mode 100644 src/StackExchange.Redis/Interfaces/IRequestDisposer.cs diff --git a/src/StackExchange.Redis/Interfaces/IDatabase.cs b/src/StackExchange.Redis/Interfaces/IDatabase.cs index 36d3131b2..a0e4a93ef 100644 --- a/src/StackExchange.Redis/Interfaces/IDatabase.cs +++ b/src/StackExchange.Redis/Interfaces/IDatabase.cs @@ -1606,6 +1606,30 @@ public partial interface IDatabase : IRedis, IDatabaseAsync /// long Publish(RedisChannel channel, RedisValue message, CommandFlags flags = CommandFlags.None); + /// + /// Execute an arbitrary command against the server; this is primarily intended for executing modules, + /// but may also be used to provide access to new features that lack a direct API. + /// + /// The command to run. + /// The arguments to pass for the command. + /// The arguments data disposer. + /// The flags to use for this operation. + /// A dynamic representation of the command's result. + /// This API should be considered an advanced feature; inappropriate use can be harmful. + RedisResult Exec(string command, ReadOnlyMemory args = default, IRequestDisposer? argsDisposer = null, CommandFlags flags = CommandFlags.None); + + /// + /// Execute an arbitrary command against the server; this is primarily intended for executing modules, + /// but may also be used to provide access to new features that lack a direct API. + /// + /// The command to run. + /// The arguments to pass for the command. + /// The arguments data disposer. + /// The flags to use for this operation. + /// A dynamic representation of the command's result. + /// This API should be considered an advanced feature; inappropriate use can be harmful. + Lease? ExecLease(string command, ReadOnlyMemory args = default, IRequestDisposer? argsDisposer = null, CommandFlags flags = CommandFlags.None); + /// /// Execute an arbitrary command against the server; this is primarily intended for executing modules, /// but may also be used to provide access to new features that lack a direct API. diff --git a/src/StackExchange.Redis/Interfaces/IDatabaseAsync.cs b/src/StackExchange.Redis/Interfaces/IDatabaseAsync.cs index 6c33d7cb3..a72bccb45 100644 --- a/src/StackExchange.Redis/Interfaces/IDatabaseAsync.cs +++ b/src/StackExchange.Redis/Interfaces/IDatabaseAsync.cs @@ -396,6 +396,12 @@ public partial interface IDatabaseAsync : IRedisAsync /// Task PublishAsync(RedisChannel channel, RedisValue message, CommandFlags flags = CommandFlags.None); + /// + Task ExecAsync(string command, ReadOnlyMemory args = default, IRequestDisposer? argsDisposer = null, CommandFlags flags = CommandFlags.None); + + /// + Task?> ExecLeaseAsync(string command, ReadOnlyMemory args = default, IRequestDisposer? argsDisposer = null, CommandFlags flags = CommandFlags.None); + /// Task ExecuteAsync(string command, params object[] args); diff --git a/src/StackExchange.Redis/Interfaces/IRequestDisposer.cs b/src/StackExchange.Redis/Interfaces/IRequestDisposer.cs new file mode 100644 index 000000000..720c97013 --- /dev/null +++ b/src/StackExchange.Redis/Interfaces/IRequestDisposer.cs @@ -0,0 +1,15 @@ +using System; + +namespace StackExchange.Redis; + +/// +/// Disposing the request data. +/// +public interface IRequestDisposer +{ + /// + /// Disposing the request data. + /// + /// lua script request. + void Dispose(ReadOnlyMemory args); +} diff --git a/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixed.cs b/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixed.cs index cf7f31425..188c37c4e 100644 --- a/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixed.cs +++ b/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixed.cs @@ -410,6 +410,24 @@ public Task StringLongestCommonSubsequenceWithMatchesAsync(Redis public Task PublishAsync(RedisChannel channel, RedisValue message, CommandFlags flags = CommandFlags.None) => Inner.PublishAsync(ToInner(channel), message, flags); + public Task ExecAsync(string command, ReadOnlyMemory args = default, IRequestDisposer? argsDisposer = null, CommandFlags flags = CommandFlags.None) + { + if ((flags & CommandFlags.FireAndForget) != 0) + return Inner.ExecAsync(command, ToInnerCopy(args), argsDisposer, flags); + + var result = Inner.ExecAsync(command, ToInnerLease(args, out var lease), argsDisposer, flags); + return lease != null ? ReturnAfterResult(result, lease) : result; + } + + public Task?> ExecLeaseAsync(string command, ReadOnlyMemory args = default, IRequestDisposer? argsDisposer = null, CommandFlags flags = CommandFlags.None) + { + if ((flags & CommandFlags.FireAndForget) != 0) + return Inner.ExecLeaseAsync(command, ToInnerCopy(args), argsDisposer, flags); + + var result = Inner.ExecLeaseAsync(command, ToInnerLease(args, out var lease), argsDisposer, flags); + return lease != null ? ReturnAfterResult(result, lease) : result; + } + public Task ExecuteAsync(string command, params object[] args) => Inner.ExecuteAsync(command, ToInner(args), CommandFlags.None); diff --git a/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixedDatabase.cs b/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixedDatabase.cs index 5832f7f7a..8af74e62b 100644 --- a/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixedDatabase.cs +++ b/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixedDatabase.cs @@ -382,6 +382,28 @@ public LCSMatchResult StringLongestCommonSubsequenceWithMatches(RedisKey first, public long Publish(RedisChannel channel, RedisValue message, CommandFlags flags = CommandFlags.None) => Inner.Publish(ToInner(channel), message, flags); + public RedisResult Exec(string command, ReadOnlyMemory args = default, IRequestDisposer? argsDisposer = null, CommandFlags flags = CommandFlags.None) + { + if ((flags & CommandFlags.FireAndForget) != 0) + return Inner.Exec(command, ToInnerCopy(args), argsDisposer, flags); + + var result = Inner.Exec(command, ToInnerLease(args, out var lease), argsDisposer, flags); + if (lease != null) ArrayPool.Shared.Return(lease, clearArray: true); + + return result; + } + + public Lease? ExecLease(string command, ReadOnlyMemory args = default, IRequestDisposer? argsDisposer = null, CommandFlags flags = CommandFlags.None) + { + if ((flags & CommandFlags.FireAndForget) != 0) + return Inner.ExecLease(command, ToInnerCopy(args), argsDisposer, flags); + + var result = Inner.ExecLease(command, ToInnerLease(args, out var lease), argsDisposer, flags); + if (lease != null) ArrayPool.Shared.Return(lease, clearArray: true); + + return result; + } + public RedisResult Execute(string command, params object[] args) => Inner.Execute(command, ToInner(args), CommandFlags.None); diff --git a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt index 1ebabe499..871a38b17 100644 --- a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt @@ -23,6 +23,12 @@ StackExchange.Redis.ClusterSlotAssignment.Primary.get -> StackExchange.Redis.Clu StackExchange.Redis.ClusterSlotAssignment.Replicas.get -> System.Collections.Generic.IReadOnlyList! StackExchange.Redis.ClusterSlotAssignment.Slots.get -> StackExchange.Redis.SlotRange StackExchange.Redis.ClusterSlotNode +StackExchange.Redis.IRequestDisposer +StackExchange.Redis.IRequestDisposer.Dispose(System.ReadOnlyMemory args) -> void +StackExchange.Redis.IDatabase.Exec(string! command, System.ReadOnlyMemory args = default(System.ReadOnlyMemory), StackExchange.Redis.IRequestDisposer? argsDisposer = null, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> StackExchange.Redis.RedisResult! +StackExchange.Redis.IDatabaseAsync.ExecAsync(string! command, System.ReadOnlyMemory args = default(System.ReadOnlyMemory), StackExchange.Redis.IRequestDisposer? argsDisposer = null, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.Task! +StackExchange.Redis.IDatabase.ExecLease(string! command, System.ReadOnlyMemory args = default(System.ReadOnlyMemory), StackExchange.Redis.IRequestDisposer? argsDisposer = null, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> StackExchange.Redis.Lease? +StackExchange.Redis.IDatabaseAsync.ExecLeaseAsync(string! command, System.ReadOnlyMemory args = default(System.ReadOnlyMemory), StackExchange.Redis.IRequestDisposer? argsDisposer = null, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.Task?>! StackExchange.Redis.IDatabase.ScriptEval(string! script, System.ReadOnlyMemory args, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> StackExchange.Redis.RedisResult! StackExchange.Redis.IDatabaseAsync.ScriptEvalAsync(string! script, System.ReadOnlyMemory args, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.Task! StackExchange.Redis.IDatabase.ScriptEvalReadOnly(string! script, System.ReadOnlyMemory args, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> StackExchange.Redis.RedisResult! diff --git a/src/StackExchange.Redis/RedisDatabase.cs b/src/StackExchange.Redis/RedisDatabase.cs index 6ab467e15..0b0b92031 100644 --- a/src/StackExchange.Redis/RedisDatabase.cs +++ b/src/StackExchange.Redis/RedisDatabase.cs @@ -5,7 +5,6 @@ using System.Diagnostics.CodeAnalysis; using System.Net; using System.Runtime.CompilerServices; -using System.Runtime.InteropServices; using System.Threading; using System.Threading.Tasks; using RESPite.Messages; @@ -2006,6 +2005,18 @@ public Task PublishAsync(RedisChannel channel, RedisValue message, Command return ExecuteAsync(msg, ResultProcessor.Int64, server: multiplexer.GetSubscribedServer(channel)); } + public RedisResult Exec(string command, ReadOnlyMemory args = default, IRequestDisposer? argsDisposer = null, CommandFlags flags = CommandFlags.None) + { + var msg = new ExecMessage(multiplexer?.CommandMap, Database, flags, command, args, argsDisposer); + return ExecuteSync(msg, ResultProcessor.ScriptResult)!; + } + + public Lease? ExecLease(string command, ReadOnlyMemory args = default, IRequestDisposer? argsDisposer = null, CommandFlags flags = CommandFlags.None) + { + var msg = new ExecMessage(multiplexer?.CommandMap, Database, flags, command, args, argsDisposer); + return ExecuteSync(msg, ResultProcessor.LeaseScript); + } + public RedisResult Execute(string command, params object[] args) => Execute(command, args, CommandFlags.None); @@ -2015,6 +2026,18 @@ public RedisResult Execute(string command, ICollection args, CommandFlag return ExecuteSync(msg, ResultProcessor.ScriptResult)!; } + public Task ExecAsync(string command, ReadOnlyMemory args = default, IRequestDisposer? argsDisposer = null, CommandFlags flags = CommandFlags.None) + { + var msg = new ExecMessage(multiplexer?.CommandMap, Database, flags, command, args, argsDisposer); + return ExecuteAsync(msg, ResultProcessor.ScriptResult, defaultValue: RedisResult.NullSingle); + } + + public Task?> ExecLeaseAsync(string command, ReadOnlyMemory args = default, IRequestDisposer? argsDisposer = null, CommandFlags flags = CommandFlags.None) + { + var msg = new ExecMessage(multiplexer?.CommandMap, Database, flags, command, args, argsDisposer); + return ExecuteAsync(msg, ResultProcessor.LeaseScript); + } + public Task ExecuteAsync(string command, params object[] args) => ExecuteAsync(command, args, CommandFlags.None); @@ -6056,6 +6079,127 @@ protected override bool SetResultCore(PhysicalConnection connection, Message mes } } + internal sealed class ExecMessage : Message + { + private readonly ReadOnlyMemory _args; + private readonly IRequestDisposer? _argsDisposer; + private string _unknownCommand; + + private static int RemoveDbIfNotRequired(int suggestedDb, string adhocCommand, out RedisCommand knownCommand) + { + // attempt to parse the ad-hoc command to a known command, so we can apply correct aliasing, etc + if (!RedisCommandMetadata.TryParseCI(adhocCommand, out knownCommand)) + { + knownCommand = RedisCommand.UNKNOWN; + } + if ((knownCommand is not RedisCommand.UNKNOWN & suggestedDb >= 0) && !Message.RequiresDatabase(knownCommand)) + { + // strip the DB; historically we didn't enforce this when IDatabase was + // used to issue known commands as strings, so: don't complain now + // (this is only an issue *because* we now recognise the known commands) + suggestedDb = -1; + } + return suggestedDb; + } + + public ExecMessage(CommandMap? map, int db, CommandFlags flags, string command, ReadOnlyMemory args, IRequestDisposer? argsDisposer) + : base(RemoveDbIfNotRequired(db, command, out var knownCommand), flags, knownCommand) + { + if (args.Length >= MessageWriter.REDIS_MAX_ARGS) // using >= here because we will be adding 1 for the command itself (which is an arg for the purposes of the multi-bulk protocol) + { + throw ExceptionFactory.TooManyArgs(command, args.Length); + } + + // a redis command token never contains space, so a command like + // "ACL SETUSER x" is always a caller mistake (it gets sent as one unknown token + // and the server replies with an opaque error); fail fast with actionable guidance + if (command.IndexOf(' ') >= 0) throw ExceptionFactory.CommandHasWhitespace(command); + + map ??= CommandMap.Default; + _unknownCommand = ""; + if (Command is RedisCommand.UNKNOWN) + { + _unknownCommand = command; + } + else if (!map.IsAvailable(Command)) + { + throw ExceptionFactory.CommandDisabled(command); + } + _args = args; + _argsDisposer = argsDisposer; + } + + protected override void WriteImpl(in MessageWriter writer) + { + if (Command is RedisCommand.UNKNOWN) + { + writer.WriteHeader(_unknownCommand, _args.Length); + } + else + { + writer.WriteHeader(Command, _args.Length); + } + foreach (ref readonly var arg in _args.Span) + { + var value = arg.Value; + if (!value.IsNull) + { + writer.WriteBulkString(value); + } + else + { + var key = arg.Key; + if (!key.IsNull) + { + writer.Write(key); + } + else + { + Debug.Assert(arg.IsNull); + throw new InvalidOperationException("A null is not valid in this context"); + } + } + } + _argsDisposer?.Dispose(_args); + } + + public override string CommandString => Command is RedisCommand.UNKNOWN ? _unknownCommand : base.CommandString; + public override string CommandAndKey => CommandString; + + public override int GetHashSlot(ServerSelectionStrategy serverSelectionStrategy) + { + int slot = ServerSelectionStrategy.NoSlot; + foreach (ref readonly var arg in _args.Span) + { + var key = arg.Key; + if (!key.IsNull) + { + slot = serverSelectionStrategy.CombineSlot(slot, key); + } + } + return slot; + } + public override int ArgCount => _args.Length; + + protected override bool TryGetSubCommand(out SubCommand subCommand) + { + // the sub-command (if any) is the first argument after the command itself, + // e.g. CLIENT [GETNAME]; ad-hoc Execute args are boxed objects, so normalize + // the first one to a RedisValue before probing it against the known sub-commands + foreach (ref readonly var arg in _args.Span) + { + var value = arg.Value; + if (!value.IsNull) + { + return SubCommandMetadata.TryGetSubCommand(value, out subCommand); + } + break; // only the first argument is a sub-command candidate + } + subCommand = SubCommand.Unknown; + return false; + } + } + internal sealed class ExecuteMessage : Message { private readonly ICollection _args; From dfd611e7a85048f016ca819a6746505de6c66b59 Mon Sep 17 00:00:00 2001 From: Ivan Tikhonov Date: Tue, 1 Sep 2026 00:01:02 +0300 Subject: [PATCH 09/17] args is default to ScriptEval --- src/StackExchange.Redis/Interfaces/IDatabase.cs | 8 ++++---- .../Interfaces/IDatabaseAsync.cs | 8 ++++---- .../KeyspaceIsolation/KeyPrefixed.cs | 8 ++++---- .../KeyspaceIsolation/KeyPrefixedDatabase.cs | 8 ++++---- .../PublicAPI/PublicAPI.Unshipped.txt | 16 ++++++++-------- src/StackExchange.Redis/RedisDatabase.cs | 16 ++++++++-------- 6 files changed, 32 insertions(+), 32 deletions(-) diff --git a/src/StackExchange.Redis/Interfaces/IDatabase.cs b/src/StackExchange.Redis/Interfaces/IDatabase.cs index a0e4a93ef..10480ac52 100644 --- a/src/StackExchange.Redis/Interfaces/IDatabase.cs +++ b/src/StackExchange.Redis/Interfaces/IDatabase.cs @@ -1663,7 +1663,7 @@ public partial interface IDatabase : IRedis, IDatabaseAsync /// , /// . /// - RedisResult ScriptEval(string script, ReadOnlyMemory args, CommandFlags flags = CommandFlags.None); + RedisResult ScriptEval(string script, ReadOnlyMemory args = default, CommandFlags flags = CommandFlags.None); /// /// Execute a Lua script against the server. @@ -1677,7 +1677,7 @@ public partial interface IDatabase : IRedis, IDatabaseAsync /// , /// . /// - Lease? ScriptEvalLease(string script, ReadOnlyMemory args, CommandFlags flags = CommandFlags.None); + Lease? ScriptEvalLease(string script, ReadOnlyMemory args = default, CommandFlags flags = CommandFlags.None); /// /// Execute a Lua script against the server. @@ -1744,7 +1744,7 @@ public partial interface IDatabase : IRedis, IDatabaseAsync /// , /// . /// - RedisResult ScriptEvalReadOnly(string script, ReadOnlyMemory args, CommandFlags flags = CommandFlags.None); + RedisResult ScriptEvalReadOnly(string script, ReadOnlyMemory args = default, CommandFlags flags = CommandFlags.None); /// /// Read-only variant of the EVAL command that cannot execute commands that modify data, Execute a Lua script against the server. @@ -1758,7 +1758,7 @@ public partial interface IDatabase : IRedis, IDatabaseAsync /// , /// . /// - Lease? ScriptEvalReadOnlyLease(string script, ReadOnlyMemory args, CommandFlags flags = CommandFlags.None); + Lease? ScriptEvalReadOnlyLease(string script, ReadOnlyMemory args = default, CommandFlags flags = CommandFlags.None); /// /// Read-only variant of the EVAL command that cannot execute commands that modify data, Execute a Lua script against the server. diff --git a/src/StackExchange.Redis/Interfaces/IDatabaseAsync.cs b/src/StackExchange.Redis/Interfaces/IDatabaseAsync.cs index a72bccb45..7191d76b1 100644 --- a/src/StackExchange.Redis/Interfaces/IDatabaseAsync.cs +++ b/src/StackExchange.Redis/Interfaces/IDatabaseAsync.cs @@ -409,10 +409,10 @@ public partial interface IDatabaseAsync : IRedisAsync Task ExecuteAsync(string command, ICollection? args, CommandFlags flags = CommandFlags.None); /// - Task ScriptEvalAsync(string script, ReadOnlyMemory args, CommandFlags flags = CommandFlags.None); + Task ScriptEvalAsync(string script, ReadOnlyMemory args = default, CommandFlags flags = CommandFlags.None); /// - Task?> ScriptEvalLeaseAsync(string script, ReadOnlyMemory args, CommandFlags flags = CommandFlags.None); + Task?> ScriptEvalLeaseAsync(string script, ReadOnlyMemory args = default, CommandFlags flags = CommandFlags.None); /// Task ScriptEvaluateAsync(string script, RedisKey[]? keys = null, RedisValue[]? values = null, CommandFlags flags = CommandFlags.None); @@ -428,10 +428,10 @@ public partial interface IDatabaseAsync : IRedisAsync Task ScriptEvaluateAsync(LoadedLuaScript script, object? parameters = null, CommandFlags flags = CommandFlags.None); /// - Task ScriptEvalReadOnlyAsync(string script, ReadOnlyMemory args, CommandFlags flags = CommandFlags.None); + Task ScriptEvalReadOnlyAsync(string script, ReadOnlyMemory args = default, CommandFlags flags = CommandFlags.None); /// - Task?> ScriptEvalReadOnlyLeaseAsync(string script, ReadOnlyMemory args, CommandFlags flags = CommandFlags.None); + Task?> ScriptEvalReadOnlyLeaseAsync(string script, ReadOnlyMemory args = default, CommandFlags flags = CommandFlags.None); /// Task ScriptEvaluateReadOnlyAsync(string script, RedisKey[]? keys = null, RedisValue[]? values = null, CommandFlags flags = CommandFlags.None); diff --git a/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixed.cs b/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixed.cs index 188c37c4e..cd3a23d14 100644 --- a/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixed.cs +++ b/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixed.cs @@ -438,7 +438,7 @@ public Task ScriptEvaluateAsync(byte[] hash, RedisKey[]? keys = nul // TODO: The return value could contain prefixed keys. It might make sense to 'unprefix' those? Inner.ScriptEvaluateAsync(hash, ToInner(keys), values, flags); - public Task ScriptEvalAsync(string script, ReadOnlyMemory args, CommandFlags flags = CommandFlags.None) + public Task ScriptEvalAsync(string script, ReadOnlyMemory args = default, CommandFlags flags = CommandFlags.None) { if ((flags & CommandFlags.FireAndForget) != 0) return Inner.ScriptEvalAsync(script, ToInnerCopy(args), flags); @@ -447,7 +447,7 @@ public Task ScriptEvalAsync(string script, ReadOnlyMemory?> ScriptEvalLeaseAsync(string script, ReadOnlyMemory args, CommandFlags flags = CommandFlags.None) + public Task?> ScriptEvalLeaseAsync(string script, ReadOnlyMemory args = default, CommandFlags flags = CommandFlags.None) { if ((flags & CommandFlags.FireAndForget) != 0) return Inner.ScriptEvalLeaseAsync(script, ToInnerCopy(args), flags); @@ -472,7 +472,7 @@ public Task ScriptEvaluateReadOnlyAsync(byte[] hash, RedisKey[]? ke // TODO: The return value could contain prefixed keys. It might make sense to 'unprefix' those? Inner.ScriptEvaluateAsync(hash, ToInner(keys), values, flags); - public Task ScriptEvalReadOnlyAsync(string script, ReadOnlyMemory args, CommandFlags flags = CommandFlags.None) + public Task ScriptEvalReadOnlyAsync(string script, ReadOnlyMemory args = default, CommandFlags flags = CommandFlags.None) { if ((flags & CommandFlags.FireAndForget) != 0) return Inner.ScriptEvalReadOnlyAsync(script, ToInnerCopy(args), flags); @@ -481,7 +481,7 @@ public Task ScriptEvalReadOnlyAsync(string script, ReadOnlyMemory?> ScriptEvalReadOnlyLeaseAsync(string script, ReadOnlyMemory args, CommandFlags flags = CommandFlags.None) + public Task?> ScriptEvalReadOnlyLeaseAsync(string script, ReadOnlyMemory args = default, CommandFlags flags = CommandFlags.None) { if ((flags & CommandFlags.FireAndForget) != 0) return Inner.ScriptEvalReadOnlyLeaseAsync(script, ToInnerCopy(args), flags); diff --git a/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixedDatabase.cs b/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixedDatabase.cs index 8af74e62b..151296dba 100644 --- a/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixedDatabase.cs +++ b/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixedDatabase.cs @@ -414,7 +414,7 @@ public RedisResult ScriptEvaluate(byte[] hash, RedisKey[]? keys = null, RedisVal // TODO: The return value could contain prefixed keys. It might make sense to 'unprefix' those? Inner.ScriptEvaluate(hash, ToInner(keys), values, flags); - public RedisResult ScriptEval(string script, ReadOnlyMemory args, CommandFlags flags = CommandFlags.None) + public RedisResult ScriptEval(string script, ReadOnlyMemory args = default, CommandFlags flags = CommandFlags.None) { if ((flags & CommandFlags.FireAndForget) != 0) return Inner.ScriptEval(script, ToInnerCopy(args), flags); @@ -425,7 +425,7 @@ public RedisResult ScriptEval(string script, ReadOnlyMemory arg return result; } - public Lease? ScriptEvalLease(string script, ReadOnlyMemory args, CommandFlags flags = CommandFlags.None) + public Lease? ScriptEvalLease(string script, ReadOnlyMemory args = default, CommandFlags flags = CommandFlags.None) { if ((flags & CommandFlags.FireAndForget) != 0) return Inner.ScriptEvalLease(script, ToInnerCopy(args), flags); @@ -452,7 +452,7 @@ public RedisResult ScriptEvaluateReadOnly(byte[] hash, RedisKey[]? keys = null, // TODO: The return value could contain prefixed keys. It might make sense to 'unprefix' those? Inner.ScriptEvaluateReadOnly(hash, ToInner(keys), values, flags); - public RedisResult ScriptEvalReadOnly(string script, ReadOnlyMemory args, CommandFlags flags = CommandFlags.None) + public RedisResult ScriptEvalReadOnly(string script, ReadOnlyMemory args = default, CommandFlags flags = CommandFlags.None) { if ((flags & CommandFlags.FireAndForget) != 0) return Inner.ScriptEvalReadOnly(script, ToInnerCopy(args), flags); @@ -463,7 +463,7 @@ public RedisResult ScriptEvalReadOnly(string script, ReadOnlyMemory? ScriptEvalReadOnlyLease(string script, ReadOnlyMemory args, CommandFlags flags = CommandFlags.None) + public Lease? ScriptEvalReadOnlyLease(string script, ReadOnlyMemory args = default, CommandFlags flags = CommandFlags.None) { if ((flags & CommandFlags.FireAndForget) != 0) return Inner.ScriptEvalReadOnlyLease(script, ToInnerCopy(args), flags); diff --git a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt index 871a38b17..73490bb4c 100644 --- a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt @@ -29,14 +29,14 @@ StackExchange.Redis.IDatabase.Exec(string! command, System.ReadOnlyMemory args = default(System.ReadOnlyMemory), StackExchange.Redis.IRequestDisposer? argsDisposer = null, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.Task! StackExchange.Redis.IDatabase.ExecLease(string! command, System.ReadOnlyMemory args = default(System.ReadOnlyMemory), StackExchange.Redis.IRequestDisposer? argsDisposer = null, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> StackExchange.Redis.Lease? StackExchange.Redis.IDatabaseAsync.ExecLeaseAsync(string! command, System.ReadOnlyMemory args = default(System.ReadOnlyMemory), StackExchange.Redis.IRequestDisposer? argsDisposer = null, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.Task?>! -StackExchange.Redis.IDatabase.ScriptEval(string! script, System.ReadOnlyMemory args, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> StackExchange.Redis.RedisResult! -StackExchange.Redis.IDatabaseAsync.ScriptEvalAsync(string! script, System.ReadOnlyMemory args, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.Task! -StackExchange.Redis.IDatabase.ScriptEvalReadOnly(string! script, System.ReadOnlyMemory args, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> StackExchange.Redis.RedisResult! -StackExchange.Redis.IDatabaseAsync.ScriptEvalReadOnlyAsync(string! script, System.ReadOnlyMemory args, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.Task! -StackExchange.Redis.IDatabase.ScriptEvalLease(string! script, System.ReadOnlyMemory args, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> StackExchange.Redis.Lease? -StackExchange.Redis.IDatabaseAsync.ScriptEvalLeaseAsync(string! script, System.ReadOnlyMemory args, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.Task?>! -StackExchange.Redis.IDatabase.ScriptEvalReadOnlyLease(string! script, System.ReadOnlyMemory args, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> StackExchange.Redis.Lease? -StackExchange.Redis.IDatabaseAsync.ScriptEvalReadOnlyLeaseAsync(string! script, System.ReadOnlyMemory args, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.Task?>! +StackExchange.Redis.IDatabase.ScriptEval(string! script, System.ReadOnlyMemory args = default(System.ReadOnlyMemory), StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> StackExchange.Redis.RedisResult! +StackExchange.Redis.IDatabaseAsync.ScriptEvalAsync(string! script, System.ReadOnlyMemory args = default(System.ReadOnlyMemory), StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.Task! +StackExchange.Redis.IDatabase.ScriptEvalReadOnly(string! script, System.ReadOnlyMemory args = default(System.ReadOnlyMemory), StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> StackExchange.Redis.RedisResult! +StackExchange.Redis.IDatabaseAsync.ScriptEvalReadOnlyAsync(string! script, System.ReadOnlyMemory args = default(System.ReadOnlyMemory), StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.Task! +StackExchange.Redis.IDatabase.ScriptEvalLease(string! script, System.ReadOnlyMemory args = default(System.ReadOnlyMemory), StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> StackExchange.Redis.Lease? +StackExchange.Redis.IDatabaseAsync.ScriptEvalLeaseAsync(string! script, System.ReadOnlyMemory args = default(System.ReadOnlyMemory), StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.Task?>! +StackExchange.Redis.IDatabase.ScriptEvalReadOnlyLease(string! script, System.ReadOnlyMemory args = default(System.ReadOnlyMemory), StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> StackExchange.Redis.Lease? +StackExchange.Redis.IDatabaseAsync.ScriptEvalReadOnlyLeaseAsync(string! script, System.ReadOnlyMemory args = default(System.ReadOnlyMemory), StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.Task?>! StackExchange.Redis.RedisKeyOrValue StackExchange.Redis.RedisKeyOrValue.RedisKeyOrValue() -> void StackExchange.Redis.RedisKeyOrValue.RedisKeyOrValue(in StackExchange.Redis.RedisKey key) -> void diff --git a/src/StackExchange.Redis/RedisDatabase.cs b/src/StackExchange.Redis/RedisDatabase.cs index 0b0b92031..74274d14e 100644 --- a/src/StackExchange.Redis/RedisDatabase.cs +++ b/src/StackExchange.Redis/RedisDatabase.cs @@ -2047,7 +2047,7 @@ public Task ExecuteAsync(string command, ICollection? args, return ExecuteAsync(msg, ResultProcessor.ScriptResult, defaultValue: RedisResult.NullSingle); } - public RedisResult ScriptEval(string script, ReadOnlyMemory args, CommandFlags flags = CommandFlags.None) + public RedisResult ScriptEval(string script, ReadOnlyMemory args = default, CommandFlags flags = CommandFlags.None) { var command = ResultProcessor.ScriptLoadProcessor.IsSHA1(script) ? RedisCommand.EVALSHA : RedisCommand.EVAL; var msg = new ScriptEvalMessage(Database, flags, command, script, args); @@ -2062,7 +2062,7 @@ public RedisResult ScriptEval(string script, ReadOnlyMemory arg } } - public Lease? ScriptEvalLease(string script, ReadOnlyMemory args, CommandFlags flags = CommandFlags.None) + public Lease? ScriptEvalLease(string script, ReadOnlyMemory args = default, CommandFlags flags = CommandFlags.None) { var command = ResultProcessor.ScriptLoadProcessor.IsSHA1(script) ? RedisCommand.EVALSHA : RedisCommand.EVAL; var msg = new ScriptEvalMessage(Database, flags, command, script, args); @@ -2108,7 +2108,7 @@ public RedisResult ScriptEvaluate(LoadedLuaScript script, object? parameters = n return script.Evaluate(this, parameters, withKeyPrefix: null, flags); } - public async Task ScriptEvalAsync(string script, ReadOnlyMemory args, CommandFlags flags = CommandFlags.None) + public async Task ScriptEvalAsync(string script, ReadOnlyMemory args = default, CommandFlags flags = CommandFlags.None) { var command = ResultProcessor.ScriptLoadProcessor.IsSHA1(script) ? RedisCommand.EVALSHA : RedisCommand.EVAL; var msg = new ScriptEvalMessage(Database, flags, command, script, args); @@ -2124,7 +2124,7 @@ public async Task ScriptEvalAsync(string script, ReadOnlyMemory?> ScriptEvalLeaseAsync(string script, ReadOnlyMemory args, CommandFlags flags = CommandFlags.None) + public async Task?> ScriptEvalLeaseAsync(string script, ReadOnlyMemory args = default, CommandFlags flags = CommandFlags.None) { var command = ResultProcessor.ScriptLoadProcessor.IsSHA1(script) ? RedisCommand.EVALSHA : RedisCommand.EVAL; var msg = new ScriptEvalMessage(Database, flags, command, script, args); @@ -2172,7 +2172,7 @@ public Task ScriptEvaluateAsync(LoadedLuaScript script, object? par return script.EvaluateAsync(this, parameters, withKeyPrefix: null, flags); } - public RedisResult ScriptEvalReadOnly(string script, ReadOnlyMemory args, CommandFlags flags = CommandFlags.None) + public RedisResult ScriptEvalReadOnly(string script, ReadOnlyMemory args = default, CommandFlags flags = CommandFlags.None) { var command = ResultProcessor.ScriptLoadProcessor.IsSHA1(script) ? RedisCommand.EVALSHA_RO : RedisCommand.EVAL_RO; var msg = new ScriptEvalMessage(Database, flags, command, script, args); @@ -2187,7 +2187,7 @@ public RedisResult ScriptEvalReadOnly(string script, ReadOnlyMemory? ScriptEvalReadOnlyLease(string script, ReadOnlyMemory args, CommandFlags flags = CommandFlags.None) + public Lease? ScriptEvalReadOnlyLease(string script, ReadOnlyMemory args = default, CommandFlags flags = CommandFlags.None) { var command = ResultProcessor.ScriptLoadProcessor.IsSHA1(script) ? RedisCommand.EVALSHA_RO : RedisCommand.EVAL_RO; var msg = new ScriptEvalMessage(Database, flags, command, script, args); @@ -2223,7 +2223,7 @@ public RedisResult ScriptEvaluateReadOnly(byte[] hash, RedisKey[]? keys = null, return ExecuteSync(msg, ResultProcessor.ScriptResult, defaultValue: RedisResult.NullSingle); } - public async Task ScriptEvalReadOnlyAsync(string script, ReadOnlyMemory args, CommandFlags flags = CommandFlags.None) + public async Task ScriptEvalReadOnlyAsync(string script, ReadOnlyMemory args = default, CommandFlags flags = CommandFlags.None) { var command = ResultProcessor.ScriptLoadProcessor.IsSHA1(script) ? RedisCommand.EVALSHA_RO : RedisCommand.EVAL_RO; var msg = new ScriptEvalMessage(Database, flags, command, script, args); @@ -2238,7 +2238,7 @@ public async Task ScriptEvalReadOnlyAsync(string script, ReadOnlyMe } } - public async Task?> ScriptEvalReadOnlyLeaseAsync(string script, ReadOnlyMemory args, CommandFlags flags = CommandFlags.None) + public async Task?> ScriptEvalReadOnlyLeaseAsync(string script, ReadOnlyMemory args = default, CommandFlags flags = CommandFlags.None) { var command = ResultProcessor.ScriptLoadProcessor.IsSHA1(script) ? RedisCommand.EVALSHA_RO : RedisCommand.EVAL_RO; var msg = new ScriptEvalMessage(Database, flags, command, script, args); From 3558ca26dc559c42c27cb3ae62c4b83f4841dbf2 Mon Sep 17 00:00:00 2001 From: Ivan Tikhonov Date: Tue, 1 Sep 2026 00:57:11 +0300 Subject: [PATCH 10/17] You cannot return an array twice or an array that is not from the pool. --- src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixed.cs | 6 ++++++ .../KeyspaceIsolation/KeyPrefixedDatabase.cs | 6 ++++++ 2 files changed, 12 insertions(+) diff --git a/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixed.cs b/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixed.cs index cd3a23d14..f3d8363ce 100644 --- a/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixed.cs +++ b/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixed.cs @@ -412,6 +412,9 @@ public Task PublishAsync(RedisChannel channel, RedisValue message, Command public Task ExecAsync(string command, ReadOnlyMemory args = default, IRequestDisposer? argsDisposer = null, CommandFlags flags = CommandFlags.None) { + // You cannot return an array twice or an array that is not from the pool. + argsDisposer = null; + if ((flags & CommandFlags.FireAndForget) != 0) return Inner.ExecAsync(command, ToInnerCopy(args), argsDisposer, flags); @@ -421,6 +424,9 @@ public Task ExecAsync(string command, ReadOnlyMemory?> ExecLeaseAsync(string command, ReadOnlyMemory args = default, IRequestDisposer? argsDisposer = null, CommandFlags flags = CommandFlags.None) { + // You cannot return an array twice or an array that is not from the pool. + argsDisposer = null; + if ((flags & CommandFlags.FireAndForget) != 0) return Inner.ExecLeaseAsync(command, ToInnerCopy(args), argsDisposer, flags); diff --git a/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixedDatabase.cs b/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixedDatabase.cs index 151296dba..4ea97229c 100644 --- a/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixedDatabase.cs +++ b/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixedDatabase.cs @@ -384,6 +384,9 @@ public long Publish(RedisChannel channel, RedisValue message, CommandFlags flags public RedisResult Exec(string command, ReadOnlyMemory args = default, IRequestDisposer? argsDisposer = null, CommandFlags flags = CommandFlags.None) { + // You cannot return an array twice or an array that is not from the pool. + argsDisposer = null; + if ((flags & CommandFlags.FireAndForget) != 0) return Inner.Exec(command, ToInnerCopy(args), argsDisposer, flags); @@ -395,6 +398,9 @@ public RedisResult Exec(string command, ReadOnlyMemory args = d public Lease? ExecLease(string command, ReadOnlyMemory args = default, IRequestDisposer? argsDisposer = null, CommandFlags flags = CommandFlags.None) { + // You cannot return an array twice or an array that is not from the pool. + argsDisposer = null; + if ((flags & CommandFlags.FireAndForget) != 0) return Inner.ExecLease(command, ToInnerCopy(args), argsDisposer, flags); From 2631112c0b7fac15c8b1e045c0145c85cbfc2f86 Mon Sep 17 00:00:00 2001 From: Ivan Tikhonov Date: Wed, 2 Sep 2026 11:21:56 +0300 Subject: [PATCH 11/17] internal RedisKeyOrValue.StorageType --- src/StackExchange.Redis/RedisDatabase.cs | 34 ++++++++++------------ src/StackExchange.Redis/RedisKeyOrValue.cs | 8 ++--- 2 files changed, 20 insertions(+), 22 deletions(-) diff --git a/src/StackExchange.Redis/RedisDatabase.cs b/src/StackExchange.Redis/RedisDatabase.cs index 74274d14e..49b91db2f 100644 --- a/src/StackExchange.Redis/RedisDatabase.cs +++ b/src/StackExchange.Redis/RedisDatabase.cs @@ -6141,23 +6141,19 @@ protected override void WriteImpl(in MessageWriter writer) } foreach (ref readonly var arg in _args.Span) { - var value = arg.Value; - if (!value.IsNull) + var type = arg.Type; + if (type == RedisKeyOrValue.StorageType.Value) { - writer.WriteBulkString(value); + writer.WriteBulkString(arg.UnsafeValue); + } + else if (type == RedisKeyOrValue.StorageType.Key) + { + writer.Write(arg.UnsafeKey); } else { - var key = arg.Key; - if (!key.IsNull) - { - writer.Write(key); - } - else - { - Debug.Assert(arg.IsNull); - throw new InvalidOperationException("A null is not valid in this context"); - } + Debug.Assert(type == RedisKeyOrValue.StorageType.Null); + throw new InvalidOperationException("A null is not valid in this context"); } } _argsDisposer?.Dispose(_args); @@ -6328,14 +6324,15 @@ public ScriptEvalMessage(int db, CommandFlags flags, RedisCommand command, strin var span = args.Span; foreach (ref readonly var arg in span) { - if (arg.IsNull) throw new ArgumentException("A null is not valid in this context", nameof(args)); - if (arg.IsKey) + var type = arg.Type; + if (type == RedisKeyOrValue.StorageType.Null) throw new ArgumentException("A null is not valid in this context", nameof(args)); + if (type == RedisKeyOrValue.StorageType.Key) { keysCount++; } else { - Debug.Assert(arg.IsValue); + Debug.Assert(type == RedisKeyOrValue.StorageType.Value); break; } } @@ -6344,8 +6341,9 @@ public ScriptEvalMessage(int db, CommandFlags flags, RedisCommand command, strin { foreach (ref readonly var arg in span.Slice(keysCount + 1)) { - if (!arg.IsValue) - throw new ArgumentException(arg.IsNull ? "A null is not valid in this context" : "A key is not valid in this context. Keys must come before values.", nameof(args)); + var type = arg.Type; + if (type != RedisKeyOrValue.StorageType.Value) + throw new ArgumentException(type == RedisKeyOrValue.StorageType.Null ? "A null is not valid in this context" : "A key is not valid in this context. Keys must come before values.", nameof(args)); } } _args = args; diff --git a/src/StackExchange.Redis/RedisKeyOrValue.cs b/src/StackExchange.Redis/RedisKeyOrValue.cs index 4f023a97d..e64ab7352 100644 --- a/src/StackExchange.Redis/RedisKeyOrValue.cs +++ b/src/StackExchange.Redis/RedisKeyOrValue.cs @@ -11,7 +11,7 @@ namespace StackExchange.Redis; [StructLayout(LayoutKind.Explicit)] public readonly struct RedisKeyOrValue : IEquatable, IEquatable, IEquatable { - private enum StorageType + internal enum StorageType { Null, Key, @@ -24,7 +24,7 @@ private enum StorageType [FieldOffset(8)] private readonly object? _obj; #pragma warning restore SA1134 - private StorageType Type + internal StorageType Type { get { @@ -35,7 +35,7 @@ private StorageType Type } } - private RedisKey UnsafeKey + internal RedisKey UnsafeKey { get { @@ -45,7 +45,7 @@ private RedisKey UnsafeKey } } - private RedisValue UnsafeValue + internal RedisValue UnsafeValue { get { From 42f8e9d9b51b4eaad553d338b143c67dfe0effb0 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Thu, 3 Sep 2026 15:39:42 +0100 Subject: [PATCH 12/17] Rework the Lua/ad-hoc command response path around a low-allocation RespResult Ivan's PR introduced the core idea: typed key/value args for EVAL and ad-hoc commands, plus a low-allocation way to read the reply. This reworks the implementation end-to-end while keeping that idea intact: - New RespResult type: a leased, undecoded view over a raw RESP reply (IDisposable, backed by a pooled buffer), replacing the original Lease-returning design. Three null singletons preserve which of the RESP2/RESP3 null encodings was actually on the wire. - Public API settled on full words, no abbreviations: ExecuteResp(Async), ScriptEvaluateResp(Async), ScriptEvaluateReadOnlyResp(Async). ScriptEvaluateResp takes separate keys/values (ReadOnlyMemory, ReadOnlyMemory) since Lua's KEYS/ARGV never interleave; ExecuteResp keeps ReadOnlyMemory since an arbitrary command can place a key anywhere in its argument list. - RedisKeyOrValue rewritten from an unsafe StructLayout/Unsafe.As union to a safe RedisValue + byte[]? _keyPrefix design, so it actually supports KeyPrefixed key-prefix rewriting (the original layout did not). Construction funnels through FromKey/FromValue and implicit operators only, to avoid ambiguity with bare literals. - IRequestDisposer removed: it fired on every WriteImpl, which re-runs on retry/redirect, making it unsound. RespResult's own IDisposable supersedes it. - KeyPrefixedDatabase/KeyPrefixed: both wrappers now do a fire-and-forget-aware copy/lease split for all three Resp methods (not just Execute), and return the pooled buffer to ArrayPool on success *or* RedisServerException - both mean the server fully received and processed the write, so a retry can no longer be using the same buffer. Zero-key/all-value calls (e.g. RediSearch-style ad-hoc commands) pass through with no allocation at all. - [AutoDatabase]: added the two IRedisArgsMutator.Map overloads (ReadOnlyMemory, ReadOnlyMemory) that were missing, so key-prefixing via [AutoDatabase] is actually possible for these methods once something needs it (verified against a temporarily mutator-flipped RetryDatabase, then reverted). - New RespReader.TryGetRawSpan/CopyRawTo (RESPite) to capture a raw frame before decoding, which RespResult's capture path depends on. - RespReader/RespPrefix/RespException/RespAttributeReader (RESPite) and RespResult/RespReaderExtensions (StackExchange.Redis) are no longer behind the SER004 experimental diagnostic - this is the intended, stable read path for the new API. The wire-level IO internals underneath (buffer pooling, frame scanning) remain experimental. - Docs: new Execute.md, rewritten Scripting.md - basic use, reading results, leasing the argument buffer on a hot path, walking tree replies via AggregateChildren/ReadPastArray. - Tests: unit tests against raw RESP strings, live-server integration tests (RunPerProtocol), NSubstitute mock tests for KeyPrefixedDatabase, and coverage for non-scalar replies (AggregateChildren, ReadPastArray, nested sub-arrays, a real array-returning ExecuteResp call). Measured: reading a scalar/blob reply via ExecuteResp/ScriptEvaluateResp + ReadLease()/CopyTo() instead of Execute/ScriptEvaluate + (byte[])result cuts client-side allocation per call by roughly 50-95%, scaling with the size of the blob. Co-authored-by: Ivan Tikhonov --- docs/Execute.md | 82 ++++++ docs/Scripting.md | 110 ++++--- docs/exp/SER004.md | 4 + docs/index.md | 3 +- src/RESPite/Messages/RespAttributeReader.cs | 5 +- src/RESPite/Messages/RespPrefix.cs | 5 +- src/RESPite/Messages/RespReader.cs | 75 ++++- src/RESPite/PublicAPI/PublicAPI.Shipped.txt | 254 ++++++++-------- src/RESPite/PublicAPI/PublicAPI.Unshipped.txt | 2 + .../PublicAPI/Release/PublicAPI.Shipped.txt | 2 +- .../PublicAPI/net8.0/PublicAPI.Shipped.txt | 4 +- src/RESPite/RespException.cs | 5 +- src/StackExchange.Redis/AutoDatabase.cs | 32 +++ .../Interfaces/IDatabase.cs | 79 ++--- .../Interfaces/IDatabaseAsync.cs | 23 +- .../Interfaces/IRequestDisposer.cs | 15 - .../KeyspaceIsolation/KeyPrefixed.cs | 136 ++++++--- .../KeyspaceIsolation/KeyPrefixedDatabase.cs | 99 ++++--- .../PhysicalConnection.Read.cs | 4 +- .../PublicAPI/PublicAPI.Unshipped.txt | 37 +-- src/StackExchange.Redis/RedisDatabase.cs | 197 +++---------- src/StackExchange.Redis/RedisKeyOrValue.cs | 197 ++++--------- src/StackExchange.Redis/RedisValue.cs | 7 + .../RespReaderExtensions.cs | 270 +++++------------- .../RespReaderInternalExtensions.cs | 167 +++++++++++ src/StackExchange.Redis/RespResult.cs | 139 +++++++++ .../ResultProcessor.Lease.cs | 3 - .../ResultProcessor.RespResult.cs | 37 +++ .../KeyPrefixedDatabaseTests.cs | 75 +++++ .../StackExchange.Redis.Tests/NamingTests.cs | 2 + .../RespResultTests.cs | 260 +++++++++++++++++ .../RespResultProcessor.cs | 182 ++++++++++++ 32 files changed, 1632 insertions(+), 880 deletions(-) create mode 100644 docs/Execute.md delete mode 100644 src/StackExchange.Redis/Interfaces/IRequestDisposer.cs create mode 100644 src/StackExchange.Redis/RespReaderInternalExtensions.cs create mode 100644 src/StackExchange.Redis/RespResult.cs create mode 100644 src/StackExchange.Redis/ResultProcessor.RespResult.cs create mode 100644 tests/StackExchange.Redis.Tests/RespResultTests.cs create mode 100644 tests/StackExchange.Redis.Tests/ResultProcessorUnitTests/RespResultProcessor.cs diff --git a/docs/Execute.md b/docs/Execute.md new file mode 100644 index 000000000..8dee3f3d4 --- /dev/null +++ b/docs/Execute.md @@ -0,0 +1,82 @@ +Ad-hoc commands +=== + +`IDatabase.ExecuteResp(Async)` and `IDatabase.Execute(Async)` let you send a command that doesn't (yet) have a dedicated API - typically for a module, or a brand-new server feature the client hasn't caught up with. `ExecuteResp` is the modern, low-allocation-friendly overload; `Execute` is the original, `object[]`/`ICollection`-based overload, kept for compatibility. + +Basic use +--- + +`ExecuteResp` takes the command name and a single `ReadOnlyMemory` of arguments, in whatever order the command itself expects them - it's the command, not the API, that decides where keys fall in the argument list (unlike [`ScriptEvaluateResp`](Scripting), where Lua's `KEYS`/`ARGV` never interleave, an arbitrary command can place keys anywhere, so a single ordered collection is used rather than two separate ones). Wrap each argument as a key or a value to match what the command expects at that position: + +```csharp +using ConnectionMultiplexer conn = /* init code */; +var db = conn.GetDatabase(); + +// note: see "Leasing the argument buffer" below +using RespResult result = db.ExecuteResp("GET", new RedisKeyOrValue[] { (RedisKey)"mykey" }); +// ... +``` + +Keys passed this way participate in cluster slot routing and `KeyPrefixed` key-prefixing, just like a key argument to any built-in command. `ExecuteResp` recognizes known command names (applying the same command-map renaming/disabling rules as everything else) and falls back to treating the command as opaque only if it isn't recognized. + +The `new RedisKeyOrValue[]` above is fine for occasional use, but allocates on every call - see [Leasing the argument buffer](#leasing-the-argument-buffer) below for the low-allocation form once you're on a hot path. + +Reading the result +--- + +`ExecuteResp` returns a `RespResult` - a leased, undecoded view over the raw reply, backed by a pooled buffer rather than a fresh allocation per call. This is the more general form of the low-allocation pattern also used by [`ScriptEvaluateResp`](Scripting) - it's how you'd fetch a large blob value via an ad-hoc command without materializing a `RedisResult` wrapper on every call: + +```csharp +// note: see "Leasing the argument buffer" below +using RespResult result = db.ExecuteResp("GET", new RedisKeyOrValue[] { (RedisKey)"mykey" }); +if (!result.IsNull) +{ + RedisValue value = result.ReadScalar().ReadRedisValue(); + // use value ... +} +// on a genuinely hot path, ReadScalar().ScalarLength() + .CopyTo(yourBuffer) avoids +// even that allocation, by copying straight into a buffer you already own +``` + +See [Scripting](Scripting#reading-the-result-respresult-vs-redisresult) for the full rundown of `RespResult` - `IsNull`/`Prefix`, `ReadScalar()`/`Read()`, and the `ReadRedisValue`/`ReadLease`/`ReadRedisResult` accessors - it applies identically here; `ExecuteResp` and `ScriptEvaluateResp` share the same response-reading API, only the request differs (a command name instead of a script). + +Measured effect +--- + +For a single scalar (blob) reply, reading it via `ExecuteResp`/`ScriptEvaluateResp` + `ReadLease()`/`CopyTo()` instead of the classic `Execute`/`ScriptEvaluate` + `(byte[])result` measured at roughly **50-95% less client-side allocation per call**, scaling up with the size of the blob (the old path always allocates a fresh array sized to the payload; the new path reuses a pooled one). + +Leasing the argument buffer +--- + +The examples above allocate a fresh `RedisKeyOrValue[]` per call, which rather defeats the point of an API whose main selling point is low allocation. On a hot path, rent the array from `ArrayPool.Shared` instead - but the buffer can only be recycled once you know the server has fully received the write. For a synchronous call, that's once `ExecuteResp` has returned successfully *or* thrown `RedisServerException` (the server still definitely got the command - it just responded with an error). Any other exception (a timeout, a dropped connection) can mean a retry is still using the same buffer, so don't recycle it in that case: + +```csharp +var args = ArrayPool.Shared.Rent(1); // usually larger! +args[0] = (RedisKey)"mykey"; +var canReturn = true; +try +{ + using RespResult result = db.ExecuteResp("GET", args.AsMemory(0, 1)); + // use result... +} +catch (RedisServerException) +{ + throw; // the server responded - still safe to recycle below +} +catch +{ + canReturn = false; // e.g. a timeout/connection failure - a retry may still need this buffer + throw; +} +finally +{ + if (canReturn) ArrayPool.Shared.Return(args, clearArray: true); +} +``` + +The same rule applies to `ExecuteRespAsync` - just `await` the call before deciding whether it's safe to return the lease. + +The original `Execute`/`ExecuteAsync` overload +--- + +`Execute(string command, params object[] args)` / `Execute(string command, ICollection args, CommandFlags flags)` predate `RedisKeyOrValue` and `ExecuteResp`. They accept a loosely-typed bag of `object`s (each boxed to `RedisKey`/`RedisValue`/etc. internally) and always return a fully-materialized `RedisResult`. They still work and aren't going away, but for new code prefer `ExecuteResp`/`ExecuteRespAsync` - typed `RedisKeyOrValue` args, no boxing, and low-allocation on the read side. diff --git a/docs/Scripting.md b/docs/Scripting.md index 56af7afc1..eb4a4340b 100644 --- a/docs/Scripting.md +++ b/docs/Scripting.md @@ -1,59 +1,97 @@ -Scripting +Scripting === -Basic [Lua scripting](https://redis.io/commands/EVAL) is supported by the `IServer.ScriptLoad(Async)`, `IServer.ScriptExists(Async)`, `IServer.ScriptFlush(Async)`, `IDatabase.ScriptEvaluate`, and `IDatabaseAsync.ScriptEvaluateAsync` methods. -These methods expose the basic commands necessary to submit and execute Lua scripts to redis. +[Lua scripting](https://redis.io/commands/EVAL) lets you run a script server-side, atomically, in one round trip. StackExchange.Redis exposes this through `IDatabase.ScriptEvaluateResp(Async)` (and the read-only `ScriptEvaluateReadOnlyResp(Async)` twin), plus the `IServer.ScriptLoad(Async)`/`ScriptExists(Async)`/`ScriptFlush(Async)` support commands. -More sophisticated scripting is available through the `LuaScript` class. The `LuaScript` class makes it simpler to prepare and submit parameters along with a script, as well as allowing you to use cleaner variables names. +Basic use +--- -An example use of the `LuaScript`: +`ScriptEvaluateResp` takes the script text, the keys (available to the script as `KEYS`), and the values (available as `ARGV`) as two separate `ReadOnlyMemory`/`ReadOnlyMemory` parameters - kept separate deliberately, since the script indexes them separately too (`KEYS[1]`, `ARGV[1]`, ...); there's no benefit to the caller in combining them into one collection: ```csharp -const string Script = "redis.call('set', @key, @value)"; +using ConnectionMultiplexer conn = /* init code */; +var db = conn.GetDatabase(); + +using RespResult result = db.ScriptEvaluateResp( + "return redis.call('set', KEYS[1], ARGV[1])", + new RedisKey[] { "mykey" }, + new RedisValue[] { 123 }); +``` + +Keys participate in cluster slot routing and (if you're using `KeyPrefixed`) key-prefixing, exactly like any other command's keys - values do not. The script itself is cached automatically: the first call sends the full script text (`EVAL`); subsequent calls with the same script send only its SHA1 hash (`EVALSHA`), and a transparent retry re-sends the full script if the server reports `NOSCRIPT` (for example after a `SCRIPT FLUSH`). + +`ScriptEvaluateReadOnlyResp(Async)` is the same shape, for the [`EVAL_RO`/`EVALSHA_RO`](https://redis.io/commands/eval_ro) read-only variant, which can't run write commands and so is eligible to run against a replica. + +Reading the result: `RespResult` vs `RedisResult` +--- + +`ScriptEvaluateResp` returns a `RespResult`: a leased, undecoded view over the raw reply, backed by a pooled buffer rather than a fresh allocation per call. You `using` it to return the buffer once you're done. This is in contrast to the classic `ScriptEvaluate` (no `Resp` suffix, taking `RedisKey[]?`/`RedisValue[]?` arrays), which returns a `RedisResult` - a fully-materialized, general-purpose tree that's easy to cast (`(string)`, `(long)`, `(RedisValue[])`, etc.) but that always allocates: a wrapper object per node, plus a decoded value per scalar. Prefer `ScriptEvaluateResp` for new code, especially when the result is a single scalar (the common case, especially for a blob payload): -using (ConnectionMultiplexer conn = /* init code */) +```csharp +using RespResult result = db.ScriptEvaluateResp("return redis.call('get', KEYS[1])", new RedisKey[] { "mykey" }, default); +if (!result.IsNull) { - var db = conn.GetDatabase(0); + var reader = result.ReadScalar(); + + // cheapest: copy straight into a buffer you already own, sized exactly via ScalarLength() + byte[] buffer = new byte[reader.ScalarLength()]; + int written = reader.CopyTo(buffer); + + // or, if you want an owned, poolable copy to hold on to for a while: + using Lease? lease = reader.ReadLease(); - var prepared = LuaScript.Prepare(Script); - db.ScriptEvaluate(prepared, new { key = (RedisKey)"mykey", value = 123 }); + // or, if you just want the usual RedisValue/string: + RedisValue value = reader.ReadRedisValue(); } ``` -The `LuaScript` class rewrites variables in scripts of the form `@myVar` into the appropriate `ARGV[someIndex]` required by redis. If the -parameter passed is of type `RedisKey` it will be sent as part of the `KEYS` collection automatically. +A `RespResult` is never itself a `null` C# reference - the reply is always a real, non-null `RespResult`, and `IsNull` tells you whether the underlying RESP reply itself was a null (there are three distinct null encodings on the wire; `RespResult` preserves which one you got via `Prefix`, rather than collapsing them). This also leaves room for RESP3 attribute metadata on a null reply in future. -Any object that exposes field or property members with the same name as @-prefixed variables in the Lua script can be used as a parameter hash to -`Evaluate` calls. Supported member types are the following: +If the script can return a tree (an array, or a mix of shapes depending on input), `RespResult.Read()` gives you a `RespReader` positioned at the root. `.ReadRedisResult()` is the convenient option - it falls back to the familiar `RedisResult` materialization for the whole value, at the cost of allocating that same wrapper-object-per-node tree the low-allocation APIs elsewhere in this doc are trying to avoid. If efficiency actually matters for a tree-shaped reply, walk the `RespReader` directly instead: it's a forwards-only iterator over the raw reply, with the same low-level accessors (`ReadRedisValue`, `ReadLease`, `CopyTo`, `ScalarLength`, ...) available at each node, so you can read exactly what you need without materializing the rest of the tree: - - int(?) - - long(?) - - double(?) - - string - - byte[] - - bool(?) - - RedisKey - - RedisValue +```csharp +using RespResult result = db.ScriptEvaluateResp("return {1,2,'three'}", default, default); +RedisResult tree = result.Read().ReadRedisResult(); +var values = (RedisValue[])tree!; +``` -StackExchange.Redis handles Lua script caching internally. It automatically transmits the Lua script to redis on the first call to 'ScriptEvaluate'. For further calls of the same script only the hash with [`EVALSHA`](https://redis.io/commands/evalsha) is used. +For that same reply, walking it directly via `AggregateChildren()` avoids materializing the `RedisResult` tree at all - each child is a `RespReader` in its own right, so the usual scalar accessors (`ReadRedisValue`, `ReadLease`, `CopyTo`, ...) apply per element: -For more control of the Lua script transmission to redis, `LuaScript` objects can be converted into `LoadedLuaScript`s via `LuaScript.Load(IServer)`. -`LoadedLuaScripts` are evaluated with the [`EVALSHA`](https://redis.io/commands/evalsha), and referred to by hash. +```csharp +using RespResult result = db.ScriptEvaluateResp("return {1,2,'three'}", default, default); +var parent = result.Read(); +var children = parent.AggregateChildren(); +while (children.MoveNext()) +{ + // note that .Value should be preferred over .Current, but they have the same result + RedisValue value = children.Value.ReadRedisValue(); + // use value ... +} +children.MovePast(out parent); // positions `parent` right past the aggregate, e.g. to keep reading sibling data in a larger tree +``` -An example use of `LoadedLuaScript`: +If you just want the whole aggregate as a typed array via a projection, without the manual loop, `RespReader.ReadPastArray` (or its non-mutating twin `ReadArray`) does that in one call - `scalar: true` is a further hint that lets it skip the more general child-walking machinery, valid here because every element of `{1,2,'three'}` is itself a scalar rather than a nested sub-tree: ```csharp -const string Script = "redis.call('set', @key, @value)"; +RedisValue[]? values = parent.ReadPastArray(static (ref r) => r.ReadRedisValue(), scalar: true); +``` -using (ConnectionMultiplexer conn = /* init code */) -{ - var db = conn.GetDatabase(0); - var server = conn.GetServer(/* appropriate parameters*/); +This is equivalent to the manual loop above, just without needing to write it out yourself, and capturing the results as an array. - var prepared = LuaScript.Prepare(Script); - var loaded = prepared.Load(server); - loaded.Evaluate(db, new { key = (RedisKey)"mykey", value = 123 }); -} +Ad-hoc commands +--- + +`IDatabase.ExecuteResp(Async)` follows the same `RespResult` pattern for an arbitrary Redis command (not necessarily Lua) - see [Ad-hoc commands](Execute) for details. It takes `ReadOnlyMemory` rather than separate keys/values, because - unlike a script's fixed `KEYS`-then-`ARGV` shape - an arbitrary command can place keys anywhere in its argument list. + +Named parameters via `LuaScript` (legacy) +--- + +Before `ScriptEvaluateResp` existed, an alternative way to pass parameters to a script was the `LuaScript` class, which rewrites `@name`-style placeholders in your script text into the `KEYS`/`ARGV` indices Redis actually expects, using reflection over an anonymous object's members: + +```csharp +const string Script = "redis.call('set', @key, @value)"; +var prepared = LuaScript.Prepare(Script); +db.ScriptEvaluate(prepared, new { key = (RedisKey)"mykey", value = 123 }); ``` -All methods on both `LuaScript` and `LoadedLuaScript` have Async alternatives, and expose the actual script submitted to redis as the `ExecutableScript` property. +This still works (`ScriptEvaluate`/`ScriptEvaluateAsync`, and `LoadedLuaScript` for the `EVALSHA`-only variant loaded via `LuaScript.Load(IServer)`), but the reflection-based parameter binding and the `@name` rewriting are more machinery than most callers need. Prefer `ScriptEvaluateResp` with explicit `RedisKey`/`RedisValue` arguments for new code; reach for `LuaScript` only if you specifically want the named-parameter ergonomics. diff --git a/docs/exp/SER004.md b/docs/exp/SER004.md index e1e77968b..441bb3f3f 100644 --- a/docs/exp/SER004.md +++ b/docs/exp/SER004.md @@ -4,6 +4,10 @@ RESPite is an experimental library that provides high-performance low-level RESP It is used as the IO core for StackExchange.Redis v3+. You should not (yet) use it directly unless you have a very good reason to do so. +This diagnostic now covers only the wire-level IO internals (buffer pooling, frame scanning) - the value-reading +surface (`RespReader`, `RespPrefix`, `RespException`, `RespAttributeReader`, and the StackExchange.Redis-side +`RespResult`/`RespReaderExtensions` that sit on top of them) has stabilized and is no longer gated behind SER004. + To suppress this message, add the following to your `csproj` file: ```xml diff --git a/docs/index.md b/docs/index.md index 93c6eab7d..011585271 100644 --- a/docs/index.md +++ b/docs/index.md @@ -53,7 +53,8 @@ Documentation - [Hash Tags and Slots](HashTags) - co-locating keys in the same cluster slot for multi-key operations - [Where are `KEYS` / `SCAN` / `FLUSH*`?](KeysScan) - how to use server-based commands - [Profiling](Profiling) - profiling interfaces, as well as how to profile in an `async` world -- [Scripting](Scripting) - running Lua scripts with convenient named parameter replacement +- [Scripting](Scripting) - running Lua scripts, including the low-allocation `ScriptEvalLease` API +- [Ad-hoc commands](Execute) - running commands without a dedicated API, including the low-allocation `ExecLease` API - [Testing](Testing) - running the `StackExchange.Redis.Tests` suite to validate changes - [Timeouts](Timeouts) - guidance on dealing with timeout problems - [Thread Theft](ThreadTheft) - guidance on avoiding TPL threading problems diff --git a/src/RESPite/Messages/RespAttributeReader.cs b/src/RESPite/Messages/RespAttributeReader.cs index 9d61802c0..ea32f9dfe 100644 --- a/src/RESPite/Messages/RespAttributeReader.cs +++ b/src/RESPite/Messages/RespAttributeReader.cs @@ -1,12 +1,9 @@ -using System.Diagnostics.CodeAnalysis; - -namespace RESPite.Messages; +namespace RESPite.Messages; /// /// Allows attribute data to be parsed conveniently. /// /// The type of data represented by this reader. -[Experimental(Experiments.Respite, UrlFormat = Experiments.UrlFormat)] public abstract class RespAttributeReader { /// diff --git a/src/RESPite/Messages/RespPrefix.cs b/src/RESPite/Messages/RespPrefix.cs index d58749120..09fa5e5d8 100644 --- a/src/RESPite/Messages/RespPrefix.cs +++ b/src/RESPite/Messages/RespPrefix.cs @@ -1,11 +1,8 @@ -using System.Diagnostics.CodeAnalysis; - -namespace RESPite.Messages; +namespace RESPite.Messages; /// /// RESP protocol prefix. /// -[Experimental(Experiments.Respite, UrlFormat = Experiments.UrlFormat)] public enum RespPrefix : byte { /// diff --git a/src/RESPite/Messages/RespReader.cs b/src/RESPite/Messages/RespReader.cs index 2137946d8..1d7afd087 100644 --- a/src/RESPite/Messages/RespReader.cs +++ b/src/RESPite/Messages/RespReader.cs @@ -22,7 +22,6 @@ namespace RESPite.Messages; /// /// Provides low level RESP parsing functionality. /// -[Experimental(Experiments.Respite, UrlFormat = Experiments.UrlFormat)] public ref partial struct RespReader { [Flags] @@ -83,6 +82,80 @@ public readonly bool TryGetSpan(out ReadOnlySpan value) return IsNullScalar; } + /// + /// Attempt to get all remaining protocol bytes - raw and undecoded, with no regard to RESP structure - + /// as a single contiguous span. Unlike , this is not limited to the current + /// scalar; called at the start of a message, this returns the entire message, header bytes included. + /// + /// True if the remaining data is a single contiguous span, otherwise False. + /// If this reports False, can be used instead. + /// When True, the remaining raw bytes. + public readonly bool TryGetRawSpan(out ReadOnlySpan value) + { + if (_tail is null) + { + value = CurrentSpan(); + return true; + } + + value = default; + return false; + } + + /// + /// Copies all remaining protocol bytes - raw and undecoded, with no regard to RESP structure - into the + /// supplied , or as much as can be copied; the reader is not advanced. Unlike + /// , this is not limited to the current scalar; called at the start of a + /// message, this copies the entire message, header bytes included. + /// + /// The destination for the copy operation. + /// The number of bytes successfully copied; this is , or less if is too small. + public readonly int CopyRawTo(scoped Span target) + { + if (TryGetRawSpan(out var span)) + { + if (target.Length < span.Length) span = span.Slice(0, target.Length); + span.CopyTo(target); + return span.Length; + } + + var current = CurrentSpan(); + if (target.Length <= current.Length) + { + current.Slice(0, target.Length).CopyTo(target); + return target.Length; + } + current.CopyTo(target); + int totalBytes = current.Length; + target = target.Slice(current.Length); + + var tail = _tail; + var remainingTailLength = _remainingTailLength; + while (tail is not null && remainingTailLength > 0 && !target.IsEmpty) + { + var memory = tail.Memory; + tail = tail.Next; + if (memory.IsEmpty) continue; + + var tailSpan = memory.Span; + if (tailSpan.Length > remainingTailLength) + { + tailSpan = tailSpan.Slice(0, (int)remainingTailLength); + } + remainingTailLength -= tailSpan.Length; + + if (target.Length <= tailSpan.Length) + { + tailSpan.Slice(0, target.Length).CopyTo(target); + return totalBytes + target.Length; + } + tailSpan.CopyTo(target); + totalBytes += tailSpan.Length; + target = target.Slice(tailSpan.Length); + } + return totalBytes; + } + /// /// Returns the position after the end of the current element. /// diff --git a/src/RESPite/PublicAPI/PublicAPI.Shipped.txt b/src/RESPite/PublicAPI/PublicAPI.Shipped.txt index eb6ae9cbd..fd486c6b0 100644 --- a/src/RESPite/PublicAPI/PublicAPI.Shipped.txt +++ b/src/RESPite/PublicAPI/PublicAPI.Shipped.txt @@ -41,21 +41,21 @@ [SER004]RESPite.Buffers.CycleBuffer.Write(System.ReadOnlySpan value) -> void [SER004]RESPite.Buffers.ICycleBufferCallback [SER004]RESPite.Buffers.ICycleBufferCallback.PageComplete() -> void -[SER004]RESPite.Messages.RespReader.AggregateEnumerator.FillAll(scoped System.Span target, RESPite.Messages.RespReader.Projection! projection) -> void -[SER004]RESPite.Messages.RespReader.AggregateEnumerator.FillAll(scoped System.Span target, ref TState state, RESPite.Messages.RespReader.Projection! first, RESPite.Messages.RespReader.Projection! second, System.Func! combine) -> void -[SER004]RESPite.Messages.RespReader.AggregateEnumerator.FillAll(scoped System.Span target, ref TState state, RESPite.Messages.RespReader.Projection! projection) -> void -[SER004]RESPite.Messages.RespReader.AggregateEnumerator.MoveNextRaw() -> bool -[SER004]RESPite.Messages.RespReader.AggregateEnumerator.MoveNextRaw(RESPite.Messages.RespAttributeReader! respAttributeReader, ref T attributes) -> bool -[SER004]RESPite.Messages.RespReader.AggregateIsEmpty() -> bool -[SER004]RESPite.Messages.RespReader.AggregateLengthIs(int count) -> bool -[SER004]RESPite.Messages.RespReader.Clone() -> RESPite.Messages.RespReader -[SER004]RESPite.Messages.RespReader.FillAll(scoped System.Span target, ref TState state, RESPite.Messages.RespReader.Projection! projection) -> void -[SER004]RESPite.Messages.RespReader.Projection -[SER004]RESPite.Messages.RespReader.ReadArray(ref TState state, RESPite.Messages.RespReader.Projection! projection, bool scalar = false) -> TResult[]? -[SER004]RESPite.Messages.RespReader.ReadPastArray(ref TState state, RESPite.Messages.RespReader.Projection! projection, bool scalar = false) -> TResult[]? -[SER004]RESPite.Messages.RespReader.ScalarParser -[SER004]RESPite.Messages.RespReader.TryParseScalar(delegate*, out T, bool> parser, out T value) -> bool -[SER004]RESPite.Messages.RespReader.TryParseScalar(RESPite.Messages.RespReader.ScalarParser! parser, out T value) -> bool +RESPite.Messages.RespReader.AggregateEnumerator.FillAll(scoped System.Span target, RESPite.Messages.RespReader.Projection! projection) -> void +RESPite.Messages.RespReader.AggregateEnumerator.FillAll(scoped System.Span target, ref TState state, RESPite.Messages.RespReader.Projection! first, RESPite.Messages.RespReader.Projection! second, System.Func! combine) -> void +RESPite.Messages.RespReader.AggregateEnumerator.FillAll(scoped System.Span target, ref TState state, RESPite.Messages.RespReader.Projection! projection) -> void +RESPite.Messages.RespReader.AggregateEnumerator.MoveNextRaw() -> bool +RESPite.Messages.RespReader.AggregateEnumerator.MoveNextRaw(RESPite.Messages.RespAttributeReader! respAttributeReader, ref T attributes) -> bool +RESPite.Messages.RespReader.AggregateIsEmpty() -> bool +RESPite.Messages.RespReader.AggregateLengthIs(int count) -> bool +RESPite.Messages.RespReader.Clone() -> RESPite.Messages.RespReader +RESPite.Messages.RespReader.FillAll(scoped System.Span target, ref TState state, RESPite.Messages.RespReader.Projection! projection) -> void +RESPite.Messages.RespReader.Projection +RESPite.Messages.RespReader.ReadArray(ref TState state, RESPite.Messages.RespReader.Projection! projection, bool scalar = false) -> TResult[]? +RESPite.Messages.RespReader.ReadPastArray(ref TState state, RESPite.Messages.RespReader.Projection! projection, bool scalar = false) -> TResult[]? +RESPite.Messages.RespReader.ScalarParser +RESPite.Messages.RespReader.TryParseScalar(delegate*, out T, bool> parser, out T value) -> bool +RESPite.Messages.RespReader.TryParseScalar(RESPite.Messages.RespReader.ScalarParser! parser, out T value) -> bool [SER004]static RESPite.AsciiHash.CaseInsensitiveEqualityComparer.get -> System.Collections.Generic.IEqualityComparer! [SER004]static RESPite.AsciiHash.CaseSensitiveEqualityComparer.get -> System.Collections.Generic.IEqualityComparer! [SER004]static RESPite.AsciiHash.EqualsCI(System.ReadOnlySpan first, System.ReadOnlySpan second) -> bool @@ -84,112 +84,112 @@ [SER004]override RESPite.Messages.RespScanState.Equals(object? obj) -> bool [SER004]override RESPite.Messages.RespScanState.GetHashCode() -> int [SER004]override RESPite.Messages.RespScanState.ToString() -> string! -[SER004]RESPite.Messages.RespAttributeReader -[SER004]RESPite.Messages.RespAttributeReader.RespAttributeReader() -> void +RESPite.Messages.RespAttributeReader +RESPite.Messages.RespAttributeReader.RespAttributeReader() -> void [SER004]RESPite.Messages.RespFrameScanner [SER004]RESPite.Messages.RespFrameScanner.TryRead(ref RESPite.Messages.RespScanState state, in System.Buffers.ReadOnlySequence data) -> System.Buffers.OperationStatus [SER004]RESPite.Messages.RespFrameScanner.TryRead(ref RESPite.Messages.RespScanState state, System.ReadOnlySpan data) -> System.Buffers.OperationStatus [SER004]RESPite.Messages.RespFrameScanner.ValidateRequest(in System.Buffers.ReadOnlySequence message) -> void -[SER004]RESPite.Messages.RespPrefix -[SER004]RESPite.Messages.RespPrefix.Array = 42 -> RESPite.Messages.RespPrefix -[SER004]RESPite.Messages.RespPrefix.Attribute = 124 -> RESPite.Messages.RespPrefix -[SER004]RESPite.Messages.RespPrefix.BigInteger = 40 -> RESPite.Messages.RespPrefix -[SER004]RESPite.Messages.RespPrefix.Boolean = 35 -> RESPite.Messages.RespPrefix -[SER004]RESPite.Messages.RespPrefix.BulkError = 33 -> RESPite.Messages.RespPrefix -[SER004]RESPite.Messages.RespPrefix.BulkString = 36 -> RESPite.Messages.RespPrefix -[SER004]RESPite.Messages.RespPrefix.Double = 44 -> RESPite.Messages.RespPrefix -[SER004]RESPite.Messages.RespPrefix.Integer = 58 -> RESPite.Messages.RespPrefix -[SER004]RESPite.Messages.RespPrefix.Map = 37 -> RESPite.Messages.RespPrefix -[SER004]RESPite.Messages.RespPrefix.None = 0 -> RESPite.Messages.RespPrefix -[SER004]RESPite.Messages.RespPrefix.Null = 95 -> RESPite.Messages.RespPrefix -[SER004]RESPite.Messages.RespPrefix.Push = 62 -> RESPite.Messages.RespPrefix -[SER004]RESPite.Messages.RespPrefix.Set = 126 -> RESPite.Messages.RespPrefix -[SER004]RESPite.Messages.RespPrefix.SimpleError = 45 -> RESPite.Messages.RespPrefix -[SER004]RESPite.Messages.RespPrefix.SimpleString = 43 -> RESPite.Messages.RespPrefix -[SER004]RESPite.Messages.RespPrefix.StreamContinuation = 59 -> RESPite.Messages.RespPrefix -[SER004]RESPite.Messages.RespPrefix.StreamTerminator = 46 -> RESPite.Messages.RespPrefix -[SER004]RESPite.Messages.RespPrefix.VerbatimString = 61 -> RESPite.Messages.RespPrefix -[SER004]RESPite.Messages.RespReader -[SER004]RESPite.Messages.RespReader.AggregateChildren() -> RESPite.Messages.RespReader.AggregateEnumerator -[SER004]RESPite.Messages.RespReader.AggregateEnumerator -[SER004]RESPite.Messages.RespReader.AggregateEnumerator.AggregateEnumerator() -> void -[SER004]RESPite.Messages.RespReader.AggregateEnumerator.AggregateEnumerator(scoped in RESPite.Messages.RespReader reader) -> void -[SER004]RESPite.Messages.RespReader.AggregateEnumerator.DemandNext() -> void -[SER004]RESPite.Messages.RespReader.AggregateEnumerator.FillAll(scoped System.Span target, RESPite.Messages.RespReader.Projection! first, RESPite.Messages.RespReader.Projection! second, System.Func! combine) -> void -[SER004]RESPite.Messages.RespReader.AggregateEnumerator.GetEnumerator() -> RESPite.Messages.RespReader.AggregateEnumerator -[SER004]RESPite.Messages.RespReader.AggregateEnumerator.MoveNext() -> bool -[SER004]RESPite.Messages.RespReader.AggregateEnumerator.MoveNext(RESPite.Messages.RespPrefix prefix) -> bool -[SER004]RESPite.Messages.RespReader.AggregateEnumerator.MoveNext(RESPite.Messages.RespPrefix prefix, RESPite.Messages.RespAttributeReader! respAttributeReader, ref T attributes) -> bool -[SER004]RESPite.Messages.RespReader.AggregateEnumerator.MovePast(out RESPite.Messages.RespReader reader) -> void -[SER004]RESPite.Messages.RespReader.AggregateEnumerator.ReadOne(RESPite.Messages.RespReader.Projection! projection) -> T -[SER004]RESPite.Messages.RespReader.AggregateEnumerator.Value -> RESPite.Messages.RespReader -[SER004]RESPite.Messages.RespReader.AggregateLength() -> int -[SER004]RESPite.Messages.RespReader.BytesConsumed.get -> long -[SER004]RESPite.Messages.RespReader.CopyTo(scoped System.Span target) -> int -[SER004]RESPite.Messages.RespReader.CopyTo(System.Buffers.IBufferWriter! target) -> int -[SER004]RESPite.Messages.RespReader.DemandAggregate() -> void -[SER004]RESPite.Messages.RespReader.DemandEnd() -> void -[SER004]RESPite.Messages.RespReader.DemandNotNull() -> void -[SER004]RESPite.Messages.RespReader.DemandScalar() -> void -[SER004]RESPite.Messages.RespReader.FillAll(scoped System.Span target, RESPite.Messages.RespReader.Projection! projection) -> void -[SER004]RESPite.Messages.RespReader.Is(byte value) -> bool -[SER004]RESPite.Messages.RespReader.Is(System.ReadOnlySpan value) -> bool -[SER004]RESPite.Messages.RespReader.Is(System.ReadOnlySpan value) -> bool -[SER004]RESPite.Messages.RespReader.IsAggregate.get -> bool -[SER004]RESPite.Messages.RespReader.IsAttribute.get -> bool -[SER004]RESPite.Messages.RespReader.IsError.get -> bool -[SER004]RESPite.Messages.RespReader.IsNull.get -> bool -[SER004]RESPite.Messages.RespReader.IsScalar.get -> bool -[SER004]RESPite.Messages.RespReader.IsStreaming.get -> bool -[SER004]RESPite.Messages.RespReader.MoveNext() -> void -[SER004]RESPite.Messages.RespReader.MoveNext(RESPite.Messages.RespPrefix prefix) -> void -[SER004]RESPite.Messages.RespReader.MoveNext(RESPite.Messages.RespAttributeReader! respAttributeReader, ref T attributes) -> void -[SER004]RESPite.Messages.RespReader.MoveNext(RESPite.Messages.RespPrefix prefix, RESPite.Messages.RespAttributeReader! respAttributeReader, ref T attributes) -> void -[SER004]RESPite.Messages.RespReader.MoveNextAggregate() -> void -[SER004]RESPite.Messages.RespReader.MoveNextScalar() -> void -[SER004]RESPite.Messages.RespReader.Prefix.get -> RESPite.Messages.RespPrefix -[SER004]RESPite.Messages.RespReader.Projection -[SER004]RESPite.Messages.RespReader.ProtocolBytesRemaining.get -> long -[SER004]RESPite.Messages.RespReader.ReadArray(RESPite.Messages.RespReader.Projection! projection, bool scalar = false) -> TResult[]? -[SER004]RESPite.Messages.RespReader.ReadBoolean() -> bool -[SER004]RESPite.Messages.RespReader.TryReadBoolean(out bool value) -> bool -[SER004]RESPite.Messages.RespReader.ReadByteArray() -> byte[]? -[SER004]RESPite.Messages.RespReader.ReadDecimal() -> decimal -[SER004]RESPite.Messages.RespReader.ReadDouble() -> double -[SER004]RESPite.Messages.RespReader.ReadEnum(T unknownValue = default(T)) -> T -[SER004]RESPite.Messages.RespReader.ReadInt32() -> int -[SER004]RESPite.Messages.RespReader.ReadInt64() -> long -[SER004]RESPite.Messages.RespReader.ReadPairArray(RESPite.Messages.RespReader.Projection! first, RESPite.Messages.RespReader.Projection! second, System.Func! combine, bool scalar = true) -> TResult[]? -[SER004]RESPite.Messages.RespReader.ReadPastArray(RESPite.Messages.RespReader.Projection! projection, bool scalar = false) -> TResult[]? -[SER004]RESPite.Messages.RespReader.ReadString() -> string? -[SER004]RESPite.Messages.RespReader.ReadString(out string! prefix) -> string? -[SER004]RESPite.Messages.RespReader.RespReader() -> void -[SER004]RESPite.Messages.RespReader.RespReader(scoped in System.Buffers.ReadOnlySequence value) -> void -[SER004]RESPite.Messages.RespReader.RespReader(System.ReadOnlySpan value) -> void -[SER004]RESPite.Messages.RespReader.ScalarChunks() -> RESPite.Messages.RespReader.ScalarEnumerator -[SER004]RESPite.Messages.RespReader.ScalarEnumerator -[SER004]RESPite.Messages.RespReader.ScalarEnumerator.Current.get -> System.ReadOnlySpan -[SER004]RESPite.Messages.RespReader.ScalarEnumerator.CurrentLength.get -> int -[SER004]RESPite.Messages.RespReader.ScalarEnumerator.GetEnumerator() -> RESPite.Messages.RespReader.ScalarEnumerator -[SER004]RESPite.Messages.RespReader.ScalarEnumerator.MoveNext() -> bool -[SER004]RESPite.Messages.RespReader.ScalarEnumerator.MovePast(out RESPite.Messages.RespReader reader) -> void -[SER004]RESPite.Messages.RespReader.ScalarEnumerator.ScalarEnumerator() -> void -[SER004]RESPite.Messages.RespReader.ScalarEnumerator.ScalarEnumerator(scoped in RESPite.Messages.RespReader reader) -> void -[SER004]RESPite.Messages.RespReader.ScalarIsEmpty() -> bool -[SER004]RESPite.Messages.RespReader.ScalarLength() -> int -[SER004]RESPite.Messages.RespReader.ScalarLengthIs(int count) -> bool -[SER004]RESPite.Messages.RespReader.ScalarLongLength() -> long -[SER004]RESPite.Messages.RespReader.SkipChildren() -> void -[SER004]RESPite.Messages.RespReader.StartsWith(System.ReadOnlySpan value) -> bool -[SER004]RESPite.Messages.RespReader.TryGetSpan(out System.ReadOnlySpan value) -> bool -[SER004]RESPite.Messages.RespReader.TryMoveNext() -> bool -[SER004]RESPite.Messages.RespReader.TryMoveNext(bool checkError) -> bool -[SER004]RESPite.Messages.RespReader.TryMoveNext(RESPite.Messages.RespPrefix prefix) -> bool -[SER004]RESPite.Messages.RespReader.TryMoveNext(RESPite.Messages.RespAttributeReader! respAttributeReader, ref T attributes) -> bool -[SER004]RESPite.Messages.RespReader.TryReadDouble(out double value, bool allowTokens = true) -> bool -[SER004]RESPite.Messages.RespReader.TryReadInt32(out int value) -> bool -[SER004]RESPite.Messages.RespReader.TryReadInt64(out long value) -> bool -[SER004]RESPite.Messages.RespReader.TryReadNext() -> bool +RESPite.Messages.RespPrefix +RESPite.Messages.RespPrefix.Array = 42 -> RESPite.Messages.RespPrefix +RESPite.Messages.RespPrefix.Attribute = 124 -> RESPite.Messages.RespPrefix +RESPite.Messages.RespPrefix.BigInteger = 40 -> RESPite.Messages.RespPrefix +RESPite.Messages.RespPrefix.Boolean = 35 -> RESPite.Messages.RespPrefix +RESPite.Messages.RespPrefix.BulkError = 33 -> RESPite.Messages.RespPrefix +RESPite.Messages.RespPrefix.BulkString = 36 -> RESPite.Messages.RespPrefix +RESPite.Messages.RespPrefix.Double = 44 -> RESPite.Messages.RespPrefix +RESPite.Messages.RespPrefix.Integer = 58 -> RESPite.Messages.RespPrefix +RESPite.Messages.RespPrefix.Map = 37 -> RESPite.Messages.RespPrefix +RESPite.Messages.RespPrefix.None = 0 -> RESPite.Messages.RespPrefix +RESPite.Messages.RespPrefix.Null = 95 -> RESPite.Messages.RespPrefix +RESPite.Messages.RespPrefix.Push = 62 -> RESPite.Messages.RespPrefix +RESPite.Messages.RespPrefix.Set = 126 -> RESPite.Messages.RespPrefix +RESPite.Messages.RespPrefix.SimpleError = 45 -> RESPite.Messages.RespPrefix +RESPite.Messages.RespPrefix.SimpleString = 43 -> RESPite.Messages.RespPrefix +RESPite.Messages.RespPrefix.StreamContinuation = 59 -> RESPite.Messages.RespPrefix +RESPite.Messages.RespPrefix.StreamTerminator = 46 -> RESPite.Messages.RespPrefix +RESPite.Messages.RespPrefix.VerbatimString = 61 -> RESPite.Messages.RespPrefix +RESPite.Messages.RespReader +RESPite.Messages.RespReader.AggregateChildren() -> RESPite.Messages.RespReader.AggregateEnumerator +RESPite.Messages.RespReader.AggregateEnumerator +RESPite.Messages.RespReader.AggregateEnumerator.AggregateEnumerator() -> void +RESPite.Messages.RespReader.AggregateEnumerator.AggregateEnumerator(scoped in RESPite.Messages.RespReader reader) -> void +RESPite.Messages.RespReader.AggregateEnumerator.DemandNext() -> void +RESPite.Messages.RespReader.AggregateEnumerator.FillAll(scoped System.Span target, RESPite.Messages.RespReader.Projection! first, RESPite.Messages.RespReader.Projection! second, System.Func! combine) -> void +RESPite.Messages.RespReader.AggregateEnumerator.GetEnumerator() -> RESPite.Messages.RespReader.AggregateEnumerator +RESPite.Messages.RespReader.AggregateEnumerator.MoveNext() -> bool +RESPite.Messages.RespReader.AggregateEnumerator.MoveNext(RESPite.Messages.RespPrefix prefix) -> bool +RESPite.Messages.RespReader.AggregateEnumerator.MoveNext(RESPite.Messages.RespPrefix prefix, RESPite.Messages.RespAttributeReader! respAttributeReader, ref T attributes) -> bool +RESPite.Messages.RespReader.AggregateEnumerator.MovePast(out RESPite.Messages.RespReader reader) -> void +RESPite.Messages.RespReader.AggregateEnumerator.ReadOne(RESPite.Messages.RespReader.Projection! projection) -> T +RESPite.Messages.RespReader.AggregateEnumerator.Value -> RESPite.Messages.RespReader +RESPite.Messages.RespReader.AggregateLength() -> int +RESPite.Messages.RespReader.BytesConsumed.get -> long +RESPite.Messages.RespReader.CopyTo(scoped System.Span target) -> int +RESPite.Messages.RespReader.CopyTo(System.Buffers.IBufferWriter! target) -> int +RESPite.Messages.RespReader.DemandAggregate() -> void +RESPite.Messages.RespReader.DemandEnd() -> void +RESPite.Messages.RespReader.DemandNotNull() -> void +RESPite.Messages.RespReader.DemandScalar() -> void +RESPite.Messages.RespReader.FillAll(scoped System.Span target, RESPite.Messages.RespReader.Projection! projection) -> void +RESPite.Messages.RespReader.Is(byte value) -> bool +RESPite.Messages.RespReader.Is(System.ReadOnlySpan value) -> bool +RESPite.Messages.RespReader.Is(System.ReadOnlySpan value) -> bool +RESPite.Messages.RespReader.IsAggregate.get -> bool +RESPite.Messages.RespReader.IsAttribute.get -> bool +RESPite.Messages.RespReader.IsError.get -> bool +RESPite.Messages.RespReader.IsNull.get -> bool +RESPite.Messages.RespReader.IsScalar.get -> bool +RESPite.Messages.RespReader.IsStreaming.get -> bool +RESPite.Messages.RespReader.MoveNext() -> void +RESPite.Messages.RespReader.MoveNext(RESPite.Messages.RespPrefix prefix) -> void +RESPite.Messages.RespReader.MoveNext(RESPite.Messages.RespAttributeReader! respAttributeReader, ref T attributes) -> void +RESPite.Messages.RespReader.MoveNext(RESPite.Messages.RespPrefix prefix, RESPite.Messages.RespAttributeReader! respAttributeReader, ref T attributes) -> void +RESPite.Messages.RespReader.MoveNextAggregate() -> void +RESPite.Messages.RespReader.MoveNextScalar() -> void +RESPite.Messages.RespReader.Prefix.get -> RESPite.Messages.RespPrefix +RESPite.Messages.RespReader.Projection +RESPite.Messages.RespReader.ProtocolBytesRemaining.get -> long +RESPite.Messages.RespReader.ReadArray(RESPite.Messages.RespReader.Projection! projection, bool scalar = false) -> TResult[]? +RESPite.Messages.RespReader.ReadBoolean() -> bool +RESPite.Messages.RespReader.TryReadBoolean(out bool value) -> bool +RESPite.Messages.RespReader.ReadByteArray() -> byte[]? +RESPite.Messages.RespReader.ReadDecimal() -> decimal +RESPite.Messages.RespReader.ReadDouble() -> double +RESPite.Messages.RespReader.ReadEnum(T unknownValue = default(T)) -> T +RESPite.Messages.RespReader.ReadInt32() -> int +RESPite.Messages.RespReader.ReadInt64() -> long +RESPite.Messages.RespReader.ReadPairArray(RESPite.Messages.RespReader.Projection! first, RESPite.Messages.RespReader.Projection! second, System.Func! combine, bool scalar = true) -> TResult[]? +RESPite.Messages.RespReader.ReadPastArray(RESPite.Messages.RespReader.Projection! projection, bool scalar = false) -> TResult[]? +RESPite.Messages.RespReader.ReadString() -> string? +RESPite.Messages.RespReader.ReadString(out string! prefix) -> string? +RESPite.Messages.RespReader.RespReader() -> void +RESPite.Messages.RespReader.RespReader(scoped in System.Buffers.ReadOnlySequence value) -> void +RESPite.Messages.RespReader.RespReader(System.ReadOnlySpan value) -> void +RESPite.Messages.RespReader.ScalarChunks() -> RESPite.Messages.RespReader.ScalarEnumerator +RESPite.Messages.RespReader.ScalarEnumerator +RESPite.Messages.RespReader.ScalarEnumerator.Current.get -> System.ReadOnlySpan +RESPite.Messages.RespReader.ScalarEnumerator.CurrentLength.get -> int +RESPite.Messages.RespReader.ScalarEnumerator.GetEnumerator() -> RESPite.Messages.RespReader.ScalarEnumerator +RESPite.Messages.RespReader.ScalarEnumerator.MoveNext() -> bool +RESPite.Messages.RespReader.ScalarEnumerator.MovePast(out RESPite.Messages.RespReader reader) -> void +RESPite.Messages.RespReader.ScalarEnumerator.ScalarEnumerator() -> void +RESPite.Messages.RespReader.ScalarEnumerator.ScalarEnumerator(scoped in RESPite.Messages.RespReader reader) -> void +RESPite.Messages.RespReader.ScalarIsEmpty() -> bool +RESPite.Messages.RespReader.ScalarLength() -> int +RESPite.Messages.RespReader.ScalarLengthIs(int count) -> bool +RESPite.Messages.RespReader.ScalarLongLength() -> long +RESPite.Messages.RespReader.SkipChildren() -> void +RESPite.Messages.RespReader.StartsWith(System.ReadOnlySpan value) -> bool +RESPite.Messages.RespReader.TryGetSpan(out System.ReadOnlySpan value) -> bool +RESPite.Messages.RespReader.TryMoveNext() -> bool +RESPite.Messages.RespReader.TryMoveNext(bool checkError) -> bool +RESPite.Messages.RespReader.TryMoveNext(RESPite.Messages.RespPrefix prefix) -> bool +RESPite.Messages.RespReader.TryMoveNext(RESPite.Messages.RespAttributeReader! respAttributeReader, ref T attributes) -> bool +RESPite.Messages.RespReader.TryReadDouble(out double value, bool allowTokens = true) -> bool +RESPite.Messages.RespReader.TryReadInt32(out int value) -> bool +RESPite.Messages.RespReader.TryReadInt64(out long value) -> bool +RESPite.Messages.RespReader.TryReadNext() -> bool [SER004]RESPite.Messages.RespScanState [SER004]RESPite.Messages.RespScanState.IsComplete.get -> bool [SER004]RESPite.Messages.RespScanState.Prefix.get -> RESPite.Messages.RespPrefix @@ -198,18 +198,18 @@ [SER004]RESPite.Messages.RespScanState.TryRead(in System.Buffers.ReadOnlySequence value, out long bytesRead) -> bool [SER004]RESPite.Messages.RespScanState.TryRead(ref RESPite.Messages.RespReader reader, out long bytesRead) -> bool [SER004]RESPite.Messages.RespScanState.TryRead(System.ReadOnlySpan value, out int bytesRead) -> bool -[SER004]RESPite.RespException -[SER004]RESPite.RespException.RespException(string! message) -> void +RESPite.RespException +RESPite.RespException.RespException(string! message) -> void [SER004]static RESPite.Messages.RespFrameScanner.Default.get -> RESPite.Messages.RespFrameScanner! [SER004]static RESPite.Messages.RespFrameScanner.Subscription.get -> RESPite.Messages.RespFrameScanner! -[SER004]virtual RESPite.Messages.RespAttributeReader.Read(ref RESPite.Messages.RespReader reader, ref T value) -> void -[SER004]virtual RESPite.Messages.RespAttributeReader.ReadKeyValuePair(scoped System.ReadOnlySpan key, ref RESPite.Messages.RespReader reader, ref T value) -> bool -[SER004]virtual RESPite.Messages.RespAttributeReader.ReadKeyValuePairs(ref RESPite.Messages.RespReader reader, ref T value) -> int -[SER004]virtual RESPite.Messages.RespReader.Projection.Invoke(ref RESPite.Messages.RespReader value) -> T -[SER004]virtual RESPite.Messages.RespReader.Projection.Invoke(ref TState state, ref RESPite.Messages.RespReader value) -> TResult -[SER004]virtual RESPite.Messages.RespReader.ScalarParser.Invoke(scoped System.ReadOnlySpan value, out TValue result) -> bool -[SER004]RESPite.Messages.RespReader.Serialize() -> byte[]! -[SER004]RESPite.Messages.RespReader.TryParseScalar(RESPite.Messages.RespReader.ScalarParser! parser, out T value) -> bool +virtual RESPite.Messages.RespAttributeReader.Read(ref RESPite.Messages.RespReader reader, ref T value) -> void +virtual RESPite.Messages.RespAttributeReader.ReadKeyValuePair(scoped System.ReadOnlySpan key, ref RESPite.Messages.RespReader reader, ref T value) -> bool +virtual RESPite.Messages.RespAttributeReader.ReadKeyValuePairs(ref RESPite.Messages.RespReader reader, ref T value) -> int +virtual RESPite.Messages.RespReader.Projection.Invoke(ref RESPite.Messages.RespReader value) -> T +virtual RESPite.Messages.RespReader.Projection.Invoke(ref TState state, ref RESPite.Messages.RespReader value) -> TResult +virtual RESPite.Messages.RespReader.ScalarParser.Invoke(scoped System.ReadOnlySpan value, out TValue result) -> bool +RESPite.Messages.RespReader.Serialize() -> byte[]! +RESPite.Messages.RespReader.TryParseScalar(RESPite.Messages.RespReader.ScalarParser! parser, out T value) -> bool [SER004]static RESPite.AsciiHash.EqualsCI(System.ReadOnlySpan first, System.ReadOnlySpan second) -> bool [SER004]static RESPite.AsciiHash.EqualsCI(System.ReadOnlySpan first, System.ReadOnlySpan second) -> bool [SER004]static RESPite.AsciiHash.SequenceEqualsCI(System.ReadOnlySpan first, System.ReadOnlySpan second) -> bool diff --git a/src/RESPite/PublicAPI/PublicAPI.Unshipped.txt b/src/RESPite/PublicAPI/PublicAPI.Unshipped.txt index 4c74fea9a..c81293654 100644 --- a/src/RESPite/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/RESPite/PublicAPI/PublicAPI.Unshipped.txt @@ -13,3 +13,5 @@ [SER009]abstract RESPite.Transports.TransportReceiver.OnReceived(System.ReadOnlySpan payload) -> bool [SER009]virtual RESPite.Transports.TransportReceiver.OnBatchEnd() -> void [SER009]virtual RESPite.Transports.TransportReceiver.OnClosed(System.Exception? fault) -> void +RESPite.Messages.RespReader.TryGetRawSpan(out System.ReadOnlySpan value) -> bool +RESPite.Messages.RespReader.CopyRawTo(scoped System.Span target) -> int diff --git a/src/RESPite/PublicAPI/Release/PublicAPI.Shipped.txt b/src/RESPite/PublicAPI/Release/PublicAPI.Shipped.txt index dd1130e4c..f84c1ee84 100644 --- a/src/RESPite/PublicAPI/Release/PublicAPI.Shipped.txt +++ b/src/RESPite/PublicAPI/Release/PublicAPI.Shipped.txt @@ -1,2 +1,2 @@ #nullable enable -[SER004]RESPite.Messages.RespReader.AggregateEnumerator.Current.get -> RESPite.Messages.RespReader +RESPite.Messages.RespReader.AggregateEnumerator.Current.get -> RESPite.Messages.RespReader diff --git a/src/RESPite/PublicAPI/net8.0/PublicAPI.Shipped.txt b/src/RESPite/PublicAPI/net8.0/PublicAPI.Shipped.txt index 7c03d13e6..216883a2c 100644 --- a/src/RESPite/PublicAPI/net8.0/PublicAPI.Shipped.txt +++ b/src/RESPite/PublicAPI/net8.0/PublicAPI.Shipped.txt @@ -1,3 +1,3 @@ #nullable enable -[SER004]RESPite.Messages.RespReader.ParseBytes(System.IFormatProvider? formatProvider = null) -> T -[SER004]RESPite.Messages.RespReader.ParseChars(System.IFormatProvider? formatProvider = null) -> T +RESPite.Messages.RespReader.ParseBytes(System.IFormatProvider? formatProvider = null) -> T +RESPite.Messages.RespReader.ParseChars(System.IFormatProvider? formatProvider = null) -> T diff --git a/src/RESPite/RespException.cs b/src/RESPite/RespException.cs index 6b5fd7c72..86a344577 100644 --- a/src/RESPite/RespException.cs +++ b/src/RESPite/RespException.cs @@ -1,11 +1,8 @@ -using System.Diagnostics.CodeAnalysis; - -namespace RESPite; +namespace RESPite; /// /// Represents a RESP error message. /// -[Experimental(Experiments.Respite, UrlFormat = Experiments.UrlFormat)] public sealed class RespException(string message) : Exception(message) { } diff --git a/src/StackExchange.Redis/AutoDatabase.cs b/src/StackExchange.Redis/AutoDatabase.cs index 9d6195110..f25428088 100644 --- a/src/StackExchange.Redis/AutoDatabase.cs +++ b/src/StackExchange.Redis/AutoDatabase.cs @@ -148,6 +148,38 @@ public static TResult UnMap(this IRedisArgsMutator mutator, in return arr; } + // ScriptEvaluateResp's keys parameter - every element is a key (unlike RedisKeyOrValue below), + // mirroring the RedisKey[]? overload above but for the ReadOnlyMemory shape. + public static ReadOnlyMemory Map(this IRedisArgsMutator mutator, ReadOnlyMemory keys) + { + if (keys.Length is 0) return keys; + var arr = new RedisKey[keys.Length]; + var span = keys.Span; + for (int i = 0; i < arr.Length; i++) + { + arr[i] = mutator.Map(span[i]); + } + return arr; + } + + // the ExecuteResp escape hatch mixes keys and values in one collection (mirroring KeyPrefixed.ToInner); + // rewrite only the key-shaped entries, copying only when there is something to rewrite so the common + // all-values call allocates nothing. + public static ReadOnlyMemory Map(this IRedisArgsMutator mutator, ReadOnlyMemory args) + { + if (args.Length is 0) return args; + var span = args.Span; + RedisKeyOrValue[]? copy = null; + for (int i = 0; i < span.Length; i++) + { + if (span[i].IsKey) + { + (copy ??= span.ToArray())[i] = RedisKeyOrValue.FromKey(mutator.Map(span[i].Key)); + } + } + return copy ?? args; + } + [return: NotNullIfNotNull("pairs")] public static KeyValuePair[]? Map( this IRedisArgsMutator mutator, diff --git a/src/StackExchange.Redis/Interfaces/IDatabase.cs b/src/StackExchange.Redis/Interfaces/IDatabase.cs index 10480ac52..94ce9a6d1 100644 --- a/src/StackExchange.Redis/Interfaces/IDatabase.cs +++ b/src/StackExchange.Redis/Interfaces/IDatabase.cs @@ -1612,23 +1612,10 @@ public partial interface IDatabase : IRedis, IDatabaseAsync /// /// The command to run. /// The arguments to pass for the command. - /// The arguments data disposer. /// The flags to use for this operation. - /// A dynamic representation of the command's result. - /// This API should be considered an advanced feature; inappropriate use can be harmful. - RedisResult Exec(string command, ReadOnlyMemory args = default, IRequestDisposer? argsDisposer = null, CommandFlags flags = CommandFlags.None); - - /// - /// Execute an arbitrary command against the server; this is primarily intended for executing modules, - /// but may also be used to provide access to new features that lack a direct API. - /// - /// The command to run. - /// The arguments to pass for the command. - /// The arguments data disposer. - /// The flags to use for this operation. - /// A dynamic representation of the command's result. + /// The raw, undecoded reply (dispose when done); check for a RESP null. /// This API should be considered an advanced feature; inappropriate use can be harmful. - Lease? ExecLease(string command, ReadOnlyMemory args = default, IRequestDisposer? argsDisposer = null, CommandFlags flags = CommandFlags.None); + RespResult ExecuteResp(string command, ReadOnlyMemory args, CommandFlags flags = CommandFlags.None); /// /// Execute an arbitrary command against the server; this is primarily intended for executing modules, @@ -1655,29 +1642,16 @@ public partial interface IDatabase : IRedis, IDatabaseAsync /// Execute a Lua script against the server. /// /// The script to execute. - /// The args to execute against. - /// The flags to use for this operation. - /// A dynamic representation of the script's result. - /// - /// See - /// , - /// . - /// - RedisResult ScriptEval(string script, ReadOnlyMemory args = default, CommandFlags flags = CommandFlags.None); - - /// - /// Execute a Lua script against the server. - /// - /// The script to execute. - /// The args to execute against. + /// The keys to execute against (available to the script as KEYS). + /// The values to execute against (available to the script as ARGV). /// The flags to use for this operation. - /// A dynamic representation of the script's scalar result. + /// The raw, undecoded reply (dispose when done); check for a RESP null. /// /// See /// , /// . /// - Lease? ScriptEvalLease(string script, ReadOnlyMemory args = default, CommandFlags flags = CommandFlags.None); + RespResult ScriptEvaluateResp(string script, ReadOnlyMemory keys, ReadOnlyMemory values, CommandFlags flags = CommandFlags.None); /// /// Execute a Lua script against the server. @@ -1736,7 +1710,8 @@ public partial interface IDatabase : IRedis, IDatabaseAsync /// Read-only variant of the EVAL command that cannot execute commands that modify data, Execute a Lua script against the server. /// /// The script to execute. - /// The args to execute against. + /// The keys to execute against. + /// The values to execute against. /// The flags to use for this operation. /// A dynamic representation of the script's result. /// @@ -1744,47 +1719,33 @@ public partial interface IDatabase : IRedis, IDatabaseAsync /// , /// . /// - RedisResult ScriptEvalReadOnly(string script, ReadOnlyMemory args = default, CommandFlags flags = CommandFlags.None); + RedisResult ScriptEvaluateReadOnly(string script, RedisKey[]? keys = null, RedisValue[]? values = null, CommandFlags flags = CommandFlags.None); /// - /// Read-only variant of the EVAL command that cannot execute commands that modify data, Execute a Lua script against the server. + /// Read-only variant of the EVALSHA command that cannot execute commands that modify data, Execute a Lua script against the server using just the SHA1 hash. /// - /// The script to execute. - /// The args to execute against. + /// The hash of the script to execute. + /// The keys to execute against. + /// The values to execute against. /// The flags to use for this operation. /// A dynamic representation of the script's result. - /// - /// See - /// , - /// . - /// - Lease? ScriptEvalReadOnlyLease(string script, ReadOnlyMemory args = default, CommandFlags flags = CommandFlags.None); + /// + RedisResult ScriptEvaluateReadOnly(byte[] hash, RedisKey[]? keys = null, RedisValue[]? values = null, CommandFlags flags = CommandFlags.None); /// - /// Read-only variant of the EVAL command that cannot execute commands that modify data, Execute a Lua script against the server. + /// Read-only variant of the EVAL command that cannot execute commands that modify data. /// /// The script to execute. - /// The keys to execute against. - /// The values to execute against. + /// The keys to execute against (available to the script as KEYS). + /// The values to execute against (available to the script as ARGV). /// The flags to use for this operation. - /// A dynamic representation of the script's result. + /// The raw, undecoded reply (dispose when done); check for a RESP null. /// /// See /// , /// . /// - RedisResult ScriptEvaluateReadOnly(string script, RedisKey[]? keys = null, RedisValue[]? values = null, CommandFlags flags = CommandFlags.None); - - /// - /// Read-only variant of the EVALSHA command that cannot execute commands that modify data, Execute a Lua script against the server using just the SHA1 hash. - /// - /// The hash of the script to execute. - /// The keys to execute against. - /// The values to execute against. - /// The flags to use for this operation. - /// A dynamic representation of the script's result. - /// - RedisResult ScriptEvaluateReadOnly(byte[] hash, RedisKey[]? keys = null, RedisValue[]? values = null, CommandFlags flags = CommandFlags.None); + RespResult ScriptEvaluateReadOnlyResp(string script, ReadOnlyMemory keys, ReadOnlyMemory values, CommandFlags flags = CommandFlags.None); /// /// Add the specified member to the set stored at key. diff --git a/src/StackExchange.Redis/Interfaces/IDatabaseAsync.cs b/src/StackExchange.Redis/Interfaces/IDatabaseAsync.cs index 7191d76b1..e2242095a 100644 --- a/src/StackExchange.Redis/Interfaces/IDatabaseAsync.cs +++ b/src/StackExchange.Redis/Interfaces/IDatabaseAsync.cs @@ -396,11 +396,8 @@ public partial interface IDatabaseAsync : IRedisAsync /// Task PublishAsync(RedisChannel channel, RedisValue message, CommandFlags flags = CommandFlags.None); - /// - Task ExecAsync(string command, ReadOnlyMemory args = default, IRequestDisposer? argsDisposer = null, CommandFlags flags = CommandFlags.None); - - /// - Task?> ExecLeaseAsync(string command, ReadOnlyMemory args = default, IRequestDisposer? argsDisposer = null, CommandFlags flags = CommandFlags.None); + /// + Task ExecuteRespAsync(string command, ReadOnlyMemory args, CommandFlags flags = CommandFlags.None); /// Task ExecuteAsync(string command, params object[] args); @@ -408,11 +405,8 @@ public partial interface IDatabaseAsync : IRedisAsync /// Task ExecuteAsync(string command, ICollection? args, CommandFlags flags = CommandFlags.None); - /// - Task ScriptEvalAsync(string script, ReadOnlyMemory args = default, CommandFlags flags = CommandFlags.None); - - /// - Task?> ScriptEvalLeaseAsync(string script, ReadOnlyMemory args = default, CommandFlags flags = CommandFlags.None); + /// + Task ScriptEvaluateRespAsync(string script, ReadOnlyMemory keys, ReadOnlyMemory values, CommandFlags flags = CommandFlags.None); /// Task ScriptEvaluateAsync(string script, RedisKey[]? keys = null, RedisValue[]? values = null, CommandFlags flags = CommandFlags.None); @@ -427,18 +421,15 @@ public partial interface IDatabaseAsync : IRedisAsync /// Task ScriptEvaluateAsync(LoadedLuaScript script, object? parameters = null, CommandFlags flags = CommandFlags.None); - /// - Task ScriptEvalReadOnlyAsync(string script, ReadOnlyMemory args = default, CommandFlags flags = CommandFlags.None); - - /// - Task?> ScriptEvalReadOnlyLeaseAsync(string script, ReadOnlyMemory args = default, CommandFlags flags = CommandFlags.None); - /// Task ScriptEvaluateReadOnlyAsync(string script, RedisKey[]? keys = null, RedisValue[]? values = null, CommandFlags flags = CommandFlags.None); /// Task ScriptEvaluateReadOnlyAsync(byte[] hash, RedisKey[]? keys = null, RedisValue[]? values = null, CommandFlags flags = CommandFlags.None); + /// + Task ScriptEvaluateReadOnlyRespAsync(string script, ReadOnlyMemory keys, ReadOnlyMemory values, CommandFlags flags = CommandFlags.None); + /// Task SetAddAsync(RedisKey key, RedisValue value, CommandFlags flags = CommandFlags.None); diff --git a/src/StackExchange.Redis/Interfaces/IRequestDisposer.cs b/src/StackExchange.Redis/Interfaces/IRequestDisposer.cs deleted file mode 100644 index 720c97013..000000000 --- a/src/StackExchange.Redis/Interfaces/IRequestDisposer.cs +++ /dev/null @@ -1,15 +0,0 @@ -using System; - -namespace StackExchange.Redis; - -/// -/// Disposing the request data. -/// -public interface IRequestDisposer -{ - /// - /// Disposing the request data. - /// - /// lua script request. - void Dispose(ReadOnlyMemory args); -} diff --git a/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixed.cs b/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixed.cs index f3d8363ce..7fd8c6cca 100644 --- a/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixed.cs +++ b/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixed.cs @@ -410,27 +410,12 @@ public Task StringLongestCommonSubsequenceWithMatchesAsync(Redis public Task PublishAsync(RedisChannel channel, RedisValue message, CommandFlags flags = CommandFlags.None) => Inner.PublishAsync(ToInner(channel), message, flags); - public Task ExecAsync(string command, ReadOnlyMemory args = default, IRequestDisposer? argsDisposer = null, CommandFlags flags = CommandFlags.None) + public Task ExecuteRespAsync(string command, ReadOnlyMemory args, CommandFlags flags = CommandFlags.None) { - // You cannot return an array twice or an array that is not from the pool. - argsDisposer = null; - - if ((flags & CommandFlags.FireAndForget) != 0) - return Inner.ExecAsync(command, ToInnerCopy(args), argsDisposer, flags); - - var result = Inner.ExecAsync(command, ToInnerLease(args, out var lease), argsDisposer, flags); - return lease != null ? ReturnAfterResult(result, lease) : result; - } - - public Task?> ExecLeaseAsync(string command, ReadOnlyMemory args = default, IRequestDisposer? argsDisposer = null, CommandFlags flags = CommandFlags.None) - { - // You cannot return an array twice or an array that is not from the pool. - argsDisposer = null; - if ((flags & CommandFlags.FireAndForget) != 0) - return Inner.ExecLeaseAsync(command, ToInnerCopy(args), argsDisposer, flags); + return Inner.ExecuteRespAsync(command, ToInnerCopy(args), flags); - var result = Inner.ExecLeaseAsync(command, ToInnerLease(args, out var lease), argsDisposer, flags); + var result = Inner.ExecuteRespAsync(command, ToInnerLease(args, out var lease), flags); return lease != null ? ReturnAfterResult(result, lease) : result; } @@ -444,21 +429,13 @@ public Task ScriptEvaluateAsync(byte[] hash, RedisKey[]? keys = nul // TODO: The return value could contain prefixed keys. It might make sense to 'unprefix' those? Inner.ScriptEvaluateAsync(hash, ToInner(keys), values, flags); - public Task ScriptEvalAsync(string script, ReadOnlyMemory args = default, CommandFlags flags = CommandFlags.None) - { - if ((flags & CommandFlags.FireAndForget) != 0) - return Inner.ScriptEvalAsync(script, ToInnerCopy(args), flags); - - var result = Inner.ScriptEvalAsync(script, ToInnerLease(args, out var lease), flags); - return lease != null ? ReturnAfterResult(result, lease) : result; - } - - public Task?> ScriptEvalLeaseAsync(string script, ReadOnlyMemory args = default, CommandFlags flags = CommandFlags.None) + public Task ScriptEvaluateRespAsync(string script, ReadOnlyMemory keys, ReadOnlyMemory values, CommandFlags flags = CommandFlags.None) { + // TODO: The return value could contain prefixed keys. It might make sense to 'unprefix' those? if ((flags & CommandFlags.FireAndForget) != 0) - return Inner.ScriptEvalLeaseAsync(script, ToInnerCopy(args), flags); + return Inner.ScriptEvaluateRespAsync(script, ToInnerCopy(keys), values, flags); - var result = Inner.ScriptEvalLeaseAsync(script, ToInnerLease(args, out var lease), flags); + var result = Inner.ScriptEvaluateRespAsync(script, ToInnerLease(keys, out var lease), values, flags); return lease != null ? ReturnAfterResult(result, lease) : result; } @@ -478,21 +455,13 @@ public Task ScriptEvaluateReadOnlyAsync(byte[] hash, RedisKey[]? ke // TODO: The return value could contain prefixed keys. It might make sense to 'unprefix' those? Inner.ScriptEvaluateAsync(hash, ToInner(keys), values, flags); - public Task ScriptEvalReadOnlyAsync(string script, ReadOnlyMemory args = default, CommandFlags flags = CommandFlags.None) - { - if ((flags & CommandFlags.FireAndForget) != 0) - return Inner.ScriptEvalReadOnlyAsync(script, ToInnerCopy(args), flags); - - var result = Inner.ScriptEvalReadOnlyAsync(script, ToInnerLease(args, out var lease), flags); - return lease != null ? ReturnAfterResult(result, lease) : result; - } - - public Task?> ScriptEvalReadOnlyLeaseAsync(string script, ReadOnlyMemory args = default, CommandFlags flags = CommandFlags.None) + public Task ScriptEvaluateReadOnlyRespAsync(string script, ReadOnlyMemory keys, ReadOnlyMemory values, CommandFlags flags = CommandFlags.None) { + // TODO: The return value could contain prefixed keys. It might make sense to 'unprefix' those? if ((flags & CommandFlags.FireAndForget) != 0) - return Inner.ScriptEvalReadOnlyLeaseAsync(script, ToInnerCopy(args), flags); + return Inner.ScriptEvaluateReadOnlyRespAsync(script, ToInnerCopy(keys), values, flags); - var result = Inner.ScriptEvalReadOnlyLeaseAsync(script, ToInnerLease(args, out var lease), flags); + var result = Inner.ScriptEvaluateReadOnlyRespAsync(script, ToInnerLease(keys, out var lease), values, flags); return lease != null ? ReturnAfterResult(result, lease) : result; } @@ -1082,11 +1051,86 @@ protected ReadOnlyMemory ToInnerLease(ReadOnlyMemory ReturnAfterResult(Task task, RedisKeyOrValue[] lease) + // the pooled buffer can only be recycled once we know the server has fully received and + // processed the write; success and RedisServerException both give that guarantee (the latter + // is still a server response, just an error one) - other exceptions (timeouts, connection + // failures) may mean the write is still in flight for a retry, so the buffer must survive. + private static async Task ReturnAfterResult(Task task, TElement[] lease) + { + var returnLease = true; + try + { + return await task; + } + catch (RedisServerException) + { + throw; + } + catch + { + returnLease = false; + throw; + } + finally + { + if (returnLease) ArrayPool.Shared.Return(lease, clearArray: true); + } + } + + // sync counterpart of ReturnAfterResult - see that method for the return-eligibility rationale. + // takes state + a (typically static) delegate rather than a capturing lambda, so callers can + // avoid allocating a closure and delegate per call; TState carries what the delegate needs + // instead (plain struct, not ValueTuple - this type still targets net461/netstandard2.0). + protected static TResult InvokeAndReturnLease(TState state, Func invoke, TElement[] lease) + { + var returnLease = true; + try + { + return invoke(state); + } + catch (RedisServerException) + { + throw; + } + catch + { + returnLease = false; + throw; + } + finally + { + if (returnLease) ArrayPool.Shared.Return(lease, clearArray: true); + } + } + + protected ReadOnlyMemory ToInnerCopy(ReadOnlyMemory outer) + { + if (outer.Length == 0) return outer; + + var inner = new RedisKey[outer.Length]; + var span = outer.Span; + for (int i = 0; i < span.Length; i++) + { + inner[i] = ToInner(span[i]); + } + return inner; + } + + protected ReadOnlyMemory ToInnerLease(ReadOnlyMemory outer, out RedisKey[]? lease) { - var result = await task; - ArrayPool.Shared.Return(lease, clearArray: true); - return result; + if (outer.Length == 0) + { + lease = null; + return outer; + } + + lease = ArrayPool.Shared.Rent(outer.Length); + var span = outer.Span; + for (int i = 0; i < span.Length; i++) + { + lease[i] = ToInner(span[i]); + } + return lease.AsMemory(0, outer.Length); } [return: NotNullIfNotNull("outer")] diff --git a/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixedDatabase.cs b/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixedDatabase.cs index 4ea97229c..ba2062851 100644 --- a/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixedDatabase.cs +++ b/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixedDatabase.cs @@ -382,32 +382,31 @@ public LCSMatchResult StringLongestCommonSubsequenceWithMatches(RedisKey first, public long Publish(RedisChannel channel, RedisValue message, CommandFlags flags = CommandFlags.None) => Inner.Publish(ToInner(channel), message, flags); - public RedisResult Exec(string command, ReadOnlyMemory args = default, IRequestDisposer? argsDisposer = null, CommandFlags flags = CommandFlags.None) + public RespResult ExecuteResp(string command, ReadOnlyMemory args, CommandFlags flags = CommandFlags.None) { - // You cannot return an array twice or an array that is not from the pool. - argsDisposer = null; - if ((flags & CommandFlags.FireAndForget) != 0) - return Inner.Exec(command, ToInnerCopy(args), argsDisposer, flags); - - var result = Inner.Exec(command, ToInnerLease(args, out var lease), argsDisposer, flags); - if (lease != null) ArrayPool.Shared.Return(lease, clearArray: true); + return Inner.ExecuteResp(command, ToInnerCopy(args), flags); - return result; + var inner = ToInnerLease(args, out var lease); + return lease != null + ? InvokeAndReturnLease(new ExecuteRespState(Inner, command, inner, flags), static s => s.Inner.ExecuteResp(s.Command, s.Args, s.Flags), lease) + : Inner.ExecuteResp(command, inner, flags); } - public Lease? ExecLease(string command, ReadOnlyMemory args = default, IRequestDisposer? argsDisposer = null, CommandFlags flags = CommandFlags.None) + private readonly struct ExecuteRespState { - // You cannot return an array twice or an array that is not from the pool. - argsDisposer = null; - - if ((flags & CommandFlags.FireAndForget) != 0) - return Inner.ExecLease(command, ToInnerCopy(args), argsDisposer, flags); - - var result = Inner.ExecLease(command, ToInnerLease(args, out var lease), argsDisposer, flags); - if (lease != null) ArrayPool.Shared.Return(lease, clearArray: true); - - return result; + public ExecuteRespState(IDatabase inner, string command, ReadOnlyMemory args, CommandFlags flags) + { + Inner = inner; + Command = command; + Args = args; + Flags = flags; + } + + public readonly IDatabase Inner; + public readonly string Command; + public readonly ReadOnlyMemory Args; + public readonly CommandFlags Flags; } public RedisResult Execute(string command, params object[] args) @@ -420,26 +419,16 @@ public RedisResult ScriptEvaluate(byte[] hash, RedisKey[]? keys = null, RedisVal // TODO: The return value could contain prefixed keys. It might make sense to 'unprefix' those? Inner.ScriptEvaluate(hash, ToInner(keys), values, flags); - public RedisResult ScriptEval(string script, ReadOnlyMemory args = default, CommandFlags flags = CommandFlags.None) + public RespResult ScriptEvaluateResp(string script, ReadOnlyMemory keys, ReadOnlyMemory values, CommandFlags flags = CommandFlags.None) { + // note the Resp API explicitly doesn't unprefix keys if ((flags & CommandFlags.FireAndForget) != 0) - return Inner.ScriptEval(script, ToInnerCopy(args), flags); - - var result = Inner.ScriptEval(script, ToInnerLease(args, out var lease), flags); - if (lease != null) ArrayPool.Shared.Return(lease, clearArray: true); + return Inner.ScriptEvaluateResp(script, ToInnerCopy(keys), values, flags); - return result; - } - - public Lease? ScriptEvalLease(string script, ReadOnlyMemory args = default, CommandFlags flags = CommandFlags.None) - { - if ((flags & CommandFlags.FireAndForget) != 0) - return Inner.ScriptEvalLease(script, ToInnerCopy(args), flags); - - var result = Inner.ScriptEvalLease(script, ToInnerLease(args, out var lease), flags); - if (lease != null) ArrayPool.Shared.Return(lease, clearArray: true); - - return result; + var inner = ToInnerLease(keys, out var lease); + return lease != null + ? InvokeAndReturnLease(new ScriptEvaluateRespState(Inner, script, inner, values, flags), static s => s.Inner.ScriptEvaluateResp(s.Script, s.Keys, s.Values, s.Flags), lease) + : Inner.ScriptEvaluateResp(script, inner, values, flags); } public RedisResult ScriptEvaluate(string script, RedisKey[]? keys = null, RedisValue[]? values = null, CommandFlags flags = CommandFlags.None) => @@ -458,26 +447,34 @@ public RedisResult ScriptEvaluateReadOnly(byte[] hash, RedisKey[]? keys = null, // TODO: The return value could contain prefixed keys. It might make sense to 'unprefix' those? Inner.ScriptEvaluateReadOnly(hash, ToInner(keys), values, flags); - public RedisResult ScriptEvalReadOnly(string script, ReadOnlyMemory args = default, CommandFlags flags = CommandFlags.None) + public RespResult ScriptEvaluateReadOnlyResp(string script, ReadOnlyMemory keys, ReadOnlyMemory values, CommandFlags flags = CommandFlags.None) { + // note the Resp API explicitly doesn't unprefix keys if ((flags & CommandFlags.FireAndForget) != 0) - return Inner.ScriptEvalReadOnly(script, ToInnerCopy(args), flags); + return Inner.ScriptEvaluateReadOnlyResp(script, ToInnerCopy(keys), values, flags); - var result = Inner.ScriptEvalReadOnly(script, ToInnerLease(args, out var lease), flags); - if (lease != null) ArrayPool.Shared.Return(lease, clearArray: true); - - return result; + var inner = ToInnerLease(keys, out var lease); + return lease != null + ? InvokeAndReturnLease(new ScriptEvaluateRespState(Inner, script, inner, values, flags), static s => s.Inner.ScriptEvaluateReadOnlyResp(s.Script, s.Keys, s.Values, s.Flags), lease) + : Inner.ScriptEvaluateReadOnlyResp(script, inner, values, flags); } - public Lease? ScriptEvalReadOnlyLease(string script, ReadOnlyMemory args = default, CommandFlags flags = CommandFlags.None) + private readonly struct ScriptEvaluateRespState { - if ((flags & CommandFlags.FireAndForget) != 0) - return Inner.ScriptEvalReadOnlyLease(script, ToInnerCopy(args), flags); - - var result = Inner.ScriptEvalReadOnlyLease(script, ToInnerLease(args, out var lease), flags); - if (lease != null) ArrayPool.Shared.Return(lease, clearArray: true); - - return result; + public ScriptEvaluateRespState(IDatabase inner, string script, ReadOnlyMemory keys, ReadOnlyMemory values, CommandFlags flags) + { + Inner = inner; + Script = script; + Keys = keys; + Values = values; + Flags = flags; + } + + public readonly IDatabase Inner; + public readonly string Script; + public readonly ReadOnlyMemory Keys; + public readonly ReadOnlyMemory Values; + public readonly CommandFlags Flags; } public RedisResult ScriptEvaluateReadOnly(string script, RedisKey[]? keys = null, RedisValue[]? values = null, CommandFlags flags = CommandFlags.None) => diff --git a/src/StackExchange.Redis/PhysicalConnection.Read.cs b/src/StackExchange.Redis/PhysicalConnection.Read.cs index e65941021..6690a7227 100644 --- a/src/StackExchange.Redis/PhysicalConnection.Read.cs +++ b/src/StackExchange.Redis/PhysicalConnection.Read.cs @@ -707,7 +707,7 @@ private void MatchNextResult(ReadOnlySpan frame) [DoesNotReturn] static void Throw(ReadOnlySpan frame, ConnectionType connection, RedisProtocol protocol) { - var prefix = RespReaderExtensions.GetRespPrefix(frame); + var prefix = RespReaderInternalExtensions.GetRespPrefix(frame); throw new InvalidOperationException($"Received {connection}/{protocol} response with no message waiting: " + prefix.ToString()); } } @@ -718,7 +718,7 @@ static void Throw(ReadOnlySpan frame, ConnectionType connection, RedisProt _readStatus = ReadStatus.ComputeResult; var reader = new RespReader(frame); - OnDetailLog($"computing result for {msg.CommandAndKey} ({RespReaderExtensions.GetRespPrefix(frame)})"); + OnDetailLog($"computing result for {msg.CommandAndKey} ({RespReaderInternalExtensions.GetRespPrefix(frame)})"); // need to capture HIT promptly, as -MOVED could cause a resend with a new high-integrity token // (a lazy approach would be to not rotate, but: we'd rather avoid that; the -MOVED case is rare) diff --git a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt index 73490bb4c..80c2629f5 100644 --- a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt @@ -23,24 +23,20 @@ StackExchange.Redis.ClusterSlotAssignment.Primary.get -> StackExchange.Redis.Clu StackExchange.Redis.ClusterSlotAssignment.Replicas.get -> System.Collections.Generic.IReadOnlyList! StackExchange.Redis.ClusterSlotAssignment.Slots.get -> StackExchange.Redis.SlotRange StackExchange.Redis.ClusterSlotNode -StackExchange.Redis.IRequestDisposer -StackExchange.Redis.IRequestDisposer.Dispose(System.ReadOnlyMemory args) -> void -StackExchange.Redis.IDatabase.Exec(string! command, System.ReadOnlyMemory args = default(System.ReadOnlyMemory), StackExchange.Redis.IRequestDisposer? argsDisposer = null, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> StackExchange.Redis.RedisResult! -StackExchange.Redis.IDatabaseAsync.ExecAsync(string! command, System.ReadOnlyMemory args = default(System.ReadOnlyMemory), StackExchange.Redis.IRequestDisposer? argsDisposer = null, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.Task! -StackExchange.Redis.IDatabase.ExecLease(string! command, System.ReadOnlyMemory args = default(System.ReadOnlyMemory), StackExchange.Redis.IRequestDisposer? argsDisposer = null, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> StackExchange.Redis.Lease? -StackExchange.Redis.IDatabaseAsync.ExecLeaseAsync(string! command, System.ReadOnlyMemory args = default(System.ReadOnlyMemory), StackExchange.Redis.IRequestDisposer? argsDisposer = null, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.Task?>! -StackExchange.Redis.IDatabase.ScriptEval(string! script, System.ReadOnlyMemory args = default(System.ReadOnlyMemory), StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> StackExchange.Redis.RedisResult! -StackExchange.Redis.IDatabaseAsync.ScriptEvalAsync(string! script, System.ReadOnlyMemory args = default(System.ReadOnlyMemory), StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.Task! -StackExchange.Redis.IDatabase.ScriptEvalReadOnly(string! script, System.ReadOnlyMemory args = default(System.ReadOnlyMemory), StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> StackExchange.Redis.RedisResult! -StackExchange.Redis.IDatabaseAsync.ScriptEvalReadOnlyAsync(string! script, System.ReadOnlyMemory args = default(System.ReadOnlyMemory), StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.Task! -StackExchange.Redis.IDatabase.ScriptEvalLease(string! script, System.ReadOnlyMemory args = default(System.ReadOnlyMemory), StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> StackExchange.Redis.Lease? -StackExchange.Redis.IDatabaseAsync.ScriptEvalLeaseAsync(string! script, System.ReadOnlyMemory args = default(System.ReadOnlyMemory), StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.Task?>! -StackExchange.Redis.IDatabase.ScriptEvalReadOnlyLease(string! script, System.ReadOnlyMemory args = default(System.ReadOnlyMemory), StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> StackExchange.Redis.Lease? -StackExchange.Redis.IDatabaseAsync.ScriptEvalReadOnlyLeaseAsync(string! script, System.ReadOnlyMemory args = default(System.ReadOnlyMemory), StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.Task?>! +StackExchange.Redis.IDatabase.ExecuteResp(string! command, System.ReadOnlyMemory args, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> StackExchange.Redis.RespResult! +StackExchange.Redis.IDatabaseAsync.ExecuteRespAsync(string! command, System.ReadOnlyMemory args, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.Task! +StackExchange.Redis.IDatabase.ScriptEvaluateResp(string! script, System.ReadOnlyMemory keys, System.ReadOnlyMemory values, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> StackExchange.Redis.RespResult! +StackExchange.Redis.IDatabaseAsync.ScriptEvaluateRespAsync(string! script, System.ReadOnlyMemory keys, System.ReadOnlyMemory values, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.Task! +StackExchange.Redis.IDatabase.ScriptEvaluateReadOnlyResp(string! script, System.ReadOnlyMemory keys, System.ReadOnlyMemory values, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> StackExchange.Redis.RespResult! +StackExchange.Redis.IDatabaseAsync.ScriptEvaluateReadOnlyRespAsync(string! script, System.ReadOnlyMemory keys, System.ReadOnlyMemory values, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.Task! +StackExchange.Redis.RespResult +StackExchange.Redis.RespResult.Prefix.get -> RESPite.Messages.RespPrefix +StackExchange.Redis.RespResult.IsNull.get -> bool +StackExchange.Redis.RespResult.Read() -> RESPite.Messages.RespReader +StackExchange.Redis.RespResult.ReadScalar() -> RESPite.Messages.RespReader +StackExchange.Redis.RespResult.Dispose() -> void StackExchange.Redis.RedisKeyOrValue StackExchange.Redis.RedisKeyOrValue.RedisKeyOrValue() -> void -StackExchange.Redis.RedisKeyOrValue.RedisKeyOrValue(in StackExchange.Redis.RedisKey key) -> void -StackExchange.Redis.RedisKeyOrValue.RedisKeyOrValue(in StackExchange.Redis.RedisValue value) -> void StackExchange.Redis.RedisKeyOrValue.IsNull.get -> bool StackExchange.Redis.RedisKeyOrValue.IsKey.get -> bool StackExchange.Redis.RedisKeyOrValue.Key.get -> StackExchange.Redis.RedisKey @@ -52,8 +48,8 @@ StackExchange.Redis.RedisKeyOrValue.Equals(StackExchange.Redis.RedisValue other) override StackExchange.Redis.RedisKeyOrValue.GetHashCode() -> int override StackExchange.Redis.RedisKeyOrValue.Equals(object? obj) -> bool override StackExchange.Redis.RedisKeyOrValue.ToString() -> string! -static StackExchange.Redis.RedisKeyOrValue.FromKey(StackExchange.Redis.RedisKey key) -> StackExchange.Redis.RedisKeyOrValue -static StackExchange.Redis.RedisKeyOrValue.FromValue(StackExchange.Redis.RedisValue value) -> StackExchange.Redis.RedisKeyOrValue +static StackExchange.Redis.RedisKeyOrValue.FromKey(in StackExchange.Redis.RedisKey key) -> StackExchange.Redis.RedisKeyOrValue +static StackExchange.Redis.RedisKeyOrValue.FromValue(in StackExchange.Redis.RedisValue value) -> StackExchange.Redis.RedisKeyOrValue static StackExchange.Redis.RedisKeyOrValue.operator ==(StackExchange.Redis.RedisKeyOrValue x, StackExchange.Redis.RedisKeyOrValue y) -> bool static StackExchange.Redis.RedisKeyOrValue.operator !=(StackExchange.Redis.RedisKeyOrValue x, StackExchange.Redis.RedisKeyOrValue y) -> bool static StackExchange.Redis.RedisKeyOrValue.implicit operator StackExchange.Redis.RedisKeyOrValue(StackExchange.Redis.RedisKey key) -> StackExchange.Redis.RedisKeyOrValue @@ -148,3 +144,8 @@ static StackExchange.Redis.BitFieldOperation.IncrementBy(StackExchange.Redis.Bit static StackExchange.Redis.BitFieldOperation.operator ==(StackExchange.Redis.BitFieldOperation x, StackExchange.Redis.BitFieldOperation y) -> bool static StackExchange.Redis.BitFieldOperation.operator !=(StackExchange.Redis.BitFieldOperation x, StackExchange.Redis.BitFieldOperation y) -> bool static StackExchange.Redis.BitFieldOperation.Set(StackExchange.Redis.BitFieldEncoding encoding, StackExchange.Redis.BitFieldOffset offset, long value, StackExchange.Redis.BitFieldOverflow overflow = StackExchange.Redis.BitFieldOverflow.Wrap) -> StackExchange.Redis.BitFieldOperation +StackExchange.Redis.RespReaderExtensions +static StackExchange.Redis.RespReaderExtensions.ReadRedisValue(this in RESPite.Messages.RespReader reader) -> StackExchange.Redis.RedisValue +static StackExchange.Redis.RespReaderExtensions.ReadLease(this in RESPite.Messages.RespReader reader, System.Buffers.MemoryPool? pool = null) -> StackExchange.Redis.Lease? +static StackExchange.Redis.RespReaderExtensions.ReadRedisResult(this in RESPite.Messages.RespReader reader) -> StackExchange.Redis.RedisResult! +static StackExchange.Redis.RespReaderExtensions.ReadRedisKey(this in RESPite.Messages.RespReader reader) -> StackExchange.Redis.RedisKey diff --git a/src/StackExchange.Redis/RedisDatabase.cs b/src/StackExchange.Redis/RedisDatabase.cs index 49b91db2f..669cb5a5a 100644 --- a/src/StackExchange.Redis/RedisDatabase.cs +++ b/src/StackExchange.Redis/RedisDatabase.cs @@ -2005,16 +2005,10 @@ public Task PublishAsync(RedisChannel channel, RedisValue message, Command return ExecuteAsync(msg, ResultProcessor.Int64, server: multiplexer.GetSubscribedServer(channel)); } - public RedisResult Exec(string command, ReadOnlyMemory args = default, IRequestDisposer? argsDisposer = null, CommandFlags flags = CommandFlags.None) + public RespResult ExecuteResp(string command, ReadOnlyMemory args, CommandFlags flags = CommandFlags.None) { - var msg = new ExecMessage(multiplexer?.CommandMap, Database, flags, command, args, argsDisposer); - return ExecuteSync(msg, ResultProcessor.ScriptResult)!; - } - - public Lease? ExecLease(string command, ReadOnlyMemory args = default, IRequestDisposer? argsDisposer = null, CommandFlags flags = CommandFlags.None) - { - var msg = new ExecMessage(multiplexer?.CommandMap, Database, flags, command, args, argsDisposer); - return ExecuteSync(msg, ResultProcessor.LeaseScript); + var msg = new ExecMessage(multiplexer?.CommandMap, Database, flags, command, args); + return ExecuteSync(msg, ResultProcessor.RespResult)!; } public RedisResult Execute(string command, params object[] args) @@ -2026,16 +2020,10 @@ public RedisResult Execute(string command, ICollection args, CommandFlag return ExecuteSync(msg, ResultProcessor.ScriptResult)!; } - public Task ExecAsync(string command, ReadOnlyMemory args = default, IRequestDisposer? argsDisposer = null, CommandFlags flags = CommandFlags.None) - { - var msg = new ExecMessage(multiplexer?.CommandMap, Database, flags, command, args, argsDisposer); - return ExecuteAsync(msg, ResultProcessor.ScriptResult, defaultValue: RedisResult.NullSingle); - } - - public Task?> ExecLeaseAsync(string command, ReadOnlyMemory args = default, IRequestDisposer? argsDisposer = null, CommandFlags flags = CommandFlags.None) + public Task ExecuteRespAsync(string command, ReadOnlyMemory args, CommandFlags flags = CommandFlags.None) { - var msg = new ExecMessage(multiplexer?.CommandMap, Database, flags, command, args, argsDisposer); - return ExecuteAsync(msg, ResultProcessor.LeaseScript); + var msg = new ExecMessage(multiplexer?.CommandMap, Database, flags, command, args); + return ExecuteAsync(msg, ResultProcessor.RespResult, defaultValue: RespResult.NullReply); } public Task ExecuteAsync(string command, params object[] args) @@ -2047,33 +2035,18 @@ public Task ExecuteAsync(string command, ICollection? args, return ExecuteAsync(msg, ResultProcessor.ScriptResult, defaultValue: RedisResult.NullSingle); } - public RedisResult ScriptEval(string script, ReadOnlyMemory args = default, CommandFlags flags = CommandFlags.None) - { - var command = ResultProcessor.ScriptLoadProcessor.IsSHA1(script) ? RedisCommand.EVALSHA : RedisCommand.EVAL; - var msg = new ScriptEvalMessage(Database, flags, command, script, args); - try - { - return ExecuteSync(msg, ResultProcessor.ScriptResult, defaultValue: RedisResult.NullSingle); - } - catch (RedisServerException) when (msg.IsScriptUnavailable) - { - // could be a NOSCRIPT; for a sync call, we can re-issue that without problem - return ExecuteSync(msg, ResultProcessor.ScriptResult, defaultValue: RedisResult.NullSingle); - } - } - - public Lease? ScriptEvalLease(string script, ReadOnlyMemory args = default, CommandFlags flags = CommandFlags.None) + public RespResult ScriptEvaluateResp(string script, ReadOnlyMemory keys, ReadOnlyMemory values, CommandFlags flags = CommandFlags.None) { var command = ResultProcessor.ScriptLoadProcessor.IsSHA1(script) ? RedisCommand.EVALSHA : RedisCommand.EVAL; - var msg = new ScriptEvalMessage(Database, flags, command, script, args); + var msg = new ScriptEvalMessage(Database, flags, command, script, keys, values); try { - return ExecuteSync(msg, ResultProcessor.LeaseScript); + return ExecuteSync(msg, ResultProcessor.RespResult)!; } catch (RedisServerException) when (msg.IsScriptUnavailable) { // could be a NOSCRIPT; for a sync call, we can re-issue that without problem - return ExecuteSync(msg, ResultProcessor.LeaseScript); + return ExecuteSync(msg, ResultProcessor.RespResult)!; } } @@ -2108,35 +2081,19 @@ public RedisResult ScriptEvaluate(LoadedLuaScript script, object? parameters = n return script.Evaluate(this, parameters, withKeyPrefix: null, flags); } - public async Task ScriptEvalAsync(string script, ReadOnlyMemory args = default, CommandFlags flags = CommandFlags.None) - { - var command = ResultProcessor.ScriptLoadProcessor.IsSHA1(script) ? RedisCommand.EVALSHA : RedisCommand.EVAL; - var msg = new ScriptEvalMessage(Database, flags, command, script, args); - - try - { - return await ExecuteAsync(msg, ResultProcessor.ScriptResult, defaultValue: RedisResult.NullSingle).ForAwait(); - } - catch (RedisServerException) when (msg.IsScriptUnavailable) - { - // could be a NOSCRIPT; for a sync call, we can re-issue that without problem - return await ExecuteAsync(msg, ResultProcessor.ScriptResult, defaultValue: RedisResult.NullSingle).ForAwait(); - } - } - - public async Task?> ScriptEvalLeaseAsync(string script, ReadOnlyMemory args = default, CommandFlags flags = CommandFlags.None) + public async Task ScriptEvaluateRespAsync(string script, ReadOnlyMemory keys, ReadOnlyMemory values, CommandFlags flags = CommandFlags.None) { var command = ResultProcessor.ScriptLoadProcessor.IsSHA1(script) ? RedisCommand.EVALSHA : RedisCommand.EVAL; - var msg = new ScriptEvalMessage(Database, flags, command, script, args); + var msg = new ScriptEvalMessage(Database, flags, command, script, keys, values); try { - return await ExecuteAsync(msg, ResultProcessor.LeaseScript).ForAwait(); + return await ExecuteAsync(msg, ResultProcessor.RespResult, defaultValue: RespResult.NullReply).ForAwait(); } catch (RedisServerException) when (msg.IsScriptUnavailable) { // could be a NOSCRIPT; for a sync call, we can re-issue that without problem - return await ExecuteAsync(msg, ResultProcessor.LeaseScript).ForAwait(); + return await ExecuteAsync(msg, ResultProcessor.RespResult, defaultValue: RespResult.NullReply).ForAwait(); } } @@ -2172,33 +2129,18 @@ public Task ScriptEvaluateAsync(LoadedLuaScript script, object? par return script.EvaluateAsync(this, parameters, withKeyPrefix: null, flags); } - public RedisResult ScriptEvalReadOnly(string script, ReadOnlyMemory args = default, CommandFlags flags = CommandFlags.None) - { - var command = ResultProcessor.ScriptLoadProcessor.IsSHA1(script) ? RedisCommand.EVALSHA_RO : RedisCommand.EVAL_RO; - var msg = new ScriptEvalMessage(Database, flags, command, script, args); - try - { - return ExecuteSync(msg, ResultProcessor.ScriptResult, defaultValue: RedisResult.NullSingle); - } - catch (RedisServerException) when (msg.IsScriptUnavailable) - { - // could be a NOSCRIPT; for a sync call, we can re-issue that without problem - return ExecuteSync(msg, ResultProcessor.ScriptResult, defaultValue: RedisResult.NullSingle); - } - } - - public Lease? ScriptEvalReadOnlyLease(string script, ReadOnlyMemory args = default, CommandFlags flags = CommandFlags.None) + public RespResult ScriptEvaluateReadOnlyResp(string script, ReadOnlyMemory keys, ReadOnlyMemory values, CommandFlags flags = CommandFlags.None) { var command = ResultProcessor.ScriptLoadProcessor.IsSHA1(script) ? RedisCommand.EVALSHA_RO : RedisCommand.EVAL_RO; - var msg = new ScriptEvalMessage(Database, flags, command, script, args); + var msg = new ScriptEvalMessage(Database, flags, command, script, keys, values); try { - return ExecuteSync(msg, ResultProcessor.LeaseScript); + return ExecuteSync(msg, ResultProcessor.RespResult)!; } catch (RedisServerException) when (msg.IsScriptUnavailable) { // could be a NOSCRIPT; for a sync call, we can re-issue that without problem - return ExecuteSync(msg, ResultProcessor.LeaseScript); + return ExecuteSync(msg, ResultProcessor.RespResult)!; } } @@ -2223,33 +2165,18 @@ public RedisResult ScriptEvaluateReadOnly(byte[] hash, RedisKey[]? keys = null, return ExecuteSync(msg, ResultProcessor.ScriptResult, defaultValue: RedisResult.NullSingle); } - public async Task ScriptEvalReadOnlyAsync(string script, ReadOnlyMemory args = default, CommandFlags flags = CommandFlags.None) - { - var command = ResultProcessor.ScriptLoadProcessor.IsSHA1(script) ? RedisCommand.EVALSHA_RO : RedisCommand.EVAL_RO; - var msg = new ScriptEvalMessage(Database, flags, command, script, args); - try - { - return await ExecuteAsync(msg, ResultProcessor.ScriptResult, defaultValue: RedisResult.NullSingle).ForAwait(); - } - catch (RedisServerException) when (msg.IsScriptUnavailable) - { - // could be a NOSCRIPT; for a sync call, we can re-issue that without problem - return await ExecuteAsync(msg, ResultProcessor.ScriptResult, defaultValue: RedisResult.NullSingle).ForAwait(); - } - } - - public async Task?> ScriptEvalReadOnlyLeaseAsync(string script, ReadOnlyMemory args = default, CommandFlags flags = CommandFlags.None) + public async Task ScriptEvaluateReadOnlyRespAsync(string script, ReadOnlyMemory keys, ReadOnlyMemory values, CommandFlags flags = CommandFlags.None) { var command = ResultProcessor.ScriptLoadProcessor.IsSHA1(script) ? RedisCommand.EVALSHA_RO : RedisCommand.EVAL_RO; - var msg = new ScriptEvalMessage(Database, flags, command, script, args); + var msg = new ScriptEvalMessage(Database, flags, command, script, keys, values); try { - return await ExecuteAsync(msg, ResultProcessor.LeaseScript).ForAwait(); + return await ExecuteAsync(msg, ResultProcessor.RespResult, defaultValue: RespResult.NullReply).ForAwait(); } catch (RedisServerException) when (msg.IsScriptUnavailable) { // could be a NOSCRIPT; for a sync call, we can re-issue that without problem - return await ExecuteAsync(msg, ResultProcessor.LeaseScript).ForAwait(); + return await ExecuteAsync(msg, ResultProcessor.RespResult, defaultValue: RespResult.NullReply).ForAwait(); } } @@ -6082,7 +6009,6 @@ protected override bool SetResultCore(PhysicalConnection connection, Message mes internal sealed class ExecMessage : Message { private readonly ReadOnlyMemory _args; - private readonly IRequestDisposer? _argsDisposer; private string _unknownCommand; private static int RemoveDbIfNotRequired(int suggestedDb, string adhocCommand, out RedisCommand knownCommand) @@ -6102,7 +6028,7 @@ private static int RemoveDbIfNotRequired(int suggestedDb, string adhocCommand, o return suggestedDb; } - public ExecMessage(CommandMap? map, int db, CommandFlags flags, string command, ReadOnlyMemory args, IRequestDisposer? argsDisposer) + public ExecMessage(CommandMap? map, int db, CommandFlags flags, string command, ReadOnlyMemory args) : base(RemoveDbIfNotRequired(db, command, out var knownCommand), flags, knownCommand) { if (args.Length >= MessageWriter.REDIS_MAX_ARGS) // using >= here because we will be adding 1 for the command itself (which is an arg for the purposes of the multi-bulk protocol) @@ -6126,7 +6052,6 @@ public ExecMessage(CommandMap? map, int db, CommandFlags flags, string command, throw ExceptionFactory.CommandDisabled(command); } _args = args; - _argsDisposer = argsDisposer; } protected override void WriteImpl(in MessageWriter writer) @@ -6141,22 +6066,20 @@ protected override void WriteImpl(in MessageWriter writer) } foreach (ref readonly var arg in _args.Span) { - var type = arg.Type; - if (type == RedisKeyOrValue.StorageType.Value) + if (arg.IsKey) { - writer.WriteBulkString(arg.UnsafeValue); + writer.Write(arg.Key); } - else if (type == RedisKeyOrValue.StorageType.Key) + else if (arg.IsValue) { - writer.Write(arg.UnsafeKey); + writer.WriteBulkString(arg.Value); } else { - Debug.Assert(type == RedisKeyOrValue.StorageType.Null); + Debug.Assert(arg.IsNull); throw new InvalidOperationException("A null is not valid in this context"); } } - _argsDisposer?.Dispose(_args); } public override string CommandString => Command is RedisCommand.UNKNOWN ? _unknownCommand : base.CommandString; @@ -6311,53 +6234,24 @@ protected override bool TryGetSubCommand(out SubCommand subCommand) private sealed class ScriptEvalMessage : Message, IMultiMessage { - private readonly ReadOnlyMemory _args; - private readonly int _keysCount; + private readonly ReadOnlyMemory _keys; + private readonly ReadOnlyMemory _values; private readonly string _script; private byte[]? asciiHash; - public ScriptEvalMessage(int db, CommandFlags flags, RedisCommand command, string script, ReadOnlyMemory args) + public ScriptEvalMessage(int db, CommandFlags flags, RedisCommand command, string script, ReadOnlyMemory keys, ReadOnlyMemory values) : base(db, flags, command) { _script = script ?? throw new ArgumentNullException(nameof(script)); - - int keysCount = 0; - var span = args.Span; - foreach (ref readonly var arg in span) - { - var type = arg.Type; - if (type == RedisKeyOrValue.StorageType.Null) throw new ArgumentException("A null is not valid in this context", nameof(args)); - if (type == RedisKeyOrValue.StorageType.Key) - { - keysCount++; - } - else - { - Debug.Assert(type == RedisKeyOrValue.StorageType.Value); - break; - } - } - - if (span.Length > keysCount + 1) - { - foreach (ref readonly var arg in span.Slice(keysCount + 1)) - { - var type = arg.Type; - if (type != RedisKeyOrValue.StorageType.Value) - throw new ArgumentException(type == RedisKeyOrValue.StorageType.Null ? "A null is not valid in this context" : "A key is not valid in this context. Keys must come before values.", nameof(args)); - } - } - _args = args; - _keysCount = keysCount; + _keys = keys; + _values = values; } public override int GetHashSlot(ServerSelectionStrategy serverSelectionStrategy) { int slot = ServerSelectionStrategy.NoSlot; - foreach (ref readonly var arg in _args.Span.Slice(0, _keysCount)) + foreach (ref readonly var key in _keys.Span) { - Debug.Assert(arg.IsKey); - - slot = serverSelectionStrategy.CombineSlot(slot, arg.Key); + slot = serverSelectionStrategy.CombineSlot(slot, key); } return slot; } @@ -6387,33 +6281,28 @@ protected override void WriteImpl(in MessageWriter writer) { if (asciiHash != null) { - writer.WriteHeader(RedisCommand.EVALSHA, 2 + _args.Length); + writer.WriteHeader(RedisCommand.EVALSHA, 2 + _keys.Length + _values.Length); writer.WriteBulkString(asciiHash); } else { - writer.WriteHeader(RedisCommand.EVAL, 2 + _args.Length); + writer.WriteHeader(RedisCommand.EVAL, 2 + _keys.Length + _values.Length); writer.WriteBulkString(_script); } - writer.WriteBulkString(_keysCount); + writer.WriteBulkString(_keys.Length); - var span = _args.Span; - foreach (ref readonly var arg in span.Slice(0, _keysCount)) + foreach (ref readonly var key in _keys.Span) { - Debug.Assert(arg.IsKey); - - writer.Write(arg.Key); + writer.Write(key); } - foreach (ref readonly var arg in span.Slice(_keysCount)) + foreach (ref readonly var value in _values.Span) { - Debug.Assert(arg.IsValue); - - writer.WriteBulkString(arg.Value); + writer.WriteBulkString(value); } } - public override int ArgCount => 2 + _args.Length; + public override int ArgCount => 2 + _keys.Length + _values.Length; } private sealed class ScriptEvaluateMessage : Message, IMultiMessage diff --git a/src/StackExchange.Redis/RedisKeyOrValue.cs b/src/StackExchange.Redis/RedisKeyOrValue.cs index e64ab7352..227f0e6c2 100644 --- a/src/StackExchange.Redis/RedisKeyOrValue.cs +++ b/src/StackExchange.Redis/RedisKeyOrValue.cs @@ -1,190 +1,120 @@ -using System; -using System.Diagnostics; -using System.Runtime.CompilerServices; -using System.Runtime.InteropServices; +using System; namespace StackExchange.Redis; /// /// Represents a key or value that can be stored in redis. /// -[StructLayout(LayoutKind.Explicit)] public readonly struct RedisKeyOrValue : IEquatable, IEquatable, IEquatable { - internal enum StorageType - { - Null, - Key, - Value, - } - -#pragma warning disable SA1134 - [FieldOffset(0)] private readonly int _index; - [FieldOffset(4)] private readonly int _length; - [FieldOffset(8)] private readonly object? _obj; -#pragma warning restore SA1134 - - internal StorageType Type - { - get - { - var obj = _obj; - if (obj == null) return StorageType.Null; - if ((obj is byte[] || obj is string) && _index < 0) return StorageType.Key; - return StorageType.Value; - } - } - - internal RedisKey UnsafeKey - { - get - { - Debug.Assert(IsKey); - - return new RedisKey(null, _obj); - } - } - - internal RedisValue UnsafeValue - { - get - { - Debug.Assert(IsValue); - - var copy = this; - return Unsafe.As(ref copy); - } - } + // _keyPrefix is non-null (possibly empty) when this represents a key - it is the key's own prefix + // bytes, if any; the key's remaining payload (its "KeyValue") sits directly in _value, using + // RedisValue's own byte[]/string storage rather than a separate copy. _keyPrefix is null when this + // represents a value (or a genuine null - see IsNull/IsKey/IsValue below, which are deliberately + // independent checks rather than a single tri-state tag, but remain mutually exclusive in practice). + private readonly RedisValue _value; + private readonly byte[]? _keyPrefix; /// /// IsNull. /// - public bool IsNull => _obj is null; + public bool IsNull => _value.IsNull; /// /// IsKey. /// - public bool IsKey => Type == StorageType.Key; + public bool IsKey => _keyPrefix is not null; /// /// Key. /// - public RedisKey Key => Type == StorageType.Key ? new RedisKey(null, _obj) : default; + public RedisKey Key => IsKey ? new RedisKey(_keyPrefix, _value.DirectObject) : default; /// /// IsValue. /// - public bool IsValue => Type == StorageType.Value; + public bool IsValue => _keyPrefix is null && !_value.IsNull; /// /// Value. /// - public RedisValue Value - { - get - { - if (Type != StorageType.Value) return default; - - var copy = this; - return Unsafe.As(ref copy); - } - } + public RedisValue Value => IsKey ? default : _value; - /// - /// Key. - /// - /// key. - public RedisKeyOrValue(in RedisKey key) + // Construction is deliberately funneled through FromKey/FromValue and the implicit operators only, + // not public constructors: with both RedisKey and RedisValue implicitly constructible from a bare + // literal (e.g. a string), a public RedisKeyOrValue(RedisKey)/(RedisValue) ctor pair would make + // `new RedisKeyOrValue("abc")` ambiguous between the two. + private RedisKeyOrValue(in RedisKey key) { - var keyValue = key.KeyValue; - var keyPrefix = key.KeyPrefix; - if (keyPrefix != null) - { - if (keyValue != null) - keyPrefix = (byte[]?)key ?? throw new InvalidOperationException("keyPrefix is null"); - - _obj = keyPrefix; - _index = -1; - _length = keyPrefix.Length; - } - else if (keyValue == null) - { - this = default; - } - else if (keyValue is byte[] bytes) - { - _obj = bytes; - _index = -1; - _length = bytes.Length; - } - else if (keyValue is string str) - { - _obj = str; - _index = -1; - _length = str.Length; - } - else + // an empty (rather than null) prefix still marks this as a key - see IsKey - and RedisKey's own + // constructor normalizes a zero-length prefix back to null, so nothing is lost by using it here. + _keyPrefix = key.KeyPrefix ?? Array.Empty(); + + // KeyValue is only ever null/byte[]/string; assign it directly (never via a repacking + // conversion such as RedisValue.FromRaw) so DirectObject can hand the exact object back later. + _value = key.KeyValue switch { - throw new ArgumentException("Unrecognized key type", nameof(key)); - } + null => default, + byte[] bytes => bytes, + // .AsRedisValue(), not the bare implicit conversion: this is a deliberate, intentional + // wrap of the key's own payload, not a string read off the wire (see StringToRedisValue.md). + string str => str.AsRedisValue(), + _ => throw new ArgumentException("Unrecognized key type", nameof(key)), + }; } - /// - /// Value. - /// - /// value. - public RedisKeyOrValue(in RedisValue value) + private RedisKeyOrValue(in RedisValue value) { - var copy = value; - this = Unsafe.As(ref copy); + _keyPrefix = null; + _value = value; } /// - public override int GetHashCode() => Type switch + public override int GetHashCode() { - StorageType.Key => UnsafeKey.GetHashCode(), - StorageType.Value => UnsafeValue.GetHashCode(), - _ => 0, - }; + if (IsKey) return Key.GetHashCode(); + if (IsValue) return _value.GetHashCode(); + return 0; + } /// public override bool Equals(object? obj) => obj switch { RedisKeyOrValue other => Equals(other), - RedisKey key => Type == StorageType.Key && UnsafeKey.Equals(key), - RedisValue value => Type == StorageType.Value && UnsafeValue.Equals(value), + RedisKey key => IsKey && Key.Equals(key), + RedisValue value => IsValue && _value.Equals(value), _ => false, }; /// - public override string ToString() => Type switch + public override string ToString() { - StorageType.Key => UnsafeKey.ToString(), - StorageType.Value => UnsafeValue.ToString(), - _ => "(null)", - }; + if (IsKey) return Key.ToString(); + if (IsValue) return _value.ToString(); + return "(null)"; + } /// - public bool Equals(RedisKeyOrValue other) => Type switch + public bool Equals(RedisKeyOrValue other) { - StorageType.Key => other.Type == StorageType.Key && UnsafeKey.Equals(other.UnsafeKey), - StorageType.Value => other.Type == StorageType.Value && UnsafeValue.Equals(other.UnsafeValue), - _ => other.Type == Type, - }; + if (IsKey) return other.IsKey && Key.Equals(other.Key); + if (IsValue) return other.IsValue && _value.Equals(other._value); + return other is { IsKey: false, IsNull: true }; // both a genuine null, not merely a null-valued key + } /// - public bool Equals(RedisKey other) => Type == StorageType.Key && UnsafeKey.Equals(other); + public bool Equals(RedisKey other) => IsKey && Key.Equals(other); /// - public bool Equals(RedisValue other) => Type == StorageType.Value && UnsafeValue.Equals(other); + public bool Equals(RedisValue other) => IsValue && _value.Equals(other); /// Create a new instance representing a key. /// key. - public static RedisKeyOrValue FromKey(RedisKey key) => new RedisKeyOrValue(in key); + public static RedisKeyOrValue FromKey(in RedisKey key) => new(in key); /// Create a new instance representing a value. /// value. - public static RedisKeyOrValue FromValue(RedisValue value) => new RedisKeyOrValue(in value); + public static RedisKeyOrValue FromValue(in RedisValue value) => new(in value); /// /// Compares two values for equality. @@ -212,21 +142,18 @@ public RedisKeyOrValue(in RedisValue value) /// value. public static explicit operator RedisKey(RedisKeyOrValue value) { - if (value.Type != StorageType.Key) - ThrowInvalidCast(value.Type); - - return value.UnsafeKey; + if (!value.IsKey) ThrowInvalidCast(value); + return value.Key; } /// Obtains the underlying payload as a value. /// value. public static explicit operator RedisValue(RedisKeyOrValue value) { - if (value.Type != StorageType.Value) - ThrowInvalidCast(value.Type); - - return value.UnsafeValue; + if (!value.IsValue) ThrowInvalidCast(value); + return value._value; } - private static void ThrowInvalidCast(StorageType type) => throw new InvalidCastException($"Operation not valid on {type} value."); + private static void ThrowInvalidCast(in RedisKeyOrValue value) => + throw new InvalidCastException($"Operation not valid on {(value.IsKey ? "Key" : value.IsValue ? "Value" : "Null")} value."); } diff --git a/src/StackExchange.Redis/RedisValue.cs b/src/StackExchange.Redis/RedisValue.cs index 01f584bf1..accb21efa 100644 --- a/src/StackExchange.Redis/RedisValue.cs +++ b/src/StackExchange.Redis/RedisValue.cs @@ -180,6 +180,13 @@ public RedisValue(string value) // ReSharper restore ConvertToAutoProperty #pragma warning restore RCS1085 // use auto-prop + // Raw access to the underlying object, with zero interpretation. Safe to read on any instance + // (Simplify() never mutates - it only ever returns a *new* value), but only meaningful to the + // caller if this instance was itself built without going through a repacking path such as + // FromRaw (which can fold a short byte[]/string payload into the inline ShortBlob form, in which + // case DirectObject is a shared sentinel, not the original byte[]/string). + internal object? DirectObject => _obj; + private static readonly object Sentinel_SignedInteger = new(); private static readonly object Sentinel_UnsignedInteger = new(); private static readonly object Sentinel_Double = new(); diff --git a/src/StackExchange.Redis/RespReaderExtensions.cs b/src/StackExchange.Redis/RespReaderExtensions.cs index 5683208eb..c685a955a 100644 --- a/src/StackExchange.Redis/RespReaderExtensions.cs +++ b/src/StackExchange.Redis/RespReaderExtensions.cs @@ -1,237 +1,105 @@ -using System; +using System; using System.Buffers; using System.Diagnostics; -using System.Runtime.CompilerServices; -using System.Threading.Tasks; +using System.Diagnostics.CodeAnalysis; +using RESPite; using RESPite.Messages; namespace StackExchange.Redis; -internal static class RespReaderExtensions +/// +/// Provides utility methods for consuming values as additional SE.Redis common types. +/// +public static class RespReaderExtensions { - extension(in RespReader reader) + /// + /// Read a scalar value as a . + /// + public static RedisValue ReadRedisValue(this in RespReader reader) { - public RedisValue ReadRedisValue() - { - reader.DemandScalar(); - if (reader.IsNull) return RedisValue.Null; - - switch (reader.Prefix) - { - case RespPrefix.Boolean: - return reader.ReadBoolean(); - case RespPrefix.Integer: - return reader.ReadInt64(); - } - - // bulk/simple/verbatim string. Only inline (non-streaming) scalars get the compact storage - // kinds; streaming scalars fall through to ReadByteArray. - if (reader.IsInlineScalar) - { - var length = reader.ScalarLength(); - - // Short payloads (<= 8 bytes) pack inline as a short-blob: allocation-free, and with *no* - // eager numeric parse - any later (long)/(double)/etc. is deferred to the caller (Simplify - // on demand), which is cheaper for the common case of values never interpreted as numbers. - // Contiguous data (the common case) is taken straight from TryGetSpan - no stackalloc. Only a - // scalar that straddles segments needs linearizing into the 8-byte stack buffer; the length - // guard is what makes that fixed buffer safe, since Buffer() silently truncates an over-long - // discontiguous payload. - if (length <= RedisValue.MaxInlineBytes) - { - return RedisValue.FromRaw(reader.TryGetSpan(out var buffer) ? - buffer : reader.Buffer(stackalloc byte[RedisValue.MaxInlineBytes])); - } - - // Longer payloads: prefer a compact numeric storage kind when the text is the *canonical* - // representation of that number, so every projection (ToString, (byte[]), equality, hash) - // still round-trips byte-for-byte; this also avoids the byte[] alloc. Canonical parsing needs - // a contiguous span, so a discontiguous payload falls through to ReadByteArray. - if (reader.TryGetSpan(out var span) && TryReadCanonicalNumber(span, out var number)) - { - return number; - } - } - return reader.ReadByteArray(); - } - - public string DebugReadTruncatedString(int maxChars) - { - if (!reader.IsScalar) return ""; - try - { - var s = reader.ReadString() ?? ""; - return s.Length <= maxChars ? s : s.Substring(0, maxChars) + "..."; - } - catch - { - return ""; - } - } - - public RedisKey ReadRedisKey() => (RedisKey)reader.ReadByteArray(); + reader.DemandScalar(); + if (reader.IsNull) return RedisValue.Null; - public RedisChannel ReadRedisChannel(RedisChannel.RedisChannelOptions options) - => new(reader.ReadByteArray(), options); - - private bool TryGetFirst(out string first) + switch (reader.Prefix) { - if (reader.IsNonNullAggregate && !reader.AggregateIsEmpty()) - { - var clone = reader.Clone(); - if (clone.TryMoveNext()) - { - unsafe - { - if (clone.IsScalar && - clone.TryParseScalar(&PhysicalConnection.PushKindMetadata.TryParse, out PhysicalConnection.PushKind kind)) - { - first = kind.ToString(); - return true; - } - } - - first = clone.GetOverview(); - return true; - } - } - first = ""; - return false; + case RespPrefix.Boolean: + return reader.ReadBoolean(); + case RespPrefix.Integer: + return reader.ReadInt64(); } - public string GetOverview() + // bulk/simple/verbatim string. Only inline (non-streaming) scalars get the compact storage + // kinds; streaming scalars fall through to ReadByteArray. + if (reader.IsInlineScalar) { - // return reader.BufferUtf8(); // <== for when you really can't grok what is happening - if (reader.Prefix is RespPrefix.None) - { - var copy = reader; - copy.MovePastBof(); - return copy.Prefix is RespPrefix.None ? "(empty)" : copy.GetOverview(); - } - if (reader.IsNull) return "(null)"; - - return reader.Prefix switch - { - RespPrefix.SimpleString or RespPrefix.Integer or RespPrefix.SimpleError or RespPrefix.Double => $"{reader.Prefix}: {reader.ReadString()}", - RespPrefix.Push when reader.TryGetFirst(out var first) => $"{reader.Prefix} ({first}): {reader.AggregateLength()} items", - _ when reader.IsScalar => $"{reader.Prefix}: {reader.ScalarLength()} bytes, '{reader.DebugReadTruncatedString(16)}'", - _ when reader.IsAggregate => $"{reader.Prefix}: {reader.AggregateLength()} items", - _ => $"(unknown: {reader.Prefix})", - }; - } + var length = reader.ScalarLength(); - public RespPrefix GetFirstPrefix() - { - var prefix = reader.Prefix; - if (prefix is RespPrefix.None) - { - var mutable = reader; - mutable.MovePastBof(); - prefix = mutable.Prefix; + // Short payloads (<= 8 bytes) pack inline as a short-blob: allocation-free, and with *no* + // eager numeric parse - any later (long)/(double)/etc. is deferred to the caller (Simplify + // on demand), which is cheaper for the common case of values never interpreted as numbers. + // Contiguous data (the common case) is taken straight from TryGetSpan - no stackalloc. Only a + // scalar that straddles segments needs linearizing into the 8-byte stack buffer; the length + // guard is what makes that fixed buffer safe, since Buffer() silently truncates an over-long + // discontiguous payload. + if (length <= RedisValue.MaxInlineBytes) + { + return RedisValue.FromRaw(reader.TryGetSpan(out var buffer) ? + buffer : reader.Buffer(stackalloc byte[RedisValue.MaxInlineBytes])); } - return prefix; - } - - /* - public bool AggregateHasAtLeast(int count) - { - reader.DemandAggregate(); - if (reader.IsNull) return false; - if (reader.IsStreaming) return CheckStreamingAggregateAtLeast(in reader, count); - return reader.AggregateLength() >= count; - static bool CheckStreamingAggregateAtLeast(in RespReader reader, int count) + // Longer payloads: prefer a compact numeric storage kind when the text is the *canonical* + // representation of that number, so every projection (ToString, (byte[]), equality, hash) + // still round-trips byte-for-byte; this also avoids the byte[] alloc. Canonical parsing needs + // a contiguous span, so a discontiguous payload falls through to ReadByteArray. + if (reader.TryGetSpan(out var span) && TryReadCanonicalNumber(span, out var number)) { - var iter = reader.AggregateChildren(); - object? attributes = null; - while (count > 0 && iter.MoveNextRaw(null!, ref attributes)) - { - count--; - } - - return count == 0; + return number; } } - */ + return reader.ReadByteArray(); } - extension(ref RespReader reader) + /// + /// Read a scalar value as a . + /// + public static Lease? ReadLease(this in RespReader reader, MemoryPool? pool = null) { - public bool SafeTryMoveNext() => reader.TryMoveNext(checkError: false) & !reader.IsError; + reader.DemandScalar(); + if (reader.IsNull) return null; - public void MovePastBof() + var length = reader.ScalarLength(); + if (length == 0) return Lease.Empty; + + var lease = Lease.Create(length, pool, clear: false); + if (reader.TryGetSpan(out var span)) { - // if we're at BOF, read the first element, ignoring errors - if (reader.Prefix is RespPrefix.None) reader.SafeTryMoveNext(); + span.CopyTo(lease.Span); } - - public RedisValue[]? ReadPastRedisValues() - => reader.ReadPastArray(static (ref r) => r.ReadRedisValue(), scalar: true); - - public Lease? AsLease(PhysicalConnection? connection) + else { - if (!reader.IsScalar) throw new InvalidCastException("Cannot convert to Lease: " + reader.Prefix); - if (reader.IsNull) return null; - - var length = reader.ScalarLength(); - if (length == 0) return Lease.Empty; - - var pool = connection?.BridgeCouldBeNull?.Multiplexer?.RawConfig?.ResponseBufferPool; - var lease = Lease.Create(length, pool, clear: false); - if (reader.TryGetSpan(out var span)) - { - span.CopyTo(lease.Span); - } - else - { - var buffer = reader.Buffer(lease.Span); - Debug.Assert(buffer.Length == length, "buffer length mismatch"); - } - return lease; + var buffer = reader.Buffer(lease.Span); + Debug.Assert(buffer.Length == length, "buffer length mismatch"); } + return lease; } - public static RespPrefix GetRespPrefix(ReadOnlySpan frame) - { - var reader = new RespReader(frame); - reader.SafeTryMoveNext(); - return reader.Prefix; - } - - extension(RespPrefix prefix) - { - public ResultType ToResultType() => prefix switch - { - RespPrefix.Array => ResultType.Array, - RespPrefix.Attribute => ResultType.Attribute, - RespPrefix.BigInteger => ResultType.BigInteger, - RespPrefix.Boolean => ResultType.Boolean, - RespPrefix.BulkError => ResultType.BlobError, - RespPrefix.BulkString => ResultType.BulkString, - RespPrefix.SimpleString => ResultType.SimpleString, - RespPrefix.Map => ResultType.Map, - RespPrefix.Set => ResultType.Set, - RespPrefix.Double => ResultType.Double, - RespPrefix.Integer => ResultType.Integer, - RespPrefix.SimpleError => ResultType.Error, - RespPrefix.Null => ResultType.Null, - RespPrefix.VerbatimString => ResultType.VerbatimString, - RespPrefix.Push => ResultType.Push, - _ => throw new ArgumentOutOfRangeException(nameof(prefix), prefix, null), - }; - } + /// + /// Read a scalar value as a ; note that no key-prefix compensation is made. + /// + /// The key value. + public static RedisKey ReadRedisKey(this in RespReader reader) => (RedisKey)reader.ReadByteArray(); - extension(T?[] array) where T : class + /// + /// Read a value - scalar, aggregate, error, or null - as a . + /// + public static RedisResult ReadRedisResult(this in RespReader reader) { - internal bool AnyNull() + var mutable = reader.Clone(); + if (!RedisResult.TryCreate(null, ref mutable, out var result)) { - foreach (var el in array) - { - if (el is null) return true; - } - - return false; + throw new InvalidOperationException("Unable to interpret RESP reply as a RedisResult"); } + return result; } private static readonly int MaxCanonicalLength = Math.Max(Format.MaxInt64TextLen, Format.MaxDoubleTextLen); diff --git a/src/StackExchange.Redis/RespReaderInternalExtensions.cs b/src/StackExchange.Redis/RespReaderInternalExtensions.cs new file mode 100644 index 000000000..5918fa189 --- /dev/null +++ b/src/StackExchange.Redis/RespReaderInternalExtensions.cs @@ -0,0 +1,167 @@ +using System; +using RESPite.Messages; + +namespace StackExchange.Redis; + +internal static class RespReaderInternalExtensions +{ + extension(in RespReader reader) + { + internal Lease? AsLease(PhysicalConnection? connection) + => reader.ReadLease(connection?.BridgeCouldBeNull?.Multiplexer?.RawConfig?.ResponseBufferPool); + + internal string DebugReadTruncatedString(int maxChars) + { + if (!reader.IsScalar) return ""; + try + { + var s = reader.ReadString() ?? ""; + return s.Length <= maxChars ? s : s.Substring(0, maxChars) + "..."; + } + catch + { + return ""; + } + } + + internal RedisChannel ReadRedisChannel(RedisChannel.RedisChannelOptions options) + => new(reader.ReadByteArray(), options); + + internal bool TryGetFirst(out string first) + { + if (reader.IsNonNullAggregate && !reader.AggregateIsEmpty()) + { + var clone = reader.Clone(); + if (clone.TryMoveNext()) + { + unsafe + { + if (clone.IsScalar && + clone.TryParseScalar(&PhysicalConnection.PushKindMetadata.TryParse, out PhysicalConnection.PushKind kind)) + { + first = kind.ToString(); + return true; + } + } + + first = clone.GetOverview(); + return true; + } + } + first = ""; + return false; + } + + internal string GetOverview() + { + // return reader.BufferUtf8(); // <== for when you really can't grok what is happening + if (reader.Prefix is RespPrefix.None) + { + var copy = reader; + copy.MovePastBof(); + return copy.Prefix is RespPrefix.None ? "(empty)" : copy.GetOverview(); + } + if (reader.IsNull) return "(null)"; + + return reader.Prefix switch + { + RespPrefix.SimpleString or RespPrefix.Integer or RespPrefix.SimpleError or RespPrefix.Double => $"{reader.Prefix}: {reader.ReadString()}", + RespPrefix.Push when reader.TryGetFirst(out var first) => $"{reader.Prefix} ({first}): {reader.AggregateLength()} items", + _ when reader.IsScalar => $"{reader.Prefix}: {reader.ScalarLength()} bytes, '{reader.DebugReadTruncatedString(16)}'", + _ when reader.IsAggregate => $"{reader.Prefix}: {reader.AggregateLength()} items", + _ => $"(unknown: {reader.Prefix})", + }; + } + + internal RespPrefix GetFirstPrefix() + { + var prefix = reader.Prefix; + if (prefix is RespPrefix.None) + { + var mutable = reader; + mutable.MovePastBof(); + prefix = mutable.Prefix; + } + return prefix; + } + + /* + public bool AggregateHasAtLeast(int count) + { + reader.DemandAggregate(); + if (reader.IsNull) return false; + if (reader.IsStreaming) return CheckStreamingAggregateAtLeast(in reader, count); + return reader.AggregateLength() >= count; + + static bool CheckStreamingAggregateAtLeast(in RespReader reader, int count) + { + var iter = reader.AggregateChildren(); + object? attributes = null; + while (count > 0 && iter.MoveNextRaw(null!, ref attributes)) + { + count--; + } + + return count == 0; + } + } + */ + } + + extension(ref RespReader reader) + { + internal bool SafeTryMoveNext() => reader.TryMoveNext(checkError: false) & !reader.IsError; + + internal void MovePastBof() + { + // if we're at BOF, read the first element, ignoring errors + if (reader.Prefix is RespPrefix.None) reader.SafeTryMoveNext(); + } + + internal RedisValue[]? ReadPastRedisValues() + => reader.ReadPastArray(static (ref r) => r.ReadRedisValue(), scalar: true); + } + + internal static RespPrefix GetRespPrefix(ReadOnlySpan frame) + { + var reader = new RespReader(frame); + reader.SafeTryMoveNext(); + return reader.Prefix; + } + + extension(RespPrefix prefix) + { + internal ResultType ToResultType() => prefix switch + { + RespPrefix.Array => ResultType.Array, + RespPrefix.Attribute => ResultType.Attribute, + RespPrefix.BigInteger => ResultType.BigInteger, + RespPrefix.Boolean => ResultType.Boolean, + RespPrefix.BulkError => ResultType.BlobError, + RespPrefix.BulkString => ResultType.BulkString, + RespPrefix.SimpleString => ResultType.SimpleString, + RespPrefix.Map => ResultType.Map, + RespPrefix.Set => ResultType.Set, + RespPrefix.Double => ResultType.Double, + RespPrefix.Integer => ResultType.Integer, + RespPrefix.SimpleError => ResultType.Error, + RespPrefix.Null => ResultType.Null, + RespPrefix.VerbatimString => ResultType.VerbatimString, + RespPrefix.Push => ResultType.Push, + _ => throw new ArgumentOutOfRangeException(nameof(prefix), prefix, null), + }; + } + + extension(T?[] array) where T : class + { + internal bool AnyNull() + { + foreach (var el in array) + { + if (el is null) return true; + } + + return false; + } + } +} diff --git a/src/StackExchange.Redis/RespResult.cs b/src/StackExchange.Redis/RespResult.cs new file mode 100644 index 000000000..bf2a10690 --- /dev/null +++ b/src/StackExchange.Redis/RespResult.cs @@ -0,0 +1,139 @@ +using System; +using System.Buffers; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Threading; +using RESPite; +using RESPite.Messages; + +namespace StackExchange.Redis; + +/// +/// Represents a single RESP reply as a leased, undecoded byte sequence, together with its top-level +/// . This is a low-allocation alternative to for callers +/// who want direct access to a reply - including arbitrary trees, not just a single scalar payload - +/// without the cost of materializing the entire reply into / +/// instances up-front. Dispose to return the underlying buffer to the pool. +/// +/// +/// A RESP null is never represented as a null reference; check +/// instead. This preserves which of the three null encodings (a RESP2 null bulk +/// string, a RESP2 null array, or the unified RESP3 null) was actually on the wire, and leaves room for +/// future attribute metadata on a null reply. +/// +public sealed class RespResult : IDisposable +{ + private static readonly RespResult NullBulkStringReply = CreateNullSingleton(RespPrefix.BulkString, "$-1\r\n"u8); + private static readonly RespResult NullArrayReply = CreateNullSingleton(RespPrefix.Array, "*-1\r\n"u8); + + /// + /// The shared singleton representing a unified RESP3 null (_\r\n); also used as the default + /// result for a fire-and-forget request, where no reply is ever observed. + /// + internal static readonly RespResult NullReply = CreateNullSingleton(RespPrefix.Null, "_\r\n"u8); + + private static RespResult CreateNullSingleton(RespPrefix prefix, ReadOnlySpan raw) => + new(prefix, isNull: true, raw.ToArray(), raw.Length, noReturn: true); + + // the high bit of _length flags a buffer that must never be returned to a pool (the shared null + // singletons above, sitting on a fixed byte[]); Length masks it back off. Real captures never set + // it themselves - the length always comes from a checked cast of a non-negative byte count. + private const int NoReturnFlag = 1 << 31; + + // either a byte[] (from ArrayPool.Shared, or one of the fixed null singletons) or an + // IMemoryOwner (from a custom pool); sitting directly on this - rather than wrapping a + // Lease - avoids an extra allocation. + private object? _buffer; + private readonly int _length; + + private RespResult(RespPrefix prefix, bool isNull, object buffer, int length, bool noReturn = false) + { + // length must not already occupy the high bit reserved for NoReturnFlag, or Length/NoReturn below + // would misread the buffer's real size and disposal-eligibility, respectively. + Debug.Assert(length >= 0, "length must be non-negative"); + Prefix = prefix; + IsNull = isNull; + _buffer = buffer; + _length = noReturn ? length | NoReturnFlag : length; + } + + internal static RespResult Capture(RespPrefix prefix, bool isNull, ref RespReader reader, int length, MemoryPool? pool) + { + if (isNull) + { + return prefix switch + { + RespPrefix.BulkString => NullBulkStringReply, + RespPrefix.Array => NullArrayReply, + _ => NullReply, + }; + } + + object buffer = pool is null ? ArrayPool.Shared.Rent(length) : pool.Rent(length); + var result = new RespResult(prefix, isNull: false, buffer, length); + var copied = reader.CopyRawTo(result.RawSpan); + Debug.Assert(copied == length, "raw frame capture length mismatch"); + return result; + } + + /// + /// The RESP prefix of the top-level element of this reply. + /// + public RespPrefix Prefix { get; } + + /// + /// Whether this reply is a RESP null (of any of the three encodings). + /// + public bool IsNull { get; } + + private int BufferLength => _length & ~NoReturnFlag; + + private bool NoReturn => (_length & NoReturnFlag) != 0; + + private Span RawSpan + { + get + { + var buffer = _buffer; + if (buffer is byte[] arr) return new Span(arr, 0, BufferLength); + if (buffer is IMemoryOwner owner) return owner.Memory.Span.Slice(0, BufferLength); + return ThrowDisposed(); + } + } + + [DoesNotReturn] + private static Span ThrowDisposed() => throw new ObjectDisposedException(nameof(RespResult)); + + /// + /// Obtains a reader over the contents of this reply, positioned at the top-level element; this + /// supports full read access, including nested trees. + /// + public RespReader Read() + { + var reader = new RespReader(RawSpan); + reader.MoveNext(); + return reader; + } + + /// + /// Obtains a reader over the contents of a reply that is required to be scalar (i.e. a single value), + /// positioned ready to read the value. + /// + public RespReader ReadScalar() + { + var reader = new RespReader(RawSpan); + reader.MoveNextScalar(); + return reader; + } + + /// + /// Release all resources owned by this instance. + /// + public void Dispose() + { + if (NoReturn) return; // one of the shared null singletons; never disposed + var buffer = Interlocked.Exchange(ref _buffer, null); + if (buffer is byte[] arr) ArrayPool.Shared.Return(arr); + else if (buffer is IMemoryOwner owner) owner.Dispose(); + } +} diff --git a/src/StackExchange.Redis/ResultProcessor.Lease.cs b/src/StackExchange.Redis/ResultProcessor.Lease.cs index 12edbb971..919c8f42f 100644 --- a/src/StackExchange.Redis/ResultProcessor.Lease.cs +++ b/src/StackExchange.Redis/ResultProcessor.Lease.cs @@ -19,9 +19,6 @@ public static readonly ResultProcessor> public static readonly ResultProcessor> LeaseFromArray = new LeaseFromArrayProcessor(); - public static readonly ResultProcessor> - LeaseScript = new LeaseProcessor(); - private abstract class LeaseProcessor : ResultProcessor?> { protected override bool SetResultCore(PhysicalConnection connection, Message message, ref RespReader reader) diff --git a/src/StackExchange.Redis/ResultProcessor.RespResult.cs b/src/StackExchange.Redis/ResultProcessor.RespResult.cs new file mode 100644 index 000000000..9a5845dae --- /dev/null +++ b/src/StackExchange.Redis/ResultProcessor.RespResult.cs @@ -0,0 +1,37 @@ +using System; +using RESPite.Messages; + +// ReSharper disable once CheckNamespace +namespace StackExchange.Redis; + +internal abstract partial class ResultProcessor +{ + public static readonly ResultProcessor RespResult = new RespResultProcessor(); + + private sealed class RespResultProcessor : ResultProcessor + { + public override bool SetResult(PhysicalConnection connection, Message message, ref RespReader reader) + { + // capture the raw, undecoded frame - header bytes included - before anything advances the + // reader; this only works because we're called before the base implementation's MovePastBof(), + // which would otherwise consume the leading prefix/length bytes we need to capture too + var totalBytes = checked((int)reader.ProtocolBytesRemaining); + + // peek at an independent copy to learn the prefix/error/null status, leaving the raw capture untouched + var probe = reader; + probe.MovePastBof(); + + if (probe.IsError) + { + return base.SetResult(connection, message, ref reader); + } + + var pool = connection.BridgeCouldBeNull?.Multiplexer?.RawConfig?.ResponseBufferPool; + SetResult(message, StackExchange.Redis.RespResult.Capture(probe.Prefix, probe.IsNull, ref reader, totalBytes, pool)); + return true; + } + + protected override bool SetResultCore(PhysicalConnection connection, Message message, ref RespReader reader) => + throw new NotSupportedException(); // SetResult is fully overridden above; this is never invoked + } +} diff --git a/tests/StackExchange.Redis.Tests/KeyPrefixedDatabaseTests.cs b/tests/StackExchange.Redis.Tests/KeyPrefixedDatabaseTests.cs index fa7e1fba2..108bd5ef7 100644 --- a/tests/StackExchange.Redis.Tests/KeyPrefixedDatabaseTests.cs +++ b/tests/StackExchange.Redis.Tests/KeyPrefixedDatabaseTests.cs @@ -27,6 +27,15 @@ private static T[] IsRaw(T[] expected) return Arg.Is(lambda); } + internal static ReadOnlyMemory IsValueMemory(params RedisValue[] expected) => IsRawMemory(expected); + private static ReadOnlyMemory IsRawMemory(T[] expected) + { + // NB: can't touch .Span inside the expression tree - Span/ReadOnlySpan are ref structs, + // which can't appear in an expression tree; ToArray() sidesteps that. + Expression>> lambda = actual => actual.Length == expected.Length && expected.SequenceEqual(actual.ToArray()); + return Arg.Is(lambda); + } + public KeyPrefixedDatabaseTests() { mock = Substitute.For(); @@ -605,6 +614,49 @@ public void ScriptEvaluate_2() mock.Received().ScriptEvaluate(script: "script", keys: IsKeys(["prefix:a", "prefix:b"]), values: values, flags: CommandFlags.None); } + [Fact] + public void ScriptEvaluateResp_1() + { + // NB: ScriptEvaluateResp rents its inner RedisKey[] from the ArrayPool and returns it + // (clearArray: true) as soon as the (mocked) inner call completes, so by the time + // mock.Received() re-evaluates its argument matcher, the array backing the recorded + // ReadOnlyMemory has already been zeroed. Capture a copy at call time instead. + RedisKey[]? captured = null; + mock.ScriptEvaluateResp( + Arg.Any(), + Arg.Do>(m => captured = m.ToArray()), + Arg.Any>(), + Arg.Any()); + + RedisValue[] values = ["v1", "v2"]; + RedisKey[] keys = ["a", "b"]; + prefixed.ScriptEvaluateResp("script", keys, values, CommandFlags.None); + + mock.Received().ScriptEvaluateResp("script", Arg.Any>(), IsValueMemory("v1", "v2"), CommandFlags.None); + Assert.NotNull(captured); + Assert.Equal(["prefix:a", "prefix:b"], captured); + } + + [Fact] + public void ScriptEvaluateReadOnlyResp_1() + { + // see ScriptEvaluateResp_1 for why keys must be captured at call time, not verified after the fact. + RedisKey[]? captured = null; + mock.ScriptEvaluateReadOnlyResp( + Arg.Any(), + Arg.Do>(m => captured = m.ToArray()), + Arg.Any>(), + Arg.Any()); + + RedisValue[] values = ["v1", "v2"]; + RedisKey[] keys = ["a", "b"]; + prefixed.ScriptEvaluateReadOnlyResp("script", keys, values, CommandFlags.None); + + mock.Received().ScriptEvaluateReadOnlyResp("script", Arg.Any>(), IsValueMemory("v1", "v2"), CommandFlags.None); + Assert.NotNull(captured); + Assert.Equal(["prefix:a", "prefix:b"], captured); + } + [Fact] public void SetAdd_1() { @@ -1483,6 +1535,29 @@ public void Execute_2() mock.Received().Execute("CUSTOM", Arg.Is>(a => a.Count == 2 && a.ElementAt(0).Equals("arg1") && a.ElementAt(1).Equals((RedisKey)"prefix:arg2"))!, CommandFlags.None); } + [Fact] + public void ExecuteResp_1() + { + // NB: ExecuteResp rents its inner RedisKeyOrValue[] from the ArrayPool and returns it + // (clearArray: true) as soon as the (mocked) inner call completes, so by the time + // mock.Received() re-evaluates its argument matcher, the array backing the recorded + // ReadOnlyMemory has already been zeroed. Capture a copy at call time instead. + RedisKeyOrValue[]? captured = null; + mock.ExecuteResp( + Arg.Any(), + Arg.Do>(m => captured = m.ToArray()), + Arg.Any()); + + RedisKeyOrValue[] args = [(RedisValue)"value1", (RedisKey)"key1", (RedisKey)"key2"]; + prefixed.ExecuteResp("CUSTOM", args, CommandFlags.None); + + mock.Received().ExecuteResp("CUSTOM", Arg.Any>(), CommandFlags.None); + Assert.NotNull(captured); + Assert.Equal( + [(RedisValue)"value1", (RedisKey)"prefix:key1", (RedisKey)"prefix:key2"], + captured); + } + [Fact] public void GeoAdd_1() { diff --git a/tests/StackExchange.Redis.Tests/NamingTests.cs b/tests/StackExchange.Redis.Tests/NamingTests.cs index 5a6e71d54..52e42bc34 100644 --- a/tests/StackExchange.Redis.Tests/NamingTests.cs +++ b/tests/StackExchange.Redis.Tests/NamingTests.cs @@ -79,6 +79,8 @@ public void CheckDatabaseMethodsUseKeys(Type type) case nameof(IDatabaseAsync.PublishAsync): case nameof(IDatabase.Execute): case nameof(IDatabaseAsync.ExecuteAsync): + case nameof(IDatabase.ExecuteResp): + case nameof(IDatabaseAsync.ExecuteRespAsync): case nameof(IDatabase.ScriptEvaluate): case nameof(IDatabaseAsync.ScriptEvaluateAsync): case nameof(IDatabase.StreamRead): diff --git a/tests/StackExchange.Redis.Tests/RespResultTests.cs b/tests/StackExchange.Redis.Tests/RespResultTests.cs new file mode 100644 index 000000000..6d1795c8d --- /dev/null +++ b/tests/StackExchange.Redis.Tests/RespResultTests.cs @@ -0,0 +1,260 @@ +using System; +using System.Text; +using System.Threading.Tasks; +using StackExchange.Redis.KeyspaceIsolation; +using Xunit; + +namespace StackExchange.Redis.Tests; + +[RunPerProtocol] +public class RespResultTests(ITestOutputHelper output, SharedConnectionFixture fixture) : TestBase(output, fixture) +{ + [Fact] + public async Task ExecuteResp_ScalarBlob() + { + await using var conn = Create(); + var db = conn.GetDatabase(); + RedisKey key = Me(); + db.StringSet(key, "hello world"); + + using var result = db.ExecuteResp("GET", new RedisKeyOrValue[] { key }); + Assert.False(result.IsNull); + Assert.Equal("hello world", (string?)result.ReadScalar().ReadRedisValue()); + } + + [Fact] + public async Task ExecuteRespAsync_ScalarBlob() + { + await using var conn = Create(); + var db = conn.GetDatabase(); + RedisKey key = Me(); + await db.StringSetAsync(key, "hello world"); + + using var result = await db.ExecuteRespAsync("GET", new RedisKeyOrValue[] { key }); + Assert.False(result.IsNull); + Assert.Equal("hello world", (string?)result.ReadScalar().ReadRedisValue()); + } + + [Fact] + public async Task ExecuteResp_MissingKey_IsNull() + { + await using var conn = Create(); + var db = conn.GetDatabase(); + RedisKey key = Me(); // never set + + using var result = db.ExecuteResp("GET", new RedisKeyOrValue[] { key }); + Assert.True(result.IsNull); + } + + [Fact] + public async Task ScriptEvaluateResp_ScalarBlob_ReadLease() + { + await using var conn = Create(); + var db = conn.GetDatabase(); + + using var result = db.ScriptEvaluateResp("return 'hello world'", default, default); + Assert.False(result.IsNull); + + using var lease = result.ReadScalar().ReadLease(); + Assert.Equal("hello world", Encoding.UTF8.GetString(lease!.Span)); + } + + [Fact] + public async Task ScriptEvaluateResp_ScalarBlob_CopyToCallerBuffer() + { + await using var conn = Create(); + var db = conn.GetDatabase(); + + using var result = db.ScriptEvaluateResp("return 'hello world'", default, default); + var reader = result.ReadScalar(); + byte[] buffer = new byte[reader.ScalarLength()]; + var copied = reader.CopyTo(buffer); + Assert.Equal("hello world", Encoding.UTF8.GetString(buffer, 0, copied)); + } + + [Fact] + public async Task ScriptEvaluateRespAsync_Integer() + { + await using var conn = Create(); + var db = conn.GetDatabase(); + + using var result = await db.ScriptEvaluateRespAsync("return 42", default, default); + Assert.Equal(42, (long)result.ReadScalar().ReadRedisValue()); + } + + [Fact] + public async Task ScriptEvaluateResp_KeysAndValues() + { + await using var conn = Create(); + var db = conn.GetDatabase(); + RedisKey key = Me(); + + using var result = db.ScriptEvaluateResp( + "redis.call('set', KEYS[1], ARGV[1]); return redis.call('get', KEYS[1])", + new RedisKey[] { key }, + new RedisValue[] { "hello keys/values" }); + + Assert.Equal("hello keys/values", (string?)result.ReadScalar().ReadRedisValue()); + } + + [Fact] + public async Task ScriptEvaluateResp_Null_IsSharedSingleton() + { + await using var conn = Create(); + var db = conn.GetDatabase(); + + using var r1 = db.ScriptEvaluateResp("return nil", default, default); + var r2 = db.ScriptEvaluateResp("return nil", default, default); + + Assert.True(r1.IsNull); + Assert.Same(r1, r2); // shared singleton - disposing r1 must not affect r2's usability + } + + [Fact] + public async Task ScriptEvaluateResp_Tree_ReadAndReadRedisResultAgree() + { + await using var conn = Create(); + var db = conn.GetDatabase(); + + using var result = db.ScriptEvaluateResp("return {1,2,'three'}", default, default); + var reader = result.Read(); + Assert.True(reader.IsAggregate); + Assert.Equal(3, reader.AggregateLength()); + + var redisResult = result.Read().ReadRedisResult(); + var values = (RedisValue[]?)redisResult; + Assert.NotNull(values); + Assert.Equal(3, values!.Length); + Assert.Equal(1, (long)values[0]); + Assert.Equal(2, (long)values[1]); + Assert.Equal("three", (string?)values[2]); + } + + [Fact] + public async Task ScriptEvaluateResp_ScalarAccessorOnTree_ThrowsConsistently() + { + await using var conn = Create(); + var db = conn.GetDatabase(); + + using var result = db.ScriptEvaluateResp("return {1,2,3}", default, default); + Assert.Throws(() => result.ReadScalar()); + } + + [Fact] + public async Task ScriptEvaluateRespAsync_ScriptError_ThrowsRedisServerException() + { + await using var conn = Create(); + var db = conn.GetDatabase(); + + await Assert.ThrowsAsync( + async () => await db.ScriptEvaluateRespAsync("this is not valid lua {{{", default, default)); + } + + [Fact] + public async Task ScriptEvaluateReadOnlyResp_Works() + { + await using var conn = Create(); + var db = conn.GetDatabase(); + RedisKey key = Me(); + db.StringSet(key, "read-only value"); + + using var result = db.ScriptEvaluateReadOnlyResp( + "return redis.call('get', KEYS[1])", + new RedisKey[] { key }, + default); + + Assert.Equal("read-only value", (string?)result.ReadScalar().ReadRedisValue()); + } + + [Fact] + public async Task ExecuteResp_PING_SimpleString() + { + await using var conn = Create(); + var db = conn.GetDatabase(); + + using var result = db.ExecuteResp("PING", default); + Assert.Equal("PONG", (string?)result.ReadScalar().ReadRedisValue()); + } + + [Fact] + public async Task ExecuteResp_ArrayReply_MGET() + { + await using var conn = Create(); + var db = conn.GetDatabase(); + RedisKey key1 = Me() + ":1"; + RedisKey key2 = Me() + ":2"; // deliberately left unset - reads back as a null array element + await db.StringSetAsync(key1, "hello"); + await db.KeyDeleteAsync(key2); + + using var result = db.ExecuteResp("MGET", new RedisKeyOrValue[] { key1, key2 }); + var reader = result.Read(); + Assert.True(reader.IsAggregate); + Assert.Equal(2, reader.AggregateLength()); + + var children = reader.AggregateChildren(); + Assert.True(children.MoveNext()); + Assert.Equal("hello", (string?)children.Value.ReadRedisValue()); + Assert.True(children.MoveNext()); + Assert.True(children.Value.IsNull); + Assert.False(children.MoveNext()); + } + + [Fact] + public async Task ExecuteResp_KeyPrefixed_ShortKey_IsPrefixedCorrectly() + { + // regression coverage: RedisKeyOrValue must round-trip a short (<=8 byte) key value through + // KeyPrefixed without it getting folded into RedisValue's inline ShortBlob/Simplify form. + await using var conn = Create(); + var raw = conn.GetDatabase(); + string prefixText = Me() + ":"; + IDatabase db = new KeyPrefixedDatabase(raw, Encoding.UTF8.GetBytes(prefixText)); + + RedisKey shortKey = "abc"; // <= 8 bytes - would be eligible for ShortBlob packing as a RedisValue + RedisKey directKey = prefixText + "abc"; + await raw.KeyDeleteAsync(directKey); + + using var setResult = db.ExecuteResp("SET", new RedisKeyOrValue[] { shortKey, (RedisValue)"short-key-value" }); + Assert.Equal("OK", (string?)setResult.ReadScalar().ReadRedisValue()); + + // confirm it actually landed at the *prefixed* key when read directly (unprefixed) connection + var direct = await raw.StringGetAsync(directKey); + Assert.Equal("short-key-value", (string?)direct); + + // and that reading it back through the prefixed wrapper also agrees + using var getResult = db.ExecuteResp("GET", new RedisKeyOrValue[] { shortKey }); + Assert.Equal("short-key-value", (string?)getResult.ReadScalar().ReadRedisValue()); + } + + [Fact] + public void RedisKeyOrValue_KeyAndValue_AreNeverEqualEvenWithSameText() + { + RedisKeyOrValue key = (RedisKey)"abc"; + RedisKeyOrValue value = (RedisValue)"abc"; + + Assert.True(key.IsKey); + Assert.False(key.IsValue); + Assert.True(value.IsValue); + Assert.False(value.IsKey); + Assert.False(key.Equals(value)); + Assert.False(value.Equals(key)); + } + + [Fact] + public void RedisKeyOrValue_Default_IsNullOnly() + { + RedisKeyOrValue none = default; + Assert.True(none.IsNull); + Assert.False(none.IsKey); + Assert.False(none.IsValue); + } + + [Fact] + public void RedisKeyOrValue_InvalidCast_Throws() + { + RedisKeyOrValue key = (RedisKey)"abc"; + Assert.Throws(() => (RedisValue)key); + + RedisKeyOrValue value = (RedisValue)"abc"; + Assert.Throws(() => (RedisKey)value); + } +} diff --git a/tests/StackExchange.Redis.Tests/ResultProcessorUnitTests/RespResultProcessor.cs b/tests/StackExchange.Redis.Tests/ResultProcessorUnitTests/RespResultProcessor.cs new file mode 100644 index 000000000..757012dbd --- /dev/null +++ b/tests/StackExchange.Redis.Tests/ResultProcessorUnitTests/RespResultProcessor.cs @@ -0,0 +1,182 @@ +using System.Text; +using RESPite.Messages; +using Xunit; + +namespace StackExchange.Redis.Tests.ResultProcessorUnitTests; + +/// +/// Tests for the result processor - the raw, low-allocation +/// counterpart to used by ExecLease/ScriptEvalLease and friends. +/// +public class RespResultProcessor(ITestOutputHelper log) : ResultProcessorUnitTest(log) +{ + [Theory] + [InlineData("$5\r\nhello\r\n", RespPrefix.BulkString, "hello")] + [InlineData("+world\r\n", RespPrefix.SimpleString, "world")] + [InlineData(":42\r\n", RespPrefix.Integer, "42")] + public void ScalarReply_CapturesRawFrameAndDecodes(string resp, RespPrefix expectedPrefix, string expectedText) + { + var processor = ResultProcessor.RespResult; + using var result = Execute(resp, processor); + + Assert.NotNull(result); + Assert.Equal(expectedPrefix, result.Prefix); + Assert.False(result.IsNull); + + // decode via ReadRedisValue() + Assert.Equal(expectedText, (string?)result.ReadScalar().ReadRedisValue()); + + // decode via ReadLease() + using var lease = result.ReadScalar().ReadLease(); + Assert.Equal(expectedText, Encoding.UTF8.GetString(lease!.Span)); + + // decode via a caller-supplied buffer (RespReader.CopyTo) + var reader = result.ReadScalar(); + byte[] buffer = new byte[reader.ScalarLength()]; + var copied = reader.CopyTo(buffer); + Assert.Equal(expectedText, Encoding.UTF8.GetString(buffer, 0, copied)); + } + + [Theory] + [InlineData("$-1\r\n", RespPrefix.BulkString)] // RESP2 null bulk string + [InlineData("*-1\r\n", RespPrefix.Array)] // RESP2 null array + [InlineData("_\r\n", RespPrefix.Null)] // RESP3 unified null + public void NullReply_UsesSharedSingleton(string resp, RespPrefix expectedPrefix) + { + var processor = ResultProcessor.RespResult; + var first = Execute(resp, processor); + var second = Execute(resp, processor); + + Assert.NotNull(first); + Assert.True(first!.IsNull); + Assert.Equal(expectedPrefix, first.Prefix); + + // the three null shapes are shared, never-disposed singletons - never a fresh allocation + Assert.Same(first, second); + + // disposing a singleton must be a complete no-op, even repeatedly + first.Dispose(); + first.Dispose(); + Assert.False(first.IsNull is false); // still usable afterwards + Assert.Equal(expectedPrefix, first.Prefix); + } + + [Fact] + public void AggregateReply_SupportsTreeAccessButRejectsScalarAccessors() + { + var processor = ResultProcessor.RespResult; + using var result = Execute("*4\r\n:1\r\n:2\r\n$5\r\nthree\r\n*2\r\n:4\r\n:5\r\n", processor); + + Assert.NotNull(result); + Assert.Equal(RespPrefix.Array, result.Prefix); + Assert.False(result.IsNull); + + var reader = result.Read(); + Assert.True(reader.IsAggregate); + Assert.Equal(4, reader.AggregateLength()); + + // scalar-only accessors must all fail the same, consistent way against a tree + Assert.Throws(() => result.ReadScalar()); + } + + [Fact] + public void AggregateReply_ReadRedisResultMaterializesFullTree() + { + var processor = ResultProcessor.RespResult; + using var result = Execute("*3\r\n:1\r\n:2\r\n$5\r\nthree\r\n", processor); + + Assert.NotNull(result); + var reader = result.Read(); + var redisResult = reader.ReadRedisResult(); + var values = (RedisValue[]?)redisResult; + + Assert.NotNull(values); + Assert.Equal(3, values!.Length); + Assert.Equal(1, (long)values[0]); + Assert.Equal(2, (long)values[1]); + Assert.Equal("three", (string?)values[2]); + } + + [Fact] + public void AggregateReply_AggregateChildren_WalksEachElement() + { + var processor = ResultProcessor.RespResult; + using var result = Execute("*3\r\n:1\r\n:2\r\n$5\r\nthree\r\n", processor); + + var children = result!.Read().AggregateChildren(); + Assert.True(children.MoveNext()); + Assert.Equal(1, (long)children.Value.ReadRedisValue()); + Assert.True(children.MoveNext()); + Assert.Equal(2, (long)children.Value.ReadRedisValue()); + Assert.True(children.MoveNext()); + Assert.Equal("three", (string?)children.Value.ReadRedisValue()); + Assert.False(children.MoveNext()); + } + + [Fact] + public void AggregateReply_AggregateChildren_DescendsIntoNestedSubArray() + { + var processor = ResultProcessor.RespResult; + using var result = Execute("*4\r\n:1\r\n:2\r\n$5\r\nthree\r\n*2\r\n:4\r\n:5\r\n", processor); + + var children = result!.Read().AggregateChildren(); + Assert.True(children.MoveNext()); + Assert.Equal(1, (long)children.Value.ReadRedisValue()); + Assert.True(children.MoveNext()); + Assert.Equal(2, (long)children.Value.ReadRedisValue()); + Assert.True(children.MoveNext()); + Assert.Equal("three", (string?)children.Value.ReadRedisValue()); + + Assert.True(children.MoveNext()); + Assert.True(children.Value.IsAggregate); + var nested = children.Value.AggregateChildren(); + Assert.True(nested.MoveNext()); + Assert.Equal(4, (long)nested.Value.ReadRedisValue()); + Assert.True(nested.MoveNext()); + Assert.Equal(5, (long)nested.Value.ReadRedisValue()); + Assert.False(nested.MoveNext()); + + Assert.False(children.MoveNext()); + } + + [Fact] + public void AggregateReply_ReadPastArray_ProjectsTypedArray() + { + var processor = ResultProcessor.RespResult; + using var result = Execute("*3\r\n:1\r\n:2\r\n$5\r\nthree\r\n", processor); + + var reader = result!.Read(); + RedisValue[]? values = reader.ReadPastArray(static (ref r) => r.ReadRedisValue(), scalar: true); + + Assert.NotNull(values); + Assert.Equal(3, values!.Length); + Assert.Equal(1, (long)values[0]); + Assert.Equal(2, (long)values[1]); + Assert.Equal("three", (string?)values[2]); + } + + [Fact] + public void NullArrayReply_ReadPastArray_ReturnsNull() + { + var processor = ResultProcessor.RespResult; + using var result = Execute("*-1\r\n", processor); + + var reader = result!.Read(); + RedisValue[]? values = reader.ReadPastArray(static (ref r) => r.ReadRedisValue(), scalar: true); + + Assert.Null(values); + } + + [Fact] + public void ErrorReply_PropagatesAsRedisServerException() + { + var resp = "-ERR something bad happened\r\n"; + var processor = ResultProcessor.RespResult; + + var success = TryExecute(resp, processor, out _, out var exception); + + Assert.False(success); + Assert.NotNull(exception); + Assert.IsType(exception); + } +} From 8cc8a7c053e6b79177dec92bbc61143feb8c49e6 Mon Sep 17 00:00:00 2001 From: mgravell Date: Fri, 4 Sep 2026 09:25:52 +0100 Subject: [PATCH 13/17] Share the reply buffer with ReadLease instead of copying out of it RespResult already copies the raw frame out of the connection's read buffer; ReadLease then copied the payload out of *that*, so pulling a blob out of ScriptEvaluateResp/ExecuteResp rented and filled two buffers for one value. Make the reply buffer reference-counted and hand out the second one by reference. RefCountedBuffer is a MemoryManager rather than a plain IMemoryOwner for two reasons: every Memory/Span access routes back through GetSpan, so use after the buffer has gone back to the pool throws rather than quietly reading somebody else's data; and MemoryManager implements IDisposable explicitly, so the single reachable Dispose can only mean "release one reference" - there is no second disposal concept to confuse it with, and no guard flag needed. The reader finds the buffer through one new field: a single service slot that either is the service or is an IServiceProvider, so services the reader does not know about in advance can still be reached without a field each. Lease is otherwise untouched - it gains an offset, and its existing Dispose already does the right thing, because ((IMemoryOwner)buffer).Dispose() lands on the manager's explicit Dispose, i.e. a release. Overriding MemoryManager.TryGetArray keeps ArraySegment - and so DecodeString and AsStream - working on a shared lease. The same slot also carries the buffer pool, which lets ReadLease drop its pool argument entirely: on the sharing path any such argument was going to be silently ignored, since the lease takes whatever buffer the reply already sits in. Connection-path readers get a services object allocated once per multiplexer and cached per connection; it reads through to the configuration rather than capturing the pool, so this stays a pure indirection. AsLease and its walk from connection to config to pool go away with it. That does turn an explicit argument into an implicit lookup, so the pool is now covered by a test that asserts a configured ResponseBufferPool really is rented from - there was no such test before, and losing the wiring would otherwise degrade silently to ArrayPool.Shared. Sharing is contextual, and the docs say so: it happens when the reader's source can offer a counted reservation, which today means RespResult. The Lease-returning commands, and any reader built over a caller's own bytes, still copy. Either way the lease is owned by the caller and disposed the same way, so calling code does not have to know which it got - except that in the sharing case the reply stays rented until the lease goes, so a short value taken from a large reply keeps the whole reply alive. Everything new here is internal; the only public API change is the removal of ReadLease's pool argument, which is unshipped. --- docs/Scripting.md | 17 +- src/RESPite/Buffers/RefCountedBuffer.cs | 239 +++++++++++++++++ src/RESPite/Messages/RespReader.cs | 60 ++++- src/StackExchange.Redis/Lease.cs | 32 ++- .../PhysicalConnection.Read.cs | 10 +- src/StackExchange.Redis/PhysicalConnection.cs | 7 + .../PublicAPI/PublicAPI.Unshipped.txt | 2 +- src/StackExchange.Redis/ReaderServices.cs | 30 +++ .../RespReaderExtensions.cs | 36 ++- .../RespReaderInternalExtensions.cs | 5 +- src/StackExchange.Redis/RespResult.cs | 69 +++-- .../ResultProcessor.Lease.cs | 4 +- .../RespResultLeaseSharingTests.cs | 245 ++++++++++++++++++ 13 files changed, 692 insertions(+), 64 deletions(-) create mode 100644 src/RESPite/Buffers/RefCountedBuffer.cs create mode 100644 src/StackExchange.Redis/ReaderServices.cs create mode 100644 tests/StackExchange.Redis.Tests/RespResultLeaseSharingTests.cs diff --git a/docs/Scripting.md b/docs/Scripting.md index eb4a4340b..f744b2eaa 100644 --- a/docs/Scripting.md +++ b/docs/Scripting.md @@ -37,7 +37,8 @@ if (!result.IsNull) byte[] buffer = new byte[reader.ScalarLength()]; int written = reader.CopyTo(buffer); - // or, if you want an owned, poolable copy to hold on to for a while: + // or, as an owned, poolable handle on the value - note this usually shares the reply's buffer + // rather than copying, so dispose it promptly (see below): using Lease? lease = reader.ReadLease(); // or, if you just want the usual RedisValue/string: @@ -45,6 +46,20 @@ if (!result.IsNull) } ``` +Whether `ReadLease()` shares the underlying buffer or takes a copy depends on where the reader came from, +not on how you call it. Reading from a `RespResult` - as above - the reply's buffer is reference-counted, +so the lease points straight into it and no copy is made. Elsewhere, including the `Lease`-returning +commands like `HashGetLease` and any `RespReader` you build over your own bytes, you get an independent +copy. Either way the lease is yours and must be disposed, so calling code does not need to know which it +got. + +The distinction does matter for how long you hold it. In the sharing case the reply stays rented until +both the `RespResult` and every lease taken from it have been disposed, so a short value taken from a +large reply keeps the whole reply alive. That is the right trade for the case this exists for - pulling +back a large blob without copying it - but dispose leases promptly, and if you want to keep a small part +of a large reply around for a long time, copy it out (`CopyTo` into your own buffer) rather than holding +the lease. + A `RespResult` is never itself a `null` C# reference - the reply is always a real, non-null `RespResult`, and `IsNull` tells you whether the underlying RESP reply itself was a null (there are three distinct null encodings on the wire; `RespResult` preserves which one you got via `Prefix`, rather than collapsing them). This also leaves room for RESP3 attribute metadata on a null reply in future. If the script can return a tree (an array, or a mix of shapes depending on input), `RespResult.Read()` gives you a `RespReader` positioned at the root. `.ReadRedisResult()` is the convenient option - it falls back to the familiar `RedisResult` materialization for the whole value, at the cost of allocating that same wrapper-object-per-node tree the low-allocation APIs elsewhere in this doc are trying to avoid. If efficiency actually matters for a tree-shaped reply, walk the `RespReader` directly instead: it's a forwards-only iterator over the raw reply, with the same low-level accessors (`ReadRedisValue`, `ReadLease`, `CopyTo`, `ScalarLength`, ...) available at each node, so you can read exactly what you need without materializing the rest of the tree: diff --git a/src/RESPite/Buffers/RefCountedBuffer.cs b/src/RESPite/Buffers/RefCountedBuffer.cs new file mode 100644 index 000000000..0ef3e7b44 --- /dev/null +++ b/src/RESPite/Buffers/RefCountedBuffer.cs @@ -0,0 +1,239 @@ +using System.Buffers; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; + +namespace RESPite.Buffers; + +/// +/// Describes a service that can hand out a counted reservation against a payload it owns, allowing a +/// caller to retain that payload without copying it. See . +/// +internal interface IPayloadReservationProvider +{ + /// + /// If lies inside a buffer owned by this instance, take a counted + /// reservation against it; the caller must dispose exactly once. + /// + bool TryReserve(ReadOnlySpan payload, out PayloadReservation reservation); +} + +/// +/// Describes a service that knows which pool buffers for this reader should come from, so that callers +/// which need to allocate (rather than share) return memory to the same place it was rented from. +/// +internal interface IBufferPoolProvider +{ + /// + /// The pool to rent from; null for . + /// + MemoryPool? BufferPool { get; } +} + +/// +/// A counted claim on a region of a buffer owned by someone else. Disposing releases +/// the claim; the payload lives at / within the owner's memory. +/// +internal readonly struct PayloadReservation(IMemoryOwner owner, int offset, int length) +{ + public IMemoryOwner Owner { get; } = owner; + public int Offset { get; } = offset; + public int Length { get; } = length; +} + +/// +/// A pooled buffer with a reference count, allowing fragments of it to outlive the original owner without +/// being copied. The count starts at one - held by whoever created it - and the buffer returns to its pool +/// when the count reaches zero. +/// +/// +/// This is a rather than a plain for two +/// reasons. Every Memory/Span access routes back through , so access +/// after the buffer has gone back to the pool throws rather than silently reading somebody else's data; +/// and implements explicitly, so the one +/// publicly reachable Dispose unambiguously means "release one reference" - there is no second +/// disposal concept to confuse it with. +/// +internal sealed class RefCountedBuffer : MemoryManager, IPayloadReservationProvider, IBufferPoolProvider +{ + // a byte[] from ArrayPool.Shared, or an IMemoryOwner from a custom pool; null once dead + private object? _buffer; + private readonly int _length; + private readonly bool _noReturn; + private readonly MemoryPool? _pool; + private int _refCount = 1; + + private RefCountedBuffer(object buffer, int length, bool noReturn, MemoryPool? pool) + { + _buffer = buffer; + _length = length; + _noReturn = noReturn; + _pool = pool; + } + + /// + /// The pool this buffer came from, so that a caller who has to copy rather than share still rents + /// from - and returns to - the same place. + /// + public MemoryPool? BufferPool => _pool; + + /// + /// Rent a buffer of at least bytes, with a reference count of one. + /// + public static RefCountedBuffer Rent(int length, MemoryPool? pool) => new( + pool is null ? ArrayPool.Shared.Rent(length) : pool.Rent(length), + length, + noReturn: false, + pool); + + /// + /// Wrap a fixed buffer that must never be returned to a pool, for shared immutable singletons. + /// + public static RefCountedBuffer CreateFixed(byte[] buffer) => new(buffer, buffer.Length, noReturn: true, pool: null); + + /// + /// The number of live references; for assertions and tests. + /// + internal int RefCount => Volatile.Read(ref _refCount); + + /// + /// Whether this buffer is fixed - shared, never counted down, and never returned to a pool. + /// + public bool IsFixed => _noReturn; + + /// + /// Take an additional reference, unless the buffer is already dead. + /// + /// + /// Increment-if-nonzero, not a bare increment: a reservation racing the final release must fail + /// rather than resurrect a buffer that has already gone back to the pool. + /// + public bool TryAddRef() + { + int count; + do + { + count = Volatile.Read(ref _refCount); + if (count == 0) return false; + } + while (Interlocked.CompareExchange(ref _refCount, count + 1, count) != count); + return true; + } + + /// + /// Release one reference, returning the buffer to its pool if that was the last. + /// + public void Release() + { + // a fixed buffer is shared for the lifetime of the process and is never counted down; letting it + // reach zero would strand every future user of the singleton sitting on it + if (_noReturn) return; + if (Interlocked.Decrement(ref _refCount) != 0) return; + + var buffer = Interlocked.Exchange(ref _buffer, null); + if (buffer is byte[] arr) ArrayPool.Shared.Return(arr); + else if (buffer is IMemoryOwner owner) owner.Dispose(); + } + + public bool TryReserve(ReadOnlySpan payload, out PayloadReservation reservation) + { + if (!payload.IsEmpty && TryGetOffset(payload, out var offset) && TryAddRef()) + { + reservation = new PayloadReservation(this, offset, payload.Length); + return true; + } + + reservation = default; + return false; + } + + // is this span a window onto *our* buffer, and if so, where? this is the pattern the BCL itself uses + // in MemoryExtensions.Overlaps; the unsigned cast makes a negative delta wrap to a huge value, so the + // single comparison rejects spans that start before us as well as those that end after us. + private bool TryGetOffset(ReadOnlySpan payload, out int offset) + { + var mine = RawSpan; + if (!mine.IsEmpty) + { + // note: via long/ulong rather than nuint, which is not available on all target frameworks + var delta = (long)Unsafe.ByteOffset( + ref MemoryMarshal.GetReference(mine), + ref MemoryMarshal.GetReference(payload)); + if (unchecked((ulong)delta) + (uint)payload.Length <= (uint)mine.Length) + { + offset = (int)delta; + return true; + } + } + + offset = 0; + return false; + } + + private Span RawSpan + { + get + { + // read into a local: a concurrent release must give a clean throw, not a null-ref + var buffer = _buffer; + if (buffer is byte[] arr) return new Span(arr, 0, _length); + if (buffer is IMemoryOwner owner) return owner.Memory.Span.Slice(0, _length); + return ThrowDisposed(); + } + } + + public override Span GetSpan() => RawSpan; + + // base version is CreateMemory(GetSpan().Length); avoid the round-trip + public override Memory Memory => CreateMemory(_length); + + /// + /// A over part of this buffer; access still routes through this instance. + /// + public Memory Slice(int offset, int length) => CreateMemory(offset, length); + + // keeping this working is what lets Lease.ArraySegment - and so DecodeString/AsStream - carry + // on working for a reservation; MemoryMarshal.TryGetArray composes this with the slice offset + protected override bool TryGetArray(out ArraySegment segment) + { + if (_buffer is byte[] arr) + { + segment = new ArraySegment(arr, 0, _length); + return true; + } + + segment = default; + return false; + } + + public override MemoryHandle Pin(int elementIndex = 0) + { + // per-call GC pin, as BlockBuffer does; note we do not pass ourselves as the IPinnable, so a + // handle cannot outlive its own disposal into an Unpin against a released buffer + if (_buffer is byte[] arr) + { + var handle = GCHandle.Alloc(arr, GCHandleType.Pinned); + unsafe + { + return new MemoryHandle((byte*)handle.AddrOfPinnedObject() + elementIndex, handle); + } + } + + if (_buffer is IMemoryOwner owner) return owner.Memory.Slice(elementIndex).Pin(); + return ThrowDisposedHandle(); + } + + // only reachable if we handed out a MemoryHandle naming ourselves as IPinnable, which we never do + public override void Unpin() => throw new NotSupportedException(); + + // this is the *only* publicly reachable Dispose on this type (MemoryManager implements + // IDisposable explicitly), and it means: release one reference + protected override void Dispose(bool disposing) => Release(); + + [DoesNotReturn] + private static Span ThrowDisposed() => throw new ObjectDisposedException(nameof(RefCountedBuffer)); + + [DoesNotReturn] + private static MemoryHandle ThrowDisposedHandle() => throw new ObjectDisposedException(nameof(RefCountedBuffer)); +} diff --git a/src/RESPite/Messages/RespReader.cs b/src/RESPite/Messages/RespReader.cs index 1d7afd087..80ab55a46 100644 --- a/src/RESPite/Messages/RespReader.cs +++ b/src/RESPite/Messages/RespReader.cs @@ -6,6 +6,7 @@ using System.Globalization; using System.Runtime.CompilerServices; using System.Text; +using RESPite.Buffers; using RESPite.Internal; #if NET @@ -46,6 +47,10 @@ private enum RespFlags : byte // the current buffer that we're observing private int _bufferIndex; // after TryRead, this should be positioned immediately before the actual data + // optional services offered by whoever owns the buffer we are reading; deliberately one field + // holding either the service itself or an IServiceProvider, rather than a field per service + private object? _services; + // the position in a multi-segment payload private long _positionBase; // total data we've already moved past in *previous* buffers private ReadOnlySequenceSegment? _tail; // the next tail node @@ -1129,17 +1134,70 @@ public readonly T ParseBytes(IFormatProvider? formatProvider = null) where T /// Initializes a new instance of the struct. /// /// The raw contents to parse with this instance. - public RespReader(ReadOnlySpan value) + public RespReader(ReadOnlySpan value) : this(value, services: null) + { + } + + /// + /// Initializes a new instance of the struct, offering services from whoever + /// owns ; see . + /// + /// The raw contents to parse with this instance. + /// The service - or - associated with the buffer. + internal RespReader(ReadOnlySpan value, object? services) { _length = 0; _flags = RespFlags.None; _prefix = RespPrefix.None; + _services = services; SetCurrent(value); _remainingTailLength = _positionBase = 0; _tail = null; } + /// + /// Obtain a service offered by whoever owns the buffer being read, if any. + /// + /// + /// The reader carries a single service slot; that slot either *is* the requested service (the common + /// case, resolved by a type test) or is an able to supply services that + /// the reader does not know about in advance. + /// + internal readonly bool TryGetService([NotNullWhen(true)] out T? service) + where T : class + { + switch (_services) + { + case T typed: + service = typed; + return true; + case IServiceProvider provider when provider.GetService(typeof(T)) is T resolved: + service = resolved; + return true; + default: + service = null; + return false; + } + } + + /// + /// If the current element is a contiguous payload inside a buffer whose owner supports counted + /// reservations, take a reservation against it, so the payload can be retained without copying. + /// + /// Callers must fall back to copying when this reports False. + internal readonly bool TryReservePayload(out PayloadReservation reservation) + { + if (TryGetSpan(out var payload) && !payload.IsEmpty + && TryGetService(out var provider)) + { + return provider.TryReserve(payload, out reservation); + } + + reservation = default; + return false; + } + private void MovePastCurrent() { // skip past the trailing portion of a value, if any diff --git a/src/StackExchange.Redis/Lease.cs b/src/StackExchange.Redis/Lease.cs index 05f742c84..1913aaaf1 100644 --- a/src/StackExchange.Redis/Lease.cs +++ b/src/StackExchange.Redis/Lease.cs @@ -1,5 +1,6 @@ using System; using System.Buffers; +using System.Diagnostics; using System.Runtime.CompilerServices; using System.Runtime.InteropServices; using System.Threading; @@ -19,6 +20,10 @@ public sealed class Lease : IMemoryOwner private object? _buffer; + // where the data starts within _buffer; non-zero only for a lease that shares a larger, + // reference-counted buffer with other holders (see Create(in PayloadReservation)) + private readonly int _offset; + /// /// Gets whether this lease is empty. /// @@ -63,15 +68,28 @@ public static Lease Create(int length, MemoryPool? pool, bool clear = true return lease; } + /// + /// Create a lease over part of a buffer owned - and reference-counted - by someone else; disposing + /// the lease releases its reference, rather than returning the buffer directly. + /// + internal static Lease Create(IMemoryOwner owner, int offset, int length) + { + // must be non-empty: Dispose() short-circuits on Length == 0, so a zero-length lease would + // take the reference and never give it back, stranding the buffer + Debug.Assert(length > 0, "a shared lease must be non-empty"); + return new Lease(owner, length, offset); + } + private Lease(T[] arr, int length) { _buffer = arr; Length = length; } - private Lease(IMemoryOwner memoryOwner, int length) + private Lease(IMemoryOwner memoryOwner, int length, int offset = 0) { _buffer = memoryOwner; + _offset = offset; Length = length; } @@ -100,15 +118,15 @@ public void Dispose() /// The data as a . /// public Memory Memory => _buffer is IMemoryOwner memoryOwner - ? memoryOwner.Memory.Slice(0, Length) - : new Memory((T[]?)_buffer ?? ThrowDisposed(), 0, Length); + ? memoryOwner.Memory.Slice(_offset, Length) + : new Memory((T[]?)_buffer ?? ThrowDisposed(), _offset, Length); /// /// The data as a . /// public Span Span => _buffer is IMemoryOwner memoryOwner - ? memoryOwner.Memory.Span.Slice(0, Length) - : new Span((T[]?)_buffer ?? ThrowDisposed(), 0, Length); + ? memoryOwner.Memory.Span.Slice(_offset, Length) + : new Span((T[]?)_buffer ?? ThrowDisposed(), _offset, Length); /// /// The data as an . @@ -122,9 +140,9 @@ public ArraySegment ArraySegment if (!MemoryMarshal.TryGetArray((ReadOnlyMemory)memoryOwner.Memory, out var segment)) throw new NotSupportedException("Only array-backed buffers are supported"); - return new ArraySegment(segment.Array!, segment.Offset, Length); + return new ArraySegment(segment.Array!, segment.Offset + _offset, Length); } - return new ArraySegment((T[]?)_buffer ?? ThrowDisposed(), 0, Length); + return new ArraySegment((T[]?)_buffer ?? ThrowDisposed(), _offset, Length); } } } diff --git a/src/StackExchange.Redis/PhysicalConnection.Read.cs b/src/StackExchange.Redis/PhysicalConnection.Read.cs index 6690a7227..880852d9f 100644 --- a/src/StackExchange.Redis/PhysicalConnection.Read.cs +++ b/src/StackExchange.Redis/PhysicalConnection.Read.cs @@ -474,7 +474,7 @@ static bool IsArrayPong(ReadOnlySpan payload) case ArrayPong_UC_Bulk.HashCS when payload.StartsWith(ArrayPong_UC_Bulk.U8): case ArrayPong_LC_Simple.HashCS when payload.StartsWith(ArrayPong_LC_Simple.U8): case ArrayPong_UC_Simple.HashCS when payload.StartsWith(ArrayPong_UC_Simple.U8): - var reader = new RespReader(payload); + var reader = new RespReader(payload); // no services needed: never leases return reader.SafeTryMoveNext() // have root && reader.Prefix == RespPrefix.Array // root is array && reader.SafeTryMoveNext() // have first child @@ -550,7 +550,7 @@ private OutOfBandResult OnOutOfBand(ReadOnlySpan payload, ref IMemoryOwner var muxer = BridgeCouldBeNull?.Multiplexer; if (muxer is null) return OutOfBandResult.Handled; // consume it blindly - var reader = new RespReader(payload); + var reader = new RespReader(payload, _readerServices); // read the message kind from the first element if (reader.SafeTryMoveNext() & reader.IsAggregate & !reader.IsStreaming @@ -716,7 +716,7 @@ static void Throw(ReadOnlySpan frame, ConnectionType connection, RedisProt Trace("Response to: " + msg); _readStatus = ReadStatus.ComputeResult; - var reader = new RespReader(frame); + var reader = new RespReader(frame, _readerServices); OnDetailLog($"computing result for {msg.CommandAndKey} ({RespReaderInternalExtensions.GetRespPrefix(frame)})"); @@ -750,7 +750,7 @@ static void Throw(ReadOnlySpan frame, ConnectionType connection, RedisProt static bool ProcessHighIntegrityResponseToken(Message message, ReadOnlySpan frame, PhysicalConnection? connection) { bool isValid = false; - var reader = new RespReader(frame); + var reader = new RespReader(frame); // no services needed: never leases if ((reader.SafeTryMoveNext() & reader.IsScalar) && reader.ScalarLength() is 4) { @@ -876,7 +876,7 @@ private static readonly uint [Conditional("DEBUG")] private static void DebugValidateSingleFrame(ReadOnlySpan payload) { - var reader = new RespReader(payload); + var reader = new RespReader(payload); // debug validation only: never leases if (!reader.TryMoveNext(checkError: false)) { throw new InvalidOperationException("No root RESP element"); diff --git a/src/StackExchange.Redis/PhysicalConnection.cs b/src/StackExchange.Redis/PhysicalConnection.cs index 1c4222584..0b172da11 100644 --- a/src/StackExchange.Redis/PhysicalConnection.cs +++ b/src/StackExchange.Redis/PhysicalConnection.cs @@ -46,6 +46,10 @@ private static readonly Message // things sent to this physical, but not yet received private readonly Queue _writtenAwaitingResponse = new Queue(); + // services offered to every reader we create over this connection's buffers; null for a + // detached/dummy connection, where readers simply fall back to shared defaults + private readonly ReaderServices? _readerServices; + private Message? _awaitingToken; private readonly string _physicalName; @@ -110,6 +114,9 @@ public PhysicalConnection(PhysicalBridge bridge, BufferedStreamWriter.WriteMode connectionType = bridge.ConnectionType; WriteMode = writeMode; _bridge = new WeakReference(bridge); + // resolved once here rather than per frame: readers are created for every reply, and the + // route to the multiplexer is a weak reference plus two hops + _readerServices = bridge.Multiplexer.ReaderServices; ChannelPrefix = bridge.Multiplexer.ChannelPrefix; if (ChannelPrefix?.Length == 0) ChannelPrefix = null; // null tests are easier than null+empty var endpoint = bridge.ServerEndPoint.EndPoint; diff --git a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt index 551d63592..e117146ee 100644 --- a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt @@ -146,7 +146,7 @@ static StackExchange.Redis.BitFieldOperation.operator !=(StackExchange.Redis.Bit static StackExchange.Redis.BitFieldOperation.Set(StackExchange.Redis.BitFieldEncoding encoding, StackExchange.Redis.BitFieldOffset offset, long value, StackExchange.Redis.BitFieldOverflow overflow = StackExchange.Redis.BitFieldOverflow.Wrap) -> StackExchange.Redis.BitFieldOperation StackExchange.Redis.RespReaderExtensions static StackExchange.Redis.RespReaderExtensions.ReadRedisValue(this in RESPite.Messages.RespReader reader) -> StackExchange.Redis.RedisValue -static StackExchange.Redis.RespReaderExtensions.ReadLease(this in RESPite.Messages.RespReader reader, System.Buffers.MemoryPool? pool = null) -> StackExchange.Redis.Lease? +static StackExchange.Redis.RespReaderExtensions.ReadLease(this in RESPite.Messages.RespReader reader) -> StackExchange.Redis.Lease? static StackExchange.Redis.RespReaderExtensions.ReadRedisResult(this in RESPite.Messages.RespReader reader) -> StackExchange.Redis.RedisResult! static StackExchange.Redis.RespReaderExtensions.ReadRedisKey(this in RESPite.Messages.RespReader reader) -> StackExchange.Redis.RedisKey StackExchange.Redis.ProductVariant.Dragonfly = 3 -> StackExchange.Redis.ProductVariant diff --git a/src/StackExchange.Redis/ReaderServices.cs b/src/StackExchange.Redis/ReaderServices.cs new file mode 100644 index 000000000..44558afe3 --- /dev/null +++ b/src/StackExchange.Redis/ReaderServices.cs @@ -0,0 +1,30 @@ +using System.Buffers; +using RESPite.Buffers; + +namespace StackExchange.Redis; + +/// +/// The services offered to a reading from a connection: the +/// buffers on that path are owned by the read loop rather than by the reply, so the only thing on offer +/// is where to rent from when a caller has to take a copy. +/// +/// +/// One instance per multiplexer, held by each physical connection, and handed to every reader it creates. +/// Note that this deliberately reads through to the configuration rather than capturing the pool, so that +/// it stays a pure indirection - the pool is resolved at the point of use, exactly as it was when callers +/// passed it explicitly. +/// +internal sealed class ReaderServices(ConfigurationOptions config) : IBufferPoolProvider +{ + public MemoryPool? BufferPool => config.ResponseBufferPool; +} + +public partial class ConnectionMultiplexer +{ + private ReaderServices? _readerServices; + + /// + /// Services offered to readers created against connections belonging to this multiplexer. + /// + internal ReaderServices ReaderServices => _readerServices ??= new ReaderServices(RawConfig); +} diff --git a/src/StackExchange.Redis/RespReaderExtensions.cs b/src/StackExchange.Redis/RespReaderExtensions.cs index c685a955a..1048d5e2c 100644 --- a/src/StackExchange.Redis/RespReaderExtensions.cs +++ b/src/StackExchange.Redis/RespReaderExtensions.cs @@ -1,8 +1,9 @@ -using System; +using System; using System.Buffers; using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using RESPite; +using RESPite.Buffers; using RESPite.Messages; namespace StackExchange.Redis; @@ -62,7 +63,23 @@ public static RedisValue ReadRedisValue(this in RespReader reader) /// /// Read a scalar value as a . /// - public static Lease? ReadLease(this in RespReader reader, MemoryPool? pool = null) + /// + /// + /// Whether the lease shares the underlying buffer or takes a copy depends on where the reader came + /// from, not on the caller. When the source can hand out a counted reservation over a contiguous + /// payload - as does - the lease points into that buffer and shares its + /// lifetime; otherwise, and for a payload that is not contiguous, the value is copied into a buffer of + /// its own. Either way the lease belongs to the caller and must be disposed. + /// + /// + /// In the sharing case the reply stays rented until the lease is disposed, so a short value taken from + /// a large reply keeps the whole reply alive. That is the right trade for the case this exists for - + /// retrieving a large payload without copying it - but it means leases should be disposed promptly, + /// and that a small part of a large reply is better copied out (RespReader.CopyTo) + /// than retained as a lease. + /// + /// + public static Lease? ReadLease(this in RespReader reader) { reader.DemandScalar(); if (reader.IsNull) return null; @@ -70,7 +87,20 @@ public static RedisValue ReadRedisValue(this in RespReader reader) var length = reader.ScalarLength(); if (length == 0) return Lease.Empty; - var lease = Lease.Create(length, pool, clear: false); + // if the payload is a single contiguous run inside a buffer that supports counted reservations, + // point at it rather than copying; the lease then keeps that buffer alive until it is disposed, + // which means a small payload can pin the whole reply - deliberate, and cheaper than the copy + if (reader.TryReservePayload(out var reservation)) + { + Debug.Assert(reservation.Length == length, "reserved length mismatch"); + return Lease.Create(reservation.Owner, reservation.Offset, reservation.Length); + } + + // otherwise copy - renting from the same pool the data came from, which the reader knows about + // via its services; there is deliberately no pool argument, because on the sharing path above + // any such argument would be silently ignored + reader.TryGetService(out var pools); + var lease = Lease.Create(length, pools?.BufferPool, clear: false); if (reader.TryGetSpan(out var span)) { span.CopyTo(lease.Span); diff --git a/src/StackExchange.Redis/RespReaderInternalExtensions.cs b/src/StackExchange.Redis/RespReaderInternalExtensions.cs index 5918fa189..33ad2f9bc 100644 --- a/src/StackExchange.Redis/RespReaderInternalExtensions.cs +++ b/src/StackExchange.Redis/RespReaderInternalExtensions.cs @@ -1,4 +1,4 @@ -using System; +using System; using RESPite.Messages; namespace StackExchange.Redis; @@ -7,9 +7,6 @@ internal static class RespReaderInternalExtensions { extension(in RespReader reader) { - internal Lease? AsLease(PhysicalConnection? connection) - => reader.ReadLease(connection?.BridgeCouldBeNull?.Multiplexer?.RawConfig?.ResponseBufferPool); - internal string DebugReadTruncatedString(int maxChars) { if (!reader.IsScalar) return ""; diff --git a/src/StackExchange.Redis/RespResult.cs b/src/StackExchange.Redis/RespResult.cs index bf2a10690..2520791b4 100644 --- a/src/StackExchange.Redis/RespResult.cs +++ b/src/StackExchange.Redis/RespResult.cs @@ -1,9 +1,10 @@ -using System; +using System; using System.Buffers; using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.Threading; using RESPite; +using RESPite.Buffers; using RESPite.Messages; namespace StackExchange.Redis; @@ -33,28 +34,18 @@ public sealed class RespResult : IDisposable internal static readonly RespResult NullReply = CreateNullSingleton(RespPrefix.Null, "_\r\n"u8); private static RespResult CreateNullSingleton(RespPrefix prefix, ReadOnlySpan raw) => - new(prefix, isNull: true, raw.ToArray(), raw.Length, noReturn: true); + new(prefix, isNull: true, RefCountedBuffer.CreateFixed(raw.ToArray())); - // the high bit of _length flags a buffer that must never be returned to a pool (the shared null - // singletons above, sitting on a fixed byte[]); Length masks it back off. Real captures never set - // it themselves - the length always comes from a checked cast of a non-negative byte count. - private const int NoReturnFlag = 1 << 31; + // reference-counted, so that a Lease taken from this reply (see RespReaderExtensions.ReadLease) can + // point back into this buffer rather than copying out of it; the buffer returns to its pool when this + // result and every lease taken from it have been disposed. + private RefCountedBuffer? _buffer; - // either a byte[] (from ArrayPool.Shared, or one of the fixed null singletons) or an - // IMemoryOwner (from a custom pool); sitting directly on this - rather than wrapping a - // Lease - avoids an extra allocation. - private object? _buffer; - private readonly int _length; - - private RespResult(RespPrefix prefix, bool isNull, object buffer, int length, bool noReturn = false) + private RespResult(RespPrefix prefix, bool isNull, RefCountedBuffer buffer) { - // length must not already occupy the high bit reserved for NoReturnFlag, or Length/NoReturn below - // would misread the buffer's real size and disposal-eligibility, respectively. - Debug.Assert(length >= 0, "length must be non-negative"); Prefix = prefix; IsNull = isNull; _buffer = buffer; - _length = noReturn ? length | NoReturnFlag : length; } internal static RespResult Capture(RespPrefix prefix, bool isNull, ref RespReader reader, int length, MemoryPool? pool) @@ -69,8 +60,9 @@ internal static RespResult Capture(RespPrefix prefix, bool isNull, ref RespReade }; } - object buffer = pool is null ? ArrayPool.Shared.Rent(length) : pool.Rent(length); - var result = new RespResult(prefix, isNull: false, buffer, length); + Debug.Assert(length >= 0, "length must be non-negative"); + var buffer = RefCountedBuffer.Rent(length, pool); + var result = new RespResult(prefix, isNull: false, buffer); var copied = reader.CopyRawTo(result.RawSpan); Debug.Assert(copied == length, "raw frame capture length mismatch"); return result; @@ -86,23 +78,10 @@ internal static RespResult Capture(RespPrefix prefix, bool isNull, ref RespReade /// public bool IsNull { get; } - private int BufferLength => _length & ~NoReturnFlag; - - private bool NoReturn => (_length & NoReturnFlag) != 0; - - private Span RawSpan - { - get - { - var buffer = _buffer; - if (buffer is byte[] arr) return new Span(arr, 0, BufferLength); - if (buffer is IMemoryOwner owner) return owner.Memory.Span.Slice(0, BufferLength); - return ThrowDisposed(); - } - } + private Span RawSpan => (_buffer ?? ThrowDisposed()).GetSpan(); [DoesNotReturn] - private static Span ThrowDisposed() => throw new ObjectDisposedException(nameof(RespResult)); + private static RefCountedBuffer ThrowDisposed() => throw new ObjectDisposedException(nameof(RespResult)); /// /// Obtains a reader over the contents of this reply, positioned at the top-level element; this @@ -110,7 +89,8 @@ private Span RawSpan /// public RespReader Read() { - var reader = new RespReader(RawSpan); + var buffer = _buffer ?? ThrowDisposed(); + var reader = new RespReader(buffer.GetSpan(), buffer); reader.MoveNext(); return reader; } @@ -121,7 +101,8 @@ public RespReader Read() /// public RespReader ReadScalar() { - var reader = new RespReader(RawSpan); + var buffer = _buffer ?? ThrowDisposed(); + var reader = new RespReader(buffer.GetSpan(), buffer); reader.MoveNextScalar(); return reader; } @@ -131,9 +112,17 @@ public RespReader ReadScalar() /// public void Dispose() { - if (NoReturn) return; // one of the shared null singletons; never disposed - var buffer = Interlocked.Exchange(ref _buffer, null); - if (buffer is byte[] arr) ArrayPool.Shared.Return(arr); - else if (buffer is IMemoryOwner owner) owner.Dispose(); + // one of the shared null singletons: never counted down, and the field must stay put - these + // instances are handed out again and again for the lifetime of the process + if (_buffer is { IsFixed: true }) return; + + // exchange-to-null makes this once-only, however many times a caller disposes us; any leases + // still holding a reservation keep the buffer alive until they are disposed in turn + Interlocked.Exchange(ref _buffer, null)?.Release(); } + + /// + /// The number of live references to the underlying buffer; for tests. + /// + internal int RefCount => _buffer?.RefCount ?? 0; } diff --git a/src/StackExchange.Redis/ResultProcessor.Lease.cs b/src/StackExchange.Redis/ResultProcessor.Lease.cs index 919c8f42f..bcbcc2021 100644 --- a/src/StackExchange.Redis/ResultProcessor.Lease.cs +++ b/src/StackExchange.Redis/ResultProcessor.Lease.cs @@ -163,7 +163,7 @@ protected override bool SetResultCore(PhysicalConnection connection, Message mes { if (reader.IsScalar) { - SetResult(message, reader.AsLease(connection)!); + SetResult(message, reader.ReadLease()!); return true; } return false; @@ -178,7 +178,7 @@ protected override bool SetResultCore(PhysicalConnection connection, Message mes && reader.TryMoveNext() && reader.IsScalar) { // treat an array of 1 like a single reply - SetResult(message, reader.AsLease(connection)!); + SetResult(message, reader.ReadLease()!); return true; } return false; diff --git a/tests/StackExchange.Redis.Tests/RespResultLeaseSharingTests.cs b/tests/StackExchange.Redis.Tests/RespResultLeaseSharingTests.cs new file mode 100644 index 000000000..64ae82589 --- /dev/null +++ b/tests/StackExchange.Redis.Tests/RespResultLeaseSharingTests.cs @@ -0,0 +1,245 @@ +using System; +using System.Buffers; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Xunit; + +namespace StackExchange.Redis.Tests; + +/// +/// Covers the reference-counted sharing between a and the leases taken from it: +/// a contiguous scalar payload should be handed out by reference rather than copied, and the underlying +/// buffer should survive until the result and every lease have been disposed. +/// +public class RespResultLeaseSharingTests(ITestOutputHelper output, SharedConnectionFixture fixture) : TestBase(output, fixture) +{ + private async Task<(IInternalConnectionMultiplexer Conn, RespResult Result, string Expected)> GetBlobAsync(int size = 4096) + { + var conn = Create(); + var db = conn.GetDatabase(); + RedisKey key = Me(); + var expected = new string('x', size - 8) + "-the-end"; + await db.StringSetAsync(key, expected); + return (conn, await db.ExecuteRespAsync("GET", new RedisKeyOrValue[] { key }), expected); + } + + [Fact] + public async Task ReadLease_SharesTheReplyBuffer_RatherThanCopying() + { + var (conn, result, expected) = await GetBlobAsync(); + await using var _ = conn; + using (result) + { + Assert.Equal(1, result.RefCount); + + using var lease = result.ReadScalar().ReadLease(); + Assert.NotNull(lease); + Assert.Equal(2, result.RefCount); // the lease shares the buffer; nothing was copied + Assert.Equal(expected, Encoding.UTF8.GetString(lease!.Span)); + } + } + + [Fact] + public async Task LeaseMayOutliveTheResult() + { + var (conn, result, expected) = await GetBlobAsync(); + await using var _ = conn; + + var lease = result.ReadScalar().ReadLease(); + result.Dispose(); + + // the buffer is still alive, because the lease still holds a reference + Assert.Equal(expected, Encoding.UTF8.GetString(lease!.Span)); + lease.Dispose(); + Assert.Throws(() => lease.Span.Length); + } + + [Fact] + public async Task ResultMayOutliveTheLease() + { + var (conn, result, expected) = await GetBlobAsync(); + await using var _ = conn; + using (result) + { + var lease = result.ReadScalar().ReadLease(); + lease!.Dispose(); + + Assert.Equal(1, result.RefCount); + Assert.Equal(expected, (string?)result.ReadScalar().ReadRedisValue()); // still readable + } + } + + [Fact] + public async Task DisposingRepeatedlyReleasesOnlyOnce() + { + var (conn, result, _) = await GetBlobAsync(); + await using var __ = conn; + + var lease = result.ReadScalar().ReadLease(); + Assert.Equal(2, result.RefCount); + + lease!.Dispose(); + lease.Dispose(); + lease.Dispose(); + Assert.Equal(1, result.RefCount); + + result.Dispose(); + result.Dispose(); + // fully released; reading now must fault rather than read a recycled buffer + Assert.Throws(() => result.ReadScalar()); + } + + [Fact] + public async Task TwoLeasesFromOneResultAreIndependent() + { + var (conn, result, expected) = await GetBlobAsync(); + await using var _ = conn; + using (result) + { + var a = result.ReadScalar().ReadLease(); + var b = result.ReadScalar().ReadLease(); + Assert.Equal(3, result.RefCount); + + a!.Dispose(); + Assert.Equal(2, result.RefCount); + Assert.Equal(expected, Encoding.UTF8.GetString(b!.Span)); // unaffected by a's disposal + b.Dispose(); + Assert.Equal(1, result.RefCount); + } + } + + [Fact] + public async Task SharedLeaseStillSupportsArraySegmentConsumers() + { + // DecodeString and AsStream both go via Lease.ArraySegment; a shared lease is backed by a + // MemoryManager rather than an array directly, so this is the case most at risk of regressing + var (conn, result, expected) = await GetBlobAsync(); + await using var _ = conn; + using (result) + { + using var lease = result.ReadScalar().ReadLease(); + + var segment = lease!.ArraySegment; + Assert.True(segment.Offset > 0, "payload should sit at a non-zero offset within the reply"); + Assert.Equal(expected.Length, segment.Count); + Assert.Equal(expected, Encoding.UTF8.GetString(segment.Array!, segment.Offset, segment.Count)); + + Assert.Equal(expected, lease.DecodeString()); + + using var stream = lease.AsStream(ownsLease: false); + using var reader = new System.IO.StreamReader(stream); + Assert.Equal(expected, reader.ReadToEnd()); + } + } + + [Fact] + public async Task ShortPayloadIsAlsoShared() + { + var conn = Create(); + await using var _ = conn; + var db = conn.GetDatabase(); + RedisKey key = Me(); + await db.StringSetAsync(key, "hi"); + + using var result = await db.ExecuteRespAsync("GET", new RedisKeyOrValue[] { key }); + using var lease = result.ReadScalar().ReadLease(); + Assert.Equal(2, result.RefCount); + Assert.Equal("hi", Encoding.UTF8.GetString(lease!.Span)); + } + + [Fact] + public async Task EmptyPayloadUsesTheSharedEmptyLease_AndTakesNoReference() + { + var conn = Create(); + await using var _ = conn; + var db = conn.GetDatabase(); + RedisKey key = Me(); + await db.StringSetAsync(key, ""); + + using var result = await db.ExecuteRespAsync("GET", new RedisKeyOrValue[] { key }); + using var lease = result.ReadScalar().ReadLease(); + Assert.Same(Lease.Empty, lease); + Assert.Equal(1, result.RefCount); // no reference taken, so nothing to strand + } + + [Fact] + public async Task NullReplyTakesNoLease() + { + var conn = Create(); + await using var _ = conn; + var db = conn.GetDatabase(); + RedisKey key = Me(); // never set + + using var result = await db.ExecuteRespAsync("GET", new RedisKeyOrValue[] { key }); + Assert.True(result.IsNull); + Assert.Null(result.ReadScalar().ReadLease()); + } + + [Fact] + public async Task DisposingASharedNullSingletonIsHarmless() + { + // the null replies are process-wide singletons on fixed buffers; disposing one must not + // poison it for every later caller + var conn = Create(); + await using var _ = conn; + var db = conn.GetDatabase(); + RedisKey key = Me(); + + for (int i = 0; i < 3; i++) + { + var result = await db.ExecuteRespAsync("GET", new RedisKeyOrValue[] { key }); + Assert.True(result.IsNull); + result.Dispose(); + result.Dispose(); + } + + // the singleton must still be readable: its buffer is reached through the same GetSpan path + // that throws once a counted buffer has been released, so this would fault if we had counted + // the singleton down to zero along the way + using var again = await db.ExecuteRespAsync("GET", new RedisKeyOrValue[] { key }); + Assert.True(again.IsNull); + var reader = again.Read(); + Assert.True(reader.IsNull); + Assert.Equal(again.Prefix, reader.Prefix); + } + + /// + /// The pool used for a copied lease is no longer passed in - it is resolved from the reader's + /// services - so this asserts that a configured ResponseBufferPool is still actually honoured. + /// Without this, losing the service wiring on a lease path would silently fall back to + /// ArrayPool<byte>.Shared, with nothing failing to say so. + /// + [Fact] + public async Task ConfiguredResponseBufferPoolIsUsedForCopiedLeases() + { + var pool = new CountingMemoryPool(); + var config = ConfigurationOptions.Parse(GetConfiguration()); + config.ResponseBufferPool = pool; + config.AllowAdmin = true; + + await using var conn = await ConnectionMultiplexer.ConnectAsync(config); + var db = conn.GetDatabase(); + RedisKey key = Me(); + await db.HashSetAsync(key, "field", new string('y', 1024)); + + var before = pool.RentCount; + using var lease = await db.HashGetLeaseAsync(key, "field"); + Assert.NotNull(lease); + Assert.Equal(1024, lease!.Length); + Assert.True(pool.RentCount > before, $"expected the configured pool to be used; rents went {before} -> {pool.RentCount}"); + } + + private sealed class CountingMemoryPool : MemoryPool + { + private int _rentCount; + public int RentCount => Volatile.Read(ref _rentCount); + public override int MaxBufferSize => MemoryPool.Shared.MaxBufferSize; + public override IMemoryOwner Rent(int minBufferSize = -1) + { + Interlocked.Increment(ref _rentCount); + return MemoryPool.Shared.Rent(minBufferSize); + } + protected override void Dispose(bool disposing) { } + } +} From e75297d8d3a57fbb9063039090cee470688f5d5f Mon Sep 17 00:00:00 2001 From: mgravell Date: Tue, 8 Sep 2026 17:09:19 +0100 Subject: [PATCH 14/17] Handle NOSCRIPT on the RespResult script path, so its retry can actually fire ScriptEvaluateResp, ScriptEvaluateReadOnlyResp and their async twins all catch RedisServerException "when (msg.IsScriptUnavailable)" and re-issue, which is how an EVALSHA whose script the server has forgotten becomes an EVAL. But nothing ever set that flag on this path: noticing NOSCRIPT - flushing our cached hashes and marking the message - lived only in ScriptResultProcessor, the processor for the classic RedisResult APIs. RespResultProcessor did not do it, so the filter could never match and all four retries were unreachable; the NOSCRIPT surfaced to the caller instead. Hoist that handling to NoteIfScriptUnavailable on the base processor and call it from both. Any processor that can be the target of an EVALSHA needs it. This showed up as an intermittent failure of RespResultTests.ScriptEvaluateReadOnlyResp_Works: ScriptingTests calls SCRIPT FLUSH in about ten places and xUnit runs classes in parallel, so it wipes the server's cache while another class still holds the hash. The test provokes it deliberately, but by poisoning only its own connection's client-side cache rather than by calling SCRIPT FLUSH - a server-wide flush from a test races every other test using scripts, which is what made this intermittent in the first place. --- .../ResultProcessor.RespResult.cs | 6 +- src/StackExchange.Redis/ResultProcessor.cs | 25 ++++-- .../ScriptEvalRespNoScriptTests.cs | 76 +++++++++++++++++++ 3 files changed, 101 insertions(+), 6 deletions(-) create mode 100644 tests/StackExchange.Redis.Tests/ScriptEvalRespNoScriptTests.cs diff --git a/src/StackExchange.Redis/ResultProcessor.RespResult.cs b/src/StackExchange.Redis/ResultProcessor.RespResult.cs index 9a5845dae..33df7ce1a 100644 --- a/src/StackExchange.Redis/ResultProcessor.RespResult.cs +++ b/src/StackExchange.Redis/ResultProcessor.RespResult.cs @@ -1,4 +1,4 @@ -using System; +using System; using RESPite.Messages; // ReSharper disable once CheckNamespace @@ -23,6 +23,10 @@ public override bool SetResult(PhysicalConnection connection, Message message, r if (probe.IsError) { + // an EVALSHA can come back NOSCRIPT at any time - the server may have been flushed, + // restarted, or failed over - and the callers of this processor retry on that, but only + // if we tell them; see NoteIfScriptUnavailable + NoteIfScriptUnavailable(connection, message, in probe); return base.SetResult(connection, message, ref reader); } diff --git a/src/StackExchange.Redis/ResultProcessor.cs b/src/StackExchange.Redis/ResultProcessor.cs index 68662aa29..0bd9d0910 100644 --- a/src/StackExchange.Redis/ResultProcessor.cs +++ b/src/StackExchange.Redis/ResultProcessor.cs @@ -16,6 +16,25 @@ namespace StackExchange.Redis { internal abstract partial class ResultProcessor { + /// + /// If a reply is a NOSCRIPT error, note it on the message so the caller can re-issue as EVAL, and + /// drop our cached hashes for the server. + /// + /// + /// Every processor that can be the target of an EVALSHA needs this, not just the one returning + /// : without it the retry filters on IsScriptUnavailable can never + /// match, and the NOSCRIPT surfaces to the caller. + /// + private protected static void NoteIfScriptUnavailable(PhysicalConnection connection, Message message, in RespReader errorReader) + { + if (errorReader.IsError && RedisErrorKindMetadata.Classify(errorReader) == RedisErrorKind.NoScript) + { + // scripts are not flushed individually, so assume the entire script cache is toast ("SCRIPT FLUSH") + connection.BridgeCouldBeNull?.ServerEndPoint?.FlushScriptCache(); + message.SetScriptUnavailable(); + } + } + public static readonly ResultProcessor Boolean = new BooleanProcessor(), DemandOK = new ExpectBasicStringProcessor(Literals.OK.Hash), @@ -2303,11 +2322,7 @@ public override bool SetResult(PhysicalConnection connection, Message message, r { var copy = reader; reader.MovePastBof(); - if (reader.IsError && RedisErrorKindMetadata.Classify(reader) == RedisErrorKind.NoScript) - { // scripts are not flushed individually, so assume the entire script cache is toast ("SCRIPT FLUSH") - connection.BridgeCouldBeNull?.ServerEndPoint?.FlushScriptCache(); - message.SetScriptUnavailable(); - } + NoteIfScriptUnavailable(connection, message, in reader); // and apply usual processing for the rest return base.SetResult(connection, message, ref copy); } diff --git a/tests/StackExchange.Redis.Tests/ScriptEvalRespNoScriptTests.cs b/tests/StackExchange.Redis.Tests/ScriptEvalRespNoScriptTests.cs new file mode 100644 index 000000000..67dd19b10 --- /dev/null +++ b/tests/StackExchange.Redis.Tests/ScriptEvalRespNoScriptTests.cs @@ -0,0 +1,76 @@ +using System.Text; +using System.Threading.Tasks; +using Xunit; + +namespace StackExchange.Redis.Tests; + +/// +/// A server can forget a script at any time - SCRIPT FLUSH, a restart, a failover to a replica that never +/// had it - while the client still has the hash cached, so every EVALSHA path has to cope with NOSCRIPT by +/// re-issuing as EVAL. These pin that down for the RespResult-returning script APIs. +/// +public class ScriptEvalRespNoScriptTests(ITestOutputHelper output) : TestBase(output) +{ + private const string Script = "return 'hello from ScriptEvalRespNoScriptTests'"; + private const string Expected = "hello from ScriptEvalRespNoScriptTests"; + + // A hash we hold that the server does not: exactly the state a SCRIPT FLUSH, a restart, or a + // failover to a replica that never had the script would leave us in. + // + // Deliberately *not* done by calling SCRIPT FLUSH: that is server-wide, and would race every other + // test using scripts on the same server - ScriptingTests.CheckLoads asserts ScriptExists straight + // after caching, so a flush landing in that window fails it. Poisoning only this connection's own + // client-side cache reproduces the same condition and touches nothing shared. + private static readonly byte[] UnknownHash = Encoding.ASCII.GetBytes(new string('0', 40)); + + private IInternalConnectionMultiplexer ConnectWithStaleHash() + { + var conn = Create(shared: false, allowAdmin: true); + conn.GetServerSnapshot()[0].AddScript(Script, UnknownHash); + return conn; + } + + [Fact] + public async Task ScriptEvaluateResp_RecoversFromNoScript() + { + await using var conn = ConnectWithStaleHash(); + using var result = conn.GetDatabase().ScriptEvaluateResp(Script, default, default); + Assert.Equal(Expected, (string?)result.ReadScalar().ReadRedisValue()); + } + + [Fact] + public async Task ScriptEvaluateRespAsync_RecoversFromNoScript() + { + await using var conn = ConnectWithStaleHash(); + using var result = await conn.GetDatabase().ScriptEvaluateRespAsync(Script, default, default); + Assert.Equal(Expected, (string?)result.ReadScalar().ReadRedisValue()); + } + + [Fact] + public async Task ScriptEvaluateReadOnlyResp_RecoversFromNoScript() + { + await using var conn = ConnectWithStaleHash(); + using var result = conn.GetDatabase().ScriptEvaluateReadOnlyResp(Script, default, default); + Assert.Equal(Expected, (string?)result.ReadScalar().ReadRedisValue()); + } + + [Fact] + public async Task ScriptEvaluateReadOnlyRespAsync_RecoversFromNoScript() + { + await using var conn = ConnectWithStaleHash(); + using var result = await conn.GetDatabase().ScriptEvaluateReadOnlyRespAsync(Script, default, default); + Assert.Equal(Expected, (string?)result.ReadScalar().ReadRedisValue()); + } + + /// + /// Control: the classic RedisResult-returning path already copes, so this shows the difference is the + /// result processor rather than anything about the message or the test setup. + /// + [Fact] + public async Task ScriptEvaluate_Classic_RecoversFromNoScript() + { + await using var conn = ConnectWithStaleHash(); + var result = conn.GetDatabase().ScriptEvaluate(Script); + Assert.Equal(Expected, (string?)result); + } +} From 95a3ff6fcc4857dbad7102bb0b3c47ee1b38b996 Mon Sep 17 00:00:00 2001 From: mgravell Date: Tue, 8 Sep 2026 17:09:19 +0100 Subject: [PATCH 15/17] Docs: steer readers to IsScalar/IsAggregate rather than a specific RespPrefix The category is stable across protocols; the specific prefix is not, and which protocol you get depends on the server and configuration rather than on your code. Aggregates are the sharp edge: RESP2 has exactly one aggregate encoding, so everything arrives as Array, while RESP3 splits it into map, set and push. Prefix == RespPrefix.Array therefore works perfectly on RESP2 and silently stops matching on RESP3. Both docs previously pointed the other way - Execute.md listed "IsNull/Prefix" as the rundown, and Scripting.md sent readers to Prefix for a test that IsNull answers. The accompanying test pins the table the docs now assert. Written after an earlier draft claimed SISMEMBER returns a boolean under RESP3, which it does not; a doc making protocol claims with nothing enforcing them goes stale quietly. --- docs/Execute.md | 4 +- docs/Scripting.md | 19 +++++- .../RespShapeCategoryTests.cs | 59 +++++++++++++++++++ 3 files changed, 80 insertions(+), 2 deletions(-) create mode 100644 tests/StackExchange.Redis.Tests/RespShapeCategoryTests.cs diff --git a/docs/Execute.md b/docs/Execute.md index 8dee3f3d4..538f055c7 100644 --- a/docs/Execute.md +++ b/docs/Execute.md @@ -38,7 +38,9 @@ if (!result.IsNull) // even that allocation, by copying straight into a buffer you already own ``` -See [Scripting](Scripting#reading-the-result-respresult-vs-redisresult) for the full rundown of `RespResult` - `IsNull`/`Prefix`, `ReadScalar()`/`Read()`, and the `ReadRedisValue`/`ReadLease`/`ReadRedisResult` accessors - it applies identically here; `ExecuteResp` and `ScriptEvaluateResp` share the same response-reading API, only the request differs (a command name instead of a script). +See [Scripting](Scripting#reading-the-result-respresult-vs-redisresult) for the full rundown of `RespResult` - `IsNull`/`IsScalar`/`IsAggregate`, `ReadScalar()`/`Read()`, and the `ReadRedisValue`/`ReadLease`/`ReadRedisResult` accessors - it applies identically here; `ExecuteResp` and `ScriptEvaluateResp` share the same response-reading API, only the request differs (a command name instead of a script). + +Test the shape of a reply with the category tests (`IsScalar`, `IsAggregate`, `IsNull`), not by comparing `Prefix` to a specific `RespPrefix` - see [Testing what came back](Scripting#testing-what-came-back). Ad-hoc commands are where this bites hardest, because you are handed whatever the command actually returns: `HGETALL` and `CONFIG GET` are arrays under RESP2 and maps under RESP3, `SMEMBERS` is an array under RESP2 and a set under RESP3, and so on. `IsAggregate` covers all of those; `Prefix == RespPrefix.Array` covers only the RESP2 spelling. Measured effect --- diff --git a/docs/Scripting.md b/docs/Scripting.md index f744b2eaa..6dd2e6612 100644 --- a/docs/Scripting.md +++ b/docs/Scripting.md @@ -60,7 +60,24 @@ back a large blob without copying it - but dispose leases promptly, and if you w of a large reply around for a long time, copy it out (`CopyTo` into your own buffer) rather than holding the lease. -A `RespResult` is never itself a `null` C# reference - the reply is always a real, non-null `RespResult`, and `IsNull` tells you whether the underlying RESP reply itself was a null (there are three distinct null encodings on the wire; `RespResult` preserves which one you got via `Prefix`, rather than collapsing them). This also leaves room for RESP3 attribute metadata on a null reply in future. +A `RespResult` is never itself a `null` C# reference - the reply is always a real, non-null `RespResult`, and `IsNull` tells you whether the underlying RESP reply itself was a null. There are three distinct null encodings on the wire and `RespResult` preserves which one you got rather than collapsing them, but `IsNull` is the test you want; see below. This also leaves room for RESP3 attribute metadata on a null reply in future. + +### Testing what came back + +Prefer the category tests - `IsScalar`, `IsAggregate`, `IsNull`, `IsError` - over comparing `Prefix` against a specific `RespPrefix`. The category is stable; the specific prefix is not, because the same command can be encoded differently under RESP2 and RESP3, and which protocol you get depends on the server version and on configuration rather than on your code: + +```csharp +var reader = result.Read(); +if (reader.IsNull) { /* no value */ } +else if (reader.IsScalar) { RedisValue value = reader.ReadRedisValue(); } +else if (reader.IsAggregate) { /* walk it - see below */ } +``` + +This matters most for **aggregates**. RESP2 has exactly one aggregate encoding - `*`, the array - so under RESP2 everything aggregate-shaped arrives as `Array`. RESP3 splits that into several: `HGETALL` and `CONFIG GET` come back as a map (`%`), `SMEMBERS` and the other set-returning commands as a set (`~`), and pub/sub delivery as a push (`>`). Code written as `Prefix == RespPrefix.Array` therefore works perfectly against a RESP2 connection and silently stops matching the moment the same code talks RESP3 - whereas `IsAggregate` is true for all of them. + +Scalars vary too, just less dramatically: RESP3 adds `,` (double), `#` (boolean), `(` (big integer) and `=` (verbatim string) where RESP2 would have sent a bulk string or an integer - `ZSCORE`, for example, is a bulk string under RESP2 and a double under RESP3. Nulls are the same story: RESP2 has a null bulk string (`$-1`) and a null array (`*-1`), RESP3 has the single `_`, which is why `IsNull` is the test rather than any prefix comparison. Note that a script is subject to this as well whenever its reply passes the protocol through - for example after `redis.setresp(3)`. + +`Prefix` remains available for when the exact wire encoding genuinely is what you care about - telling a verbatim string from a bulk string, say, or logging what actually arrived - but that is the exception, and reaching for it as a general shape test is the common way to write code that breaks on protocol upgrade. If the script can return a tree (an array, or a mix of shapes depending on input), `RespResult.Read()` gives you a `RespReader` positioned at the root. `.ReadRedisResult()` is the convenient option - it falls back to the familiar `RedisResult` materialization for the whole value, at the cost of allocating that same wrapper-object-per-node tree the low-allocation APIs elsewhere in this doc are trying to avoid. If efficiency actually matters for a tree-shaped reply, walk the `RespReader` directly instead: it's a forwards-only iterator over the raw reply, with the same low-level accessors (`ReadRedisValue`, `ReadLease`, `CopyTo`, `ScalarLength`, ...) available at each node, so you can read exactly what you need without materializing the rest of the tree: diff --git a/tests/StackExchange.Redis.Tests/RespShapeCategoryTests.cs b/tests/StackExchange.Redis.Tests/RespShapeCategoryTests.cs new file mode 100644 index 000000000..c46a1ece6 --- /dev/null +++ b/tests/StackExchange.Redis.Tests/RespShapeCategoryTests.cs @@ -0,0 +1,59 @@ +using System.Threading.Tasks; +using RESPite.Messages; +using Xunit; + +namespace StackExchange.Redis.Tests; + +/// +/// Pins the guidance in the docs (Scripting.md, "Testing what came back"): the IsScalar/IsAggregate/IsNull +/// categories hold across protocols, while the specific RespPrefix behind them does not. If a future +/// server or protocol change breaks one of these, the docs are wrong and should change with it. +/// +[RunPerProtocol] +public class RespShapeCategoryTests(ITestOutputHelper output) : TestBase(output) +{ + [Fact] + public async Task AggregateCategoryIsStableWhilePrefixIsNot() + { + await using var conn = Create(); + var db = conn.GetDatabase(); + bool resp3 = TestContext.Current.IsResp3(); + + RedisKey hkey = Me() + ":h", skey = Me() + ":s"; + await db.KeyDeleteAsync(hkey); + await db.KeyDeleteAsync(skey); + await db.HashSetAsync(hkey, "f", "v"); + await db.SetAddAsync(skey, "a"); + + using (var agg = db.ExecuteResp("HGETALL", new RedisKeyOrValue[] { hkey })) + { + var reader = agg.Read(); + Assert.True(reader.IsAggregate, "HGETALL must be IsAggregate under both protocols"); + Assert.Equal(resp3 ? RespPrefix.Map : RespPrefix.Array, reader.Prefix); + } + + using (var set = db.ExecuteResp("SMEMBERS", new RedisKeyOrValue[] { skey })) + { + var reader = set.Read(); + Assert.True(reader.IsAggregate, "SMEMBERS must be IsAggregate under both protocols"); + Assert.Equal(resp3 ? RespPrefix.Set : RespPrefix.Array, reader.Prefix); + } + + RedisKey zkey = Me() + ":z"; + await db.KeyDeleteAsync(zkey); + await db.SortedSetAddAsync(zkey, "m", 1.5); + using (var score = db.ExecuteResp("ZSCORE", new RedisKeyOrValue[] { zkey, (RedisValue)"m" })) + { + var reader = score.Read(); + Assert.True(reader.IsScalar, "ZSCORE must be IsScalar under both protocols"); + Assert.Equal(resp3 ? RespPrefix.Double : RespPrefix.BulkString, reader.Prefix); + } + + using (var missing = db.ExecuteResp("GET", new RedisKeyOrValue[] { (RedisKey)(Me() + ":nope") })) + { + var reader = missing.Read(); + Assert.True(reader.IsNull, "a missing GET must be IsNull under both protocols"); + Assert.Equal(resp3 ? RespPrefix.Null : RespPrefix.BulkString, reader.Prefix); + } + } +} From f8d9c6148c78f20095c215f098ccbbb90794347e Mon Sep 17 00:00:00 2001 From: mgravell Date: Tue, 8 Sep 2026 17:09:19 +0100 Subject: [PATCH 16/17] Send EVAL_RO/EVALSHA_RO for the read-only script APIs, where that is possible ScriptEvaluateReadOnly and ScriptEvaluateReadOnlyResp took the _RO command as far as the Message and no further: both ScriptEvaluateMessage and ScriptEvalMessage hardcoded EVAL/EVALSHA in WriteImpl, so the read-only APIs were read-only in name only - confirmed with MONITOR. Send the real thing where the server is 7.0+ and the command map allows it, and otherwise fall back to EVAL/EVALSHA, which is what these APIs have always sent. The two halves of that condition are resolved where each is knowable: the map at construction (it also decides what CheckMessage will accept), the server version at write time, when we know which server we are actually talking to and can re-resolve after a redirect. Both halves require EVAL_RO *and* EVALSHA_RO, because hash-versus-script is only decided at write time and can differ between an attempt and its retry. Falling back pins the retry category to CommandRetryReadOnly first. EVAL_RO defaults to that while EVAL defaults to CommandRetryWriteAccumulating, so swapping the command alone would quietly make a script the caller asked for read-only retry like a write. An explicit category from the caller still wins. Also fixes ServerEndPoint.GetScriptHash, which recognised a caller-supplied SHA1 only for EVALSHA - so passing a hash to the read-only API skipped the cache and tried to EVAL the hash text as a script. --- src/StackExchange.Redis/RedisDatabase.cs | 96 ++++++++++++-- src/StackExchange.Redis/RedisFeatures.cs | 6 + src/StackExchange.Redis/ServerEndPoint.cs | 2 +- .../ScriptReadOnlyCommandTests.cs | 122 ++++++++++++++++++ 4 files changed, 214 insertions(+), 12 deletions(-) create mode 100644 tests/StackExchange.Redis.Tests/ScriptReadOnlyCommandTests.cs diff --git a/src/StackExchange.Redis/RedisDatabase.cs b/src/StackExchange.Redis/RedisDatabase.cs index 669cb5a5a..286ef5904 100644 --- a/src/StackExchange.Redis/RedisDatabase.cs +++ b/src/StackExchange.Redis/RedisDatabase.cs @@ -2129,9 +2129,44 @@ public Task ScriptEvaluateAsync(LoadedLuaScript script, object? par return script.EvaluateAsync(this, parameters, withKeyPrefix: null, flags); } + /// + /// Pick the command to identify a read-only script request by, honouring the command map. The + /// server-version half of the decision cannot happen here - we do not know yet which server this + /// will go to - so that is resolved at write time; see CanUseReadOnlyScripts. + /// + /// + /// When we fall back, the retry category is pinned to the read-only one first. EVAL_RO defaults to + /// CommandRetryReadOnly and EVAL to CommandRetryWriteAccumulating, so simply swapping the command + /// would quietly make a script the caller asked for read-only retry like a write. Falling back is + /// about what the server will accept, not about what the caller asked for. + /// + /// + /// For tests: build the message a read-only script request would use, without sending it. + /// + internal Message GetReadOnlyScriptMessageForTests(string script, CommandFlags flags) + { + var command = ForReadOnlyScript( + ResultProcessor.ScriptLoadProcessor.IsSHA1(script) ? RedisCommand.EVALSHA_RO : RedisCommand.EVAL_RO, ref flags); + return new ScriptEvaluateMessage(Database, flags, command, script, null, null); + } + + private RedisCommand ForReadOnlyScript(RedisCommand readOnlyCommand, ref CommandFlags flags) + { + // both, for the same reason CanUseReadOnlyScripts wants both: hash-vs-script is decided later + var map = multiplexer.CommandMap; + if (map.IsAvailable(RedisCommand.EVAL_RO) && map.IsAvailable(RedisCommand.EVALSHA_RO)) + { + return readOnlyCommand; + } + + flags = flags.WithCategory(CommandFlags.CommandRetryReadOnly); + return readOnlyCommand == RedisCommand.EVALSHA_RO ? RedisCommand.EVALSHA : RedisCommand.EVAL; + } + public RespResult ScriptEvaluateReadOnlyResp(string script, ReadOnlyMemory keys, ReadOnlyMemory values, CommandFlags flags = CommandFlags.None) { - var command = ResultProcessor.ScriptLoadProcessor.IsSHA1(script) ? RedisCommand.EVALSHA_RO : RedisCommand.EVAL_RO; + var command = ForReadOnlyScript( + ResultProcessor.ScriptLoadProcessor.IsSHA1(script) ? RedisCommand.EVALSHA_RO : RedisCommand.EVAL_RO, ref flags); var msg = new ScriptEvalMessage(Database, flags, command, script, keys, values); try { @@ -2146,7 +2181,8 @@ public RespResult ScriptEvaluateReadOnlyResp(string script, ReadOnlyMemory ScriptEvaluateReadOnlyRespAsync(string script, ReadOnlyMemory keys, ReadOnlyMemory values, CommandFlags flags = CommandFlags.None) { - var command = ResultProcessor.ScriptLoadProcessor.IsSHA1(script) ? RedisCommand.EVALSHA_RO : RedisCommand.EVAL_RO; + var command = ForReadOnlyScript( + ResultProcessor.ScriptLoadProcessor.IsSHA1(script) ? RedisCommand.EVALSHA_RO : RedisCommand.EVAL_RO, ref flags); var msg = new ScriptEvalMessage(Database, flags, command, script, keys, values); try { @@ -2182,7 +2220,8 @@ public async Task ScriptEvaluateReadOnlyRespAsync(string script, Rea public async Task ScriptEvaluateReadOnlyAsync(string script, RedisKey[]? keys = null, RedisValue[]? values = null, CommandFlags flags = CommandFlags.None) { - var command = ResultProcessor.ScriptLoadProcessor.IsSHA1(script) ? RedisCommand.EVALSHA_RO : RedisCommand.EVAL_RO; + var command = ForReadOnlyScript( + ResultProcessor.ScriptLoadProcessor.IsSHA1(script) ? RedisCommand.EVALSHA_RO : RedisCommand.EVAL_RO, ref flags); var msg = new ScriptEvaluateMessage(Database, flags, command, script, keys, values); try { @@ -2197,7 +2236,8 @@ public async Task ScriptEvaluateReadOnlyAsync(string script, RedisK public Task ScriptEvaluateReadOnlyAsync(byte[] hash, RedisKey[]? keys = null, RedisValue[]? values = null, CommandFlags flags = CommandFlags.None) { - var msg = new ScriptEvaluateMessage(Database, flags, RedisCommand.EVALSHA_RO, hash, keys, values); + var command = ForReadOnlyScript(RedisCommand.EVALSHA_RO, ref flags); + var msg = new ScriptEvaluateMessage(Database, flags, command, hash, keys, values); return ExecuteAsync(msg, ResultProcessor.ScriptResult, defaultValue: RedisResult.NullSingle); } @@ -6232,12 +6272,39 @@ protected override bool TryGetSubCommand(out SubCommand subCommand) } } + /// + /// Whether the read-only script commands can be used against this connection. EVAL_RO/EVALSHA_RO + /// need server 7.0+, and - like any command - can be disabled or renamed via the command map; when + /// either does not hold we fall back to plain EVAL/EVALSHA, which is what the read-only APIs have + /// always sent in practice. + /// + /// + /// Both are required, not just the one we expect to send: whether a given attempt writes EVAL_RO or + /// EVALSHA_RO depends on whether the script's hash is cached at the moment of writing, which can + /// differ between the first attempt and a retry. + /// + private static bool CanUseReadOnlyScripts(PhysicalConnection connection) + { + if (connection.BridgeCouldBeNull is not { } bridge) return false; + var map = bridge.Multiplexer.CommandMap; + return bridge.ServerEndPoint.GetFeatures().ReadOnlyScripts + && map.IsAvailable(RedisCommand.EVAL_RO) + && map.IsAvailable(RedisCommand.EVALSHA_RO); + } + + /// + /// Indicates whether this message came from one of the read-only script APIs. + /// + private static bool IsReadOnlyScript(RedisCommand command) + => command is RedisCommand.EVAL_RO or RedisCommand.EVALSHA_RO; + private sealed class ScriptEvalMessage : Message, IMultiMessage { private readonly ReadOnlyMemory _keys; private readonly ReadOnlyMemory _values; private readonly string _script; private byte[]? asciiHash; + private bool useReadOnly; public ScriptEvalMessage(int db, CommandFlags flags, RedisCommand command, string script, ReadOnlyMemory keys, ReadOnlyMemory values) : base(db, flags, command) { @@ -6258,6 +6325,9 @@ public override int GetHashSlot(ServerSelectionStrategy serverSelectionStrategy) public IEnumerable GetMessages(PhysicalConnection connection) { + // resolved per connection, and re-resolved if we end up talking to a different server + useReadOnly = IsReadOnlyScript(command) && CanUseReadOnlyScripts(connection); + PhysicalBridge? bridge; if ((bridge = connection.BridgeCouldBeNull) != null && bridge.Multiplexer.CommandMap.IsAvailable(RedisCommand.SCRIPT) @@ -6281,12 +6351,12 @@ protected override void WriteImpl(in MessageWriter writer) { if (asciiHash != null) { - writer.WriteHeader(RedisCommand.EVALSHA, 2 + _keys.Length + _values.Length); + writer.WriteHeader(useReadOnly ? RedisCommand.EVALSHA_RO : RedisCommand.EVALSHA, 2 + _keys.Length + _values.Length); writer.WriteBulkString(asciiHash); } else { - writer.WriteHeader(RedisCommand.EVAL, 2 + _keys.Length + _values.Length); + writer.WriteHeader(useReadOnly ? RedisCommand.EVAL_RO : RedisCommand.EVAL, 2 + _keys.Length + _values.Length); writer.WriteBulkString(_script); } @@ -6312,6 +6382,7 @@ private sealed class ScriptEvaluateMessage : Message, IMultiMessage private readonly RedisValue[] values; private byte[]? asciiHash; private readonly byte[]? hexHash; + private bool useReadOnly; public ScriptEvaluateMessage(int db, CommandFlags flags, RedisCommand command, string script, RedisKey[]? keys, RedisValue[]? values) : this(db, flags, command, script, null, keys, values) @@ -6342,6 +6413,9 @@ private ScriptEvaluateMessage(int db, CommandFlags flags, RedisCommand command, public IEnumerable GetMessages(PhysicalConnection connection) { + // resolved per connection, and re-resolved if we end up talking to a different server + useReadOnly = IsReadOnlyScript(command) && CanUseReadOnlyScripts(connection); + PhysicalBridge? bridge; if (script != null && (bridge = connection.BridgeCouldBeNull) != null && bridge.Multiplexer.CommandMap.IsAvailable(RedisCommand.SCRIPT) @@ -6365,17 +6439,17 @@ protected override void WriteImpl(in MessageWriter writer) { if (hexHash != null) { - writer.WriteHeader(RedisCommand.EVALSHA, 2 + keys.Length + values.Length); + writer.WriteHeader(useReadOnly ? RedisCommand.EVALSHA_RO : RedisCommand.EVALSHA, 2 + keys.Length + values.Length); writer.WriteSha1AsHex(hexHash); } else if (asciiHash != null) { - writer.WriteHeader(RedisCommand.EVALSHA, 2 + keys.Length + values.Length); + writer.WriteHeader(useReadOnly ? RedisCommand.EVALSHA_RO : RedisCommand.EVALSHA, 2 + keys.Length + values.Length); writer.WriteBulkString(asciiHash); } else { - writer.WriteHeader(RedisCommand.EVAL, 2 + keys.Length + values.Length); + writer.WriteHeader(useReadOnly ? RedisCommand.EVAL_RO : RedisCommand.EVAL, 2 + keys.Length + values.Length); writer.WriteBulkString(script); } writer.WriteBulkString(keys.Length); diff --git a/src/StackExchange.Redis/RedisFeatures.cs b/src/StackExchange.Redis/RedisFeatures.cs index 9bfe8b27c..68af9fa26 100644 --- a/src/StackExchange.Redis/RedisFeatures.cs +++ b/src/StackExchange.Redis/RedisFeatures.cs @@ -164,6 +164,12 @@ public RedisFeatures(Version version) /// internal bool ReadOnlySort => Version.IsAtLeast(v7_0_0_rc1); + /// + /// Does this support EVAL_RO and + /// EVALSHA_RO? + /// + internal bool ReadOnlyScripts => Version.IsAtLeast(v7_0_0_rc1); + /// /// Is SCAN (cursor-based scanning) available? /// diff --git a/src/StackExchange.Redis/ServerEndPoint.cs b/src/StackExchange.Redis/ServerEndPoint.cs index 17d37a5c0..e2846c687 100644 --- a/src/StackExchange.Redis/ServerEndPoint.cs +++ b/src/StackExchange.Redis/ServerEndPoint.cs @@ -793,7 +793,7 @@ internal string GetProfile() internal byte[]? GetScriptHash(string script, RedisCommand command) { var found = (byte[]?)knownScripts[script]; - if (found == null && command == RedisCommand.EVALSHA) + if (found == null && (command == RedisCommand.EVALSHA || command == RedisCommand.EVALSHA_RO)) { // The script provided is a hex SHA - store and re-use the ASCii for that found = Encoding.ASCII.GetBytes(script); diff --git a/tests/StackExchange.Redis.Tests/ScriptReadOnlyCommandTests.cs b/tests/StackExchange.Redis.Tests/ScriptReadOnlyCommandTests.cs new file mode 100644 index 000000000..3e6880b25 --- /dev/null +++ b/tests/StackExchange.Redis.Tests/ScriptReadOnlyCommandTests.cs @@ -0,0 +1,122 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using Xunit; + +namespace StackExchange.Redis.Tests; + +/// +/// The read-only script APIs should put EVAL_RO/EVALSHA_RO on the wire where the server and command map +/// allow it, and fall back to EVAL/EVALSHA where they do not. +/// +/// +/// Asserting which command was actually sent is done by renaming it in the command map to something the +/// server does not implement: if we chose the read-only form, the server complains about the bogus name; +/// if we fell back, the script simply runs. A replica cannot be used to tell these apart - a modern server +/// accepts a plain EVAL on a replica so long as the script does not write. +/// +public class ScriptReadOnlyCommandTests(ITestOutputHelper output) : TestBase(output) +{ + private const string Script = "return 1"; + private const string BogusEvalRo = "EVAL_RO_DOES_NOT_EXIST"; + private const string BogusEvalShaRo = "EVALSHA_RO_DOES_NOT_EXIST"; + + // TestBase.Create has no command-map hook, so build this one directly + private async Task CreateRenamedAsync() + { + var config = ConfigurationOptions.Parse(GetConfiguration()); + config.AllowAdmin = true; + config.CommandMap = CommandMap.Create(new Dictionary + { + ["EVAL_RO"] = BogusEvalRo, + ["EVALSHA_RO"] = BogusEvalShaRo, + }); + var conn = await ConnectionMultiplexer.ConnectAsync(config); + var version = conn.GetServer(conn.GetEndPoints()[0]).Version; + Assert.SkipUnless(new RedisFeatures(version).ReadOnlyScripts, $"Requires server 7.0+, but server is {version}."); + return conn; + } + + [Fact] + public async Task ReadOnlyEval_UsesEvalRo() + { + await using var conn = await CreateRenamedAsync(); + var db = conn.GetDatabase(); + + // NoScriptCache keeps us on the non-hash path, so this is unambiguously the EVAL_RO decision + var ex = Assert.Throws( + () => db.ScriptEvaluateReadOnly(Script, flags: CommandFlags.NoScriptCache)); + Assert.Contains(BogusEvalRo, ex.Message); + } + + [Fact] + public async Task ReadOnlyEvalResp_UsesEvalRo() + { + await using var conn = await CreateRenamedAsync(); + var db = conn.GetDatabase(); + + var ex = Assert.Throws( + () => db.ScriptEvaluateReadOnlyResp(Script, default, default, CommandFlags.NoScriptCache)); + Assert.Contains(BogusEvalRo, ex.Message); + } + + [Fact] + public async Task ReadOnlyEval_UsesEvalShaRo_OnceTheHashIsKnown() + { + await using var conn = await CreateRenamedAsync(); + var db = conn.GetDatabase(); + + // first call loads the script and caches its hash; the call itself still goes out as EVAL_RO + Assert.Throws(() => db.ScriptEvaluateReadOnly(Script)); + + // now the hash is known, so the second attempt takes the EVALSHA_RO path + var ex = Assert.Throws(() => db.ScriptEvaluateReadOnly(Script)); + Assert.Contains(BogusEvalShaRo, ex.Message); + } + + [Fact] + public async Task WritableEval_IsUnaffected() + { + // the renaming above only touches the read-only forms; a normal ScriptEvaluate must be untouched + await using var conn = await CreateRenamedAsync(); + var db = conn.GetDatabase(); + Assert.Equal(1, (long)db.ScriptEvaluate(Script, flags: CommandFlags.NoScriptCache)); + } + + [Fact] + public async Task FallsBackToEval_WhenReadOnlyCommandsAreDisabled() + { + await using var conn = Create(disabledCommands: ["eval_ro", "evalsha_ro"]); + var db = conn.GetDatabase(); + + // disabled rather than renamed: we must quietly use EVAL/EVALSHA instead of failing + Assert.Equal(1, (long)db.ScriptEvaluateReadOnly(Script, flags: CommandFlags.NoScriptCache)); + Assert.Equal(1, (long)db.ScriptEvaluateReadOnly(Script)); + using var resp = db.ScriptEvaluateReadOnlyResp(Script, default, default); + Assert.Equal(1, (long)resp.ReadScalar().ReadRedisValue()); + } + + [Fact] + public async Task FallingBackKeepsTheReadOnlyRetryCategory() + { + // EVAL_RO defaults to CommandRetryReadOnly and EVAL to CommandRetryWriteAccumulating, so a + // fallback that just swapped the command would quietly change how the call retries + await using var conn = Create(disabledCommands: ["eval_ro", "evalsha_ro"]); + var db = (RedisDatabase)conn.GetDatabase(); + + var msg = db.GetReadOnlyScriptMessageForTests("return 1", CommandFlags.None); + Assert.Equal(RedisCommand.EVAL, msg.Command); // fell back... + Assert.Equal(CommandFlags.CommandRetryReadOnly, msg.Flags & Message.MaskRetryCategory); // ...but still read-only + + // and an explicit category from the caller still wins over both + var explicitly = db.GetReadOnlyScriptMessageForTests("return 1", CommandFlags.CommandRetryNever); + Assert.Equal(CommandFlags.CommandRetryNever, explicitly.Flags & Message.MaskRetryCategory); + } + + [Fact] + public void ReadOnlyScriptsRequireServer7() + { + Assert.False(new RedisFeatures(new System.Version(6, 2, 0)).ReadOnlyScripts); + Assert.True(new RedisFeatures(RedisFeatures.v7_0_0_rc1).ReadOnlyScripts); + Assert.True(new RedisFeatures(new System.Version(7, 0, 0)).ReadOnlyScripts); + } +} From c4107dffc5ec147efcefaaf849866b80c4917eff Mon Sep 17 00:00:00 2001 From: mgravell Date: Wed, 9 Sep 2026 11:26:22 +0100 Subject: [PATCH 17/17] Test the read-only fallback through the helper, not a test-only seam The retry category a fallback ends up with is message state - it never reaches the wire and changes no reply - so it was asserted via an internal GetReadOnlyScriptMessageForTests that built a message without sending it. That is a test-only method on a shipping type, and it constructed a message shape no real call site produces. ForReadOnlyScript only ever wanted the command map, so make it a static taking one; the test then calls it directly and asserts the command and the flags it sets, with no seam and no synthetic message. It also lands closer to the logic: the previous test could only observe the outcome through a message. Testing the helper alone would stop short of the reason the pin matters - that Message's own defaulting leaves an already-chosen category alone - so that link is now asserted explicitly rather than implied. Also reunites ForReadOnlyScript with its doc comment, which the seam had been sitting in front of. --- src/StackExchange.Redis/RedisDatabase.cs | 33 +++++++------- .../ScriptReadOnlyCommandTests.cs | 44 ++++++++++++++----- 2 files changed, 47 insertions(+), 30 deletions(-) diff --git a/src/StackExchange.Redis/RedisDatabase.cs b/src/StackExchange.Redis/RedisDatabase.cs index 286ef5904..a5148ee57 100644 --- a/src/StackExchange.Redis/RedisDatabase.cs +++ b/src/StackExchange.Redis/RedisDatabase.cs @@ -2140,20 +2140,9 @@ public Task ScriptEvaluateAsync(LoadedLuaScript script, object? par /// would quietly make a script the caller asked for read-only retry like a write. Falling back is /// about what the server will accept, not about what the caller asked for. /// - /// - /// For tests: build the message a read-only script request would use, without sending it. - /// - internal Message GetReadOnlyScriptMessageForTests(string script, CommandFlags flags) - { - var command = ForReadOnlyScript( - ResultProcessor.ScriptLoadProcessor.IsSHA1(script) ? RedisCommand.EVALSHA_RO : RedisCommand.EVAL_RO, ref flags); - return new ScriptEvaluateMessage(Database, flags, command, script, null, null); - } - - private RedisCommand ForReadOnlyScript(RedisCommand readOnlyCommand, ref CommandFlags flags) + internal static RedisCommand ForReadOnlyScript(CommandMap map, RedisCommand readOnlyCommand, ref CommandFlags flags) { // both, for the same reason CanUseReadOnlyScripts wants both: hash-vs-script is decided later - var map = multiplexer.CommandMap; if (map.IsAvailable(RedisCommand.EVAL_RO) && map.IsAvailable(RedisCommand.EVALSHA_RO)) { return readOnlyCommand; @@ -2166,7 +2155,9 @@ private RedisCommand ForReadOnlyScript(RedisCommand readOnlyCommand, ref Command public RespResult ScriptEvaluateReadOnlyResp(string script, ReadOnlyMemory keys, ReadOnlyMemory values, CommandFlags flags = CommandFlags.None) { var command = ForReadOnlyScript( - ResultProcessor.ScriptLoadProcessor.IsSHA1(script) ? RedisCommand.EVALSHA_RO : RedisCommand.EVAL_RO, ref flags); + multiplexer.CommandMap, + ResultProcessor.ScriptLoadProcessor.IsSHA1(script) ? RedisCommand.EVALSHA_RO : RedisCommand.EVAL_RO, + ref flags); var msg = new ScriptEvalMessage(Database, flags, command, script, keys, values); try { @@ -2182,7 +2173,9 @@ public RespResult ScriptEvaluateReadOnlyResp(string script, ReadOnlyMemory ScriptEvaluateReadOnlyRespAsync(string script, ReadOnlyMemory keys, ReadOnlyMemory values, CommandFlags flags = CommandFlags.None) { var command = ForReadOnlyScript( - ResultProcessor.ScriptLoadProcessor.IsSHA1(script) ? RedisCommand.EVALSHA_RO : RedisCommand.EVAL_RO, ref flags); + multiplexer.CommandMap, + ResultProcessor.ScriptLoadProcessor.IsSHA1(script) ? RedisCommand.EVALSHA_RO : RedisCommand.EVAL_RO, + ref flags); var msg = new ScriptEvalMessage(Database, flags, command, script, keys, values); try { @@ -2221,7 +2216,9 @@ public async Task ScriptEvaluateReadOnlyRespAsync(string script, Rea public async Task ScriptEvaluateReadOnlyAsync(string script, RedisKey[]? keys = null, RedisValue[]? values = null, CommandFlags flags = CommandFlags.None) { var command = ForReadOnlyScript( - ResultProcessor.ScriptLoadProcessor.IsSHA1(script) ? RedisCommand.EVALSHA_RO : RedisCommand.EVAL_RO, ref flags); + multiplexer.CommandMap, + ResultProcessor.ScriptLoadProcessor.IsSHA1(script) ? RedisCommand.EVALSHA_RO : RedisCommand.EVAL_RO, + ref flags); var msg = new ScriptEvaluateMessage(Database, flags, command, script, keys, values); try { @@ -2236,7 +2233,7 @@ public async Task ScriptEvaluateReadOnlyAsync(string script, RedisK public Task ScriptEvaluateReadOnlyAsync(byte[] hash, RedisKey[]? keys = null, RedisValue[]? values = null, CommandFlags flags = CommandFlags.None) { - var command = ForReadOnlyScript(RedisCommand.EVALSHA_RO, ref flags); + var command = ForReadOnlyScript(multiplexer.CommandMap, RedisCommand.EVALSHA_RO, ref flags); var msg = new ScriptEvaluateMessage(Database, flags, command, hash, keys, values); return ExecuteAsync(msg, ResultProcessor.ScriptResult, defaultValue: RedisResult.NullSingle); } diff --git a/tests/StackExchange.Redis.Tests/ScriptReadOnlyCommandTests.cs b/tests/StackExchange.Redis.Tests/ScriptReadOnlyCommandTests.cs index 3e6880b25..27bd50bc9 100644 --- a/tests/StackExchange.Redis.Tests/ScriptReadOnlyCommandTests.cs +++ b/tests/StackExchange.Redis.Tests/ScriptReadOnlyCommandTests.cs @@ -96,20 +96,40 @@ public async Task FallsBackToEval_WhenReadOnlyCommandsAreDisabled() } [Fact] - public async Task FallingBackKeepsTheReadOnlyRetryCategory() + public void FallingBackKeepsTheReadOnlyRetryCategory() { // EVAL_RO defaults to CommandRetryReadOnly and EVAL to CommandRetryWriteAccumulating, so a - // fallback that just swapped the command would quietly change how the call retries - await using var conn = Create(disabledCommands: ["eval_ro", "evalsha_ro"]); - var db = (RedisDatabase)conn.GetDatabase(); - - var msg = db.GetReadOnlyScriptMessageForTests("return 1", CommandFlags.None); - Assert.Equal(RedisCommand.EVAL, msg.Command); // fell back... - Assert.Equal(CommandFlags.CommandRetryReadOnly, msg.Flags & Message.MaskRetryCategory); // ...but still read-only - - // and an explicit category from the caller still wins over both - var explicitly = db.GetReadOnlyScriptMessageForTests("return 1", CommandFlags.CommandRetryNever); - Assert.Equal(CommandFlags.CommandRetryNever, explicitly.Flags & Message.MaskRetryCategory); + // fallback that only swapped the command would quietly change how the call retries. This is + // message state rather than anything that reaches the wire, so it is asserted directly. + var unavailable = CommandMap.Create(["eval_ro", "evalsha_ro"], available: false); + + var flags = CommandFlags.None; + var command = RedisDatabase.ForReadOnlyScript(unavailable, RedisCommand.EVAL_RO, ref flags); + Assert.Equal(RedisCommand.EVAL, command); // fell back... + Assert.Equal(CommandFlags.CommandRetryReadOnly, flags & Message.MaskRetryCategory); // ...but still read-only + + flags = CommandFlags.None; + command = RedisDatabase.ForReadOnlyScript(unavailable, RedisCommand.EVALSHA_RO, ref flags); + Assert.Equal(RedisCommand.EVALSHA, command); + Assert.Equal(CommandFlags.CommandRetryReadOnly, flags & Message.MaskRetryCategory); + + // an explicit category from the caller still wins over the fallback's + flags = CommandFlags.CommandRetryNever; + RedisDatabase.ForReadOnlyScript(unavailable, RedisCommand.EVAL_RO, ref flags); + Assert.Equal(CommandFlags.CommandRetryNever, flags & Message.MaskRetryCategory); + + // and where the commands are available, the read-only form is kept as-is + flags = CommandFlags.None; + Assert.Equal(RedisCommand.EVAL_RO, RedisDatabase.ForReadOnlyScript(CommandMap.Default, RedisCommand.EVAL_RO, ref flags)); + + // the pin above is only worth anything because Message's own defaulting leaves an already-chosen + // category alone; without that, EVAL's default would overwrite it right back to write-accumulating + Assert.Equal( + CommandFlags.CommandRetryReadOnly, + CommandFlags.CommandRetryReadOnly.WithDefaultCategory(RedisCommand.EVAL) & Message.MaskRetryCategory); + Assert.Equal( + CommandFlags.CommandRetryWriteAccumulating, + CommandFlags.None.WithDefaultCategory(RedisCommand.EVAL) & Message.MaskRetryCategory); } [Fact]