add ReadOnlyMemory<RedisKeyOrValue> and Lease<byte> for Lua and Execute - #3201
Conversation
* 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
|
There's a couple of reasons I've been deferring on this for a little bit... If we're thinking "efficiency", there's the problem of the return value, and managing it in a way that is flexible and efficient. I'm not quite ready to release it on the world yet, but a large part of the IO rewrite is "yet to come", and explicitly targets flexible read / write custom command scenarios, including Lua (but also any ad-hoc commands). I'm torn between compromising on an eval[sha] now, bs giving you the real thing "soon" - trust me, it'll be much easier than this. Thoughts? |
|
I understand that you want to give the maximum. But the perfect is the enemy of the good. The solution I propose will completely satisfy many and will be a compromise. For most tasks, the current solution will be sufficient. But I understand it's not perfect. I'd really appreciate your consideration of my merge request. It's a good temporary compromise. |
|
I'm currently in dire need of the ScriptEvaluateLease and ExecuteLease methods. If you don't like the idea of introducing a new RedisKeyOrValue or ScriptRequestDisposer type, I'm willing to live with their absence. I am willing to listen to your terms on which you would agree to add methods with a Lease return. I apologize for being stubborn, I have been waiting for these changes for a very long time. |
|
I'll try to look today. Balancing competing needs is always tricky. I'm not against reasonable APIs. The return value is the trickiest bit, since this can technically be an arbitrary tree of different data types. Thinking outside of the box: how might a lease + RespReader work for you? I.e. the lease isn't a value, but an API over the tree? If you're accessing a scalar or BLOB: this is then one line to get the blob, and I could provide helper APIs for those. |
|
I understand that the
Returning lease + RespReader would be a great solution! After the review, please give me your opinion on my idea with |
|
OK; I can see that I have "push" access; if it is OK with you, I'm going to take one half of this (the input API, i.e. |
|
Making good progress locally; just a heads-up: I need to remove the |
…espResult 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<RedisKey>, ReadOnlyMemory<RedisValue>) since Lua's KEYS/ARGV never interleave; ExecuteResp keeps ReadOnlyMemory<RedisKeyOrValue> 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<RedisKey>, ReadOnlyMemory<RedisKeyOrValue>) 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<T> (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 <pairbit@gmail.com>
|
Hi Ivan - thanks for this, the underlying idea (typed key/value args, a low-allocation reply path for EVAL and ad-hoc commands) is exactly right and is the reason I wanted to get this in. I've pushed a single commit to this branch that reworks the implementation pretty much top to bottom, so I want to be upfront about the scope before you look: the request-side shape survives (RedisKeyOrValue, keys-and-values-through-EVAL), but the response side, the RedisKeyOrValue internals, the public API names, and the Highlights:
Full details are in the commit message. Would genuinely like your take - on the API shape, anything I've missed from the original design intent, or anywhere you think this went the wrong way. The tests deliberately include examples related to the "lease" scenario - both using [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));
// ^^^ you wouldn't do this in real code - there is a reader.ReadString() method!
}
[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()]; // in reality: your own lease
var copied = reader.CopyTo(buffer);
Assert.Equal("hello world", Encoding.UTF8.GetString(buffer, 0, copied));
// ^^^ you wouldn't do this in real code - there is a reader.ReadString() method!
} |
# Conflicts: # src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt
|
Excellent solution. I couldn't have asked for anything better. I have a question. var values = ArrayPool<RedisValue>.Shared.Rent(1);
values[0] = "myvalue";
var canReturn = true;
try
{
using RespResult result = db.ScriptEvaluateResp(script, values: values.AsMemory(0, 1));
// use result...
}
catch (RedisServerException)
{
throw;
}
catch // I don't like it
{
canReturn = false;
throw;
}
finally
{
if (canReturn) ArrayPool<RedisValue>.Shared.Return(values, clearArray: true);
}I don't like |
|
I don't like that i have to rent the buffer twice to get a using var respResult = await db.ScriptEvaluateRespAsync(script, keys, values)The second time we rent to get the value. if (result.Prefix == RESPite.Messages.RespPrefix.BulkString)
{
using var lease = result.ReadScalar().ReadLease();
}For large blobs this may not be effective. Task<IMemoryOwner<byte>> RunScript()
{
using RespResult result = db.ScriptEvaluateResp(script, keys, values);
return result.AsBulkString(); //or AsScalar();
}What do you say? |
|
I say that's technically impossible. A strict interpretation of RESP3 means that a payload body does not need to be a single contiguous chunk - it can be streamed. This means at a minimum we'd need to consider ReadOnlySequence-byte. Unfortunately, ReadOnlySequence-byte doesn't have a well-understood lifetime metaphor. However, in reality it almost certainly will be contiguous, and there are APIs to fetch that directly - for example, I believe TryGetSpan and various inline parse methods are zero copy. It is also important to note that we need to avoid ambiguity over payload lifetime, which is a huge issue. |
|
Notes to self:
|
|
I was thinking about ROS. But the current RespResult implementation only uses contiguous memory blocks. I understand there's a TryGetSpan() method, but I don't want to externalize the RespResult class. Perhaps I could add TryGetScalar() that returns IMemoryOwner? |
|
I do think I can do something here with ref-counted memory, optimized for the linear case. I'll look. The calling code will need to dispose each, but it'll avoid a copy/lease unless it is genuinely discontiguous. |
I think we're near the same page; I can make something work - I have an idea. Technically it won't be a strict guarantee to not do an extra copy, but in reality it always will (not do an extra copy). So: yes, I can improve the current PR state that always does an extra copy. |
Perfect reminder for me: I need to make the docs lean people towards |
I don't disagree. When I finish the write half of the IO core rewrite, the answer will be "always". But I'll have a bit more of a think. |
You misunderstand me. The payload itself can be non-contiguous inside a contiguous buffer - split over multiple fragments. At least, theoretically. I don't think any real server actually ever returns streamed payloads, though! |
Thanks for the clarification. I didn't know about this possibility. |
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<byte> rather than a plain IMemoryOwner<byte> 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<T> 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<T> is otherwise untouched - it gains an offset, and its existing Dispose already does the right thing, because ((IMemoryOwner<T>)buffer).Dispose() lands on the manager's explicit Dispose, i.e. a release. Overriding MemoryManager<T>.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<byte>.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<byte>-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.
2c9699e to
8cc8a7c
Compare
|
I've already looked at your commit. It's amazing. Remember when I mentioned the place where you lianize ROS? private void OnResponseFrame(RespPrefix prefix, ReadOnlySequence<byte> payload)
{
if (payload.IsSingleSegment)
{
OnResponseFrame(prefix, payload.FirstSpan, ref SharedNoLease);
}
else
{
var len = checked((int)payload.Length);
var memoryPool = BridgeCouldBeNull?.Multiplexer.RawConfig.ResponseBufferPool ?? MemoryPool<byte>.Shared;
var memoryOwner = memoryPool.Rent(len);
Span<byte> oversized = memoryOwner.Memory.Span.Slice(0, len);
payload.CopyTo(oversized);
// set buffer in RespReader
OnResponseFrame(prefix, oversized, ref memoryOwner);
memoryOwner?.Dispose();
}
} |
|
Are these final changes? When is the merger planned? |
|
Sorry, long weekend, looking again now!
That's probably a step too far, or at least one to defer until we have the full end-to-end IO v3 core working - it would mix two buffer pools with delicate handling. For now I'd rather focus on reliability.
I need to think a little bit more about the inbound memory lifetime - that's my job for today. |
# Conflicts: # src/StackExchange.Redis/PhysicalConnection.cs
…lly 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.
…spPrefix 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.
…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.
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.
Three consumer-visible changes in this cycle that a build bump would under-signal: - net6.0 dropped from TargetFrameworks (#3208). Anyone still targeting it does not get a worse version, they get no version. - System.IO.Hashing's declared floor moved from 10.0.5 to 10.0.12 (#3210), on every target framework. - ScriptEvaluateReadOnly now genuinely sends EVAL_RO where the server and command map allow it, so a script that *writes* on that API starts being rejected on 7.0+ where plain EVAL used to let it through. Plus the new surface from #3201 - RespResult, RedisKeyOrValue, the *Resp family, and the un-gated RespReader API - which is additive but substantial. assemblyVersion stays at 3.0 deliberately: it is decoupled from the package version precisely so that a minor bump is not a binding break, and moving it would be one for anyone with a compiled reference or a binding redirect. Confirmed against a local pack - package 3.2.0, assembly still 3.0.0.0. versionHeightOffset went with it: it was scoped to 3.1 via versionHeightOffsetAppliesTo, so it stopped applying the moment the version changed. Verified the computed version is identical with and without it.
(outdated)
Tasks:
ReadOnlyMemory<RedisKeyOrValue>API for LuaLease<byte>?API for LuaScriptEvaluateReadOnlyAsyncagain if the script does not exist.ReadOnlyMemory<RedisKeyOrValue>API for ExecuteLease<byte>?API for ExecuteLinks
#2346
#2843
#2844
Please consider this PR, this issue has been raised for a long time