add ReadOnlyMemory<RedisKeyOrValue> and Lease<byte> for Lua and Execute - #3201
add ReadOnlyMemory<RedisKeyOrValue> and Lease<byte> for Lua and Execute#3201pairbit wants to merge 14 commits into
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 <[email protected]>
|
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();
}
} |
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