Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
33de0eb
Lua memory (#6)
pairbit Aug 31, 2026
348f274
typo !=
pairbit Aug 31, 2026
8fc9da6
Keys must come before values.
pairbit Aug 31, 2026
a8a31b9
Exception message fix
pairbit Aug 31, 2026
429dac8
ToInnerCopy if hasKeys
pairbit Aug 31, 2026
422e748
add rent args for key-prefix path
pairbit Aug 31, 2026
10fb7ad
renaming ScriptEval
pairbit Aug 31, 2026
aaf4bde
add IRequestDisposer, Exec, ExecLease
pairbit Aug 31, 2026
dfd611e
args is default to ScriptEval
pairbit Aug 31, 2026
3558ca2
You cannot return an array twice or an array that is not from the pool.
pairbit Aug 31, 2026
2631112
internal RedisKeyOrValue.StorageType
pairbit Sep 2, 2026
42f8e9d
Rework the Lua/ad-hoc command response path around a low-allocation R…
mgravell Sep 3, 2026
10b169f
Merge branch 'main' into lua-memory-pr-push
mgravell Sep 3, 2026
8cc8a7c
Share the reply buffer with ReadLease instead of copying out of it
mgravell Sep 4, 2026
b42843b
Merge remote-tracking branch 'origin/main' into lua-memory-pr-push2
mgravell Sep 8, 2026
e75297d
Handle NOSCRIPT on the RespResult script path, so its retry can actua…
mgravell Sep 8, 2026
95a3ff6
Docs: steer readers to IsScalar/IsAggregate rather than a specific Re…
mgravell Sep 8, 2026
f8d9c61
Send EVAL_RO/EVALSHA_RO for the read-only script APIs, where that is …
mgravell Sep 8, 2026
c4107df
Test the read-only fallback through the helper, not a test-only seam
mgravell Sep 9, 2026
2327803
Merge remote-tracking branch 'origin/main' into marc/3201-refcounted-…
mgravell Sep 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 84 additions & 0 deletions docs/Execute.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
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<object>`-based overload, kept for compatibility.

Basic use
---

`ExecuteResp` takes the command name and a single `ReadOnlyMemory<RedisKeyOrValue>` 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`/`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
---

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<RedisKeyOrValue>.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<RedisKeyOrValue>.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<RedisKeyOrValue>.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<object> 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.
140 changes: 105 additions & 35 deletions docs/Scripting.md
Original file line number Diff line number Diff line change
@@ -1,59 +1,129 @@
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<RedisKey>`/`ReadOnlyMemory<RedisValue>` 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.

using (ConnectionMultiplexer conn = /* init code */)
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):

```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);

var prepared = LuaScript.Prepare(Script);
db.ScriptEvaluate(prepared, new { key = (RedisKey)"mykey", value = 123 });
// 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<byte>? lease = reader.ReadLease();

// 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.
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<byte>`-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 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

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:
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:

- int(?)
- long(?)
- double(?)
- string
- byte[]
- bool(?)
- RedisKey
- RedisValue
```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 */ }
```

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.
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.

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.
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)`.

An example use of `LoadedLuaScript`:
`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:

```csharp
const string Script = "redis.call('set', @key, @value)";
using RespResult result = db.ScriptEvaluateResp("return {1,2,'three'}", default, default);
RedisResult tree = result.Read().ReadRedisResult();
var values = (RedisValue[])tree!;
```

using (ConnectionMultiplexer conn = /* init code */)
{
var db = conn.GetDatabase(0);
var server = conn.GetServer(/* appropriate parameters*/);
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:

var prepared = LuaScript.Prepare(Script);
var loaded = prepared.Load(server);
loaded.Evaluate(db, new { key = (RedisKey)"mykey", value = 123 });
```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
```

If you just want the whole aggregate as a typed array via a projection, without the manual loop, `RespReader.ReadPastArray<TResult>` (or its non-mutating twin `ReadArray<TResult>`) 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
RedisValue[]? values = parent.ReadPastArray(static (ref r) => r.ReadRedisValue(), scalar: true);
```

This is equivalent to the manual loop above, just without needing to write it out yourself, and capturing the results as an array.

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<RedisKeyOrValue>` 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.
4 changes: 4 additions & 0 deletions docs/exp/SER004.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>`, 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
Expand Down
Loading
Loading