For ROM-based APIs (mostly ScriptEvaluate/Execute): render request keys and values into one pooled buffer up front - #3211
Conversation
First half only - the codec and its tests. Nothing is wired to it yet. Keys and values arrive as caller-owned memory, but the request is not necessarily written before the call returns: it can sit in the backlog or in a batch, and a fire-and-forget caller gets no completion signal at all. So a caller who rents an args array and returns it to the pool afterwards is racing the writer, and loses silently - whatever is in the recycled array by the time we get there is what goes on the wire. Rendering at the point of the call fixes that by not referring to caller memory at all past the call. It also moves the formatting off the writer thread, where today it happens while holding the single-writer lock, so it should help throughput as well as correctness - and it is the direction the IO core rewrite goes anyway. One buffer per request rather than one per argument: entries are laid out as a 4-byte length followed by that many payload bytes, with a negative length marking a key. The complement rather than the negation, so that a zero-length key stays distinguishable from a zero-length value - there is a test for exactly that, since -0 == 0 is an easy way to get this subtly wrong. RedisKey and RedisValue can both already measure and copy themselves exactly, so this needs no oversize estimate and no backfilling: measure, rent, fill. Recycle takes the state by ref on purpose. As an instance method it would compile happily against a readonly field and silently operate on a defensive copy, stranding the buffer; as a ref parameter that same mistake is CS0192 at build time. The remaining invariant - that the state must never be copied, because two owners means a double-return to the pool - cannot be enforced by the compiler and is documented on the type. Honours RequestBufferPool rather than assuming ArrayPool<byte>.Shared, matching what the write path already does.
WriteTo emits each entry as a RESP bulk string. Keys and values are indistinguishable on the wire - the flag exists for routing, not framing - so they are written identically. GetHashSlot combines the slots of the key entries only, skipping values, and hashes the rendered bytes directly. ServerSelectionStrategy.GetHashSlot has to copy each key into a stackalloc or a rented array first; we already hold exactly the bytes it would have produced, so this avoids that copy per key. The tests assert equivalence with the existing per-key routing rather than restating the expected slots: same keys through both paths, same answer, covering one key, a repeated key, hash tags, disagreeing keys, and none. Two mutation checks stand behind them - dropping the key filter fails five of them, and swapping the complement for a negation fails the zero-length case.
It only ever slices through the buffer, so ReadOnlySpan says what it means; taking the reference via MemoryMarshal.GetReference because indexing a ReadOnlySpan gives a readonly ref that ReadUnaligned will not take.
Both messages now render their arguments at construction instead of holding the caller's ReadOnlyMemory until write time, so neither refers to caller memory once the call returns. The script itself stays out of the buffer - it is needed intact for the hash lookup and SCRIPT LOAD, and being a string it carries no lifetime hazard anyway. Likewise the command name, which for a known command is not the caller's string at all: WriteHeader resolves it through the connection's CommandMap at write time, so pre-rendering would bake in the pre-rename bytes. Three knock-on changes worth calling out: Nulls are now rejected in RenderedArgs.Create rather than at write time, so the caller finds out on the call that made the mistake instead of from a background writer later. This also closes a gap: the classic ScriptEvaluateMessage asserts its keys and values are non-null, but the ReadOnlyMemory one never did. ExecMessage.TryGetSubCommand needed the first argument as a RedisValue, which is gone once the arguments are bytes, so it is resolved in the constructor and cached. Only the first argument was ever a candidate, which is all the old write-time loop did before breaking. Routing keeps the same answers by construction: the key flag in the buffer is exactly the old "is this arg a key" test, and RedisKey.CopyTo produces the same prefix+value bytes that MessageWriter.Write(in RedisKey) writes. Still not recycling: the buffers are rented and never returned, pending the completion-path analysis. That is harmless for ArrayPool<byte>.Shared, which simply lets them be collected, but a configured RequestBufferPool will see rentals it never gets back - so this is not shippable until Recycle is wired to a definite final response.
|
Excellent. However, I would use For the final public API version, I would like to see a struct RespRequest
{
public void WriteKey(ReadOnlySpan<byte> key)
public void WriteValue(ReadOnlySpan<byte> key)
}
class DatabaseAsync
{
Task<RespResult> ExecuteRespAsync(string command, RespRequest request, CommandFlags flags = CommandFlags.None);
Task<RespResult> ScriptEvaluateRespAsync(string script, RespRequest request, CommandFlags flags = CommandFlags.None);
}
var request = db.NewRespRequest();
request.WriteKey('key1');
request.WriteValue('value1');
request.WriteKey('key2');
request.WriteValue('value2');
using var result = await db.ExecuteRespAsync(command, request);Can I assume that the core I/O v3 is aimed at this? |
|
@pairbit we're not there yet; that's a larger piece of the "write" half of the IO rewrite. If you use excessively large inputs, you're already in a world of hurt - for now, I'm just after a reliable way of making the API work - in reality, it'll work fine in all reasonable scenarios. The blit cost is not zero, but it also isn't big enough to cause me concern during the transition period between now and short-term-future when we get a more optimal write path |
|
btw, if you look in the IO core PoC, you're not a million miles away from the approach, except I've split it out - effectively you get a |
…nown open bug Make Message.Complete virtual and override it on ExecMessage/ScriptEvalMessage to call RenderedArgs.Recycle once the message is genuinely done - normal completion, -ERR, and the RecordConnectionFailed drain all route through it; PrepareToResend and caller-side timeouts never call it, so a redirect/resend still sees a live buffer. Recycle is gated on Status == CommandStatus.Sent, not unconditional: a message is enqueued into _writtenAwaitingResponse (making it visible to the async-timeout heartbeat) before WriteImpl actually runs, and Status only flips to Sent strictly after WriteTo returns. Recycling on a premature heartbeat-driven completion could race an in-flight WriteImpl reading the same buffer. If the write never happens at all, the buffer leaks rather than risking corruption - the lesser evil. Verified against a targeted concurrent-load repro (artificial server-side latency via a Lua busy-loop, tiny AsyncTimeout) with zero corruption across 48 real timeouts. Added RenderedArgsLeakTests: an end-to-end rent/return count check using a custom counting MemoryPool<byte> as RequestBufferPool, confirming ExecuteResp/ ScriptEvaluateResp calls actually return their rented buffers under real traffic, not just in the codec-level unit tests. Stable at 0-2 outstanding across many runs in isolation, never scaling with iteration count. KNOWN OPEN BUG: under full test-suite load (not reproducible in isolation, not reproducible via the targeted concurrent-load repro above), ScriptEvaluateResp/ ScriptEvaluateReadOnlyResp intermittently fail with the server error "Number of keys can't be greater than number of args" - both via the new test here and via the pre-existing RespResultTests.ScriptEvaluateReadOnlyResp_Works (sync path, shared connection). This looks like wire-level framing corruption (numkeys claims more than actually followed), specific to the new RenderedArgs-based path: a comparison test added here (OldScriptEvaluateAsync_DoesNotCorruptUnderLoad) drives the same load through the pre-existing, non-RenderedArgs ScriptEvaluateAsync and never fails. Ruled out via direct instrumentation (since removed, not committed): - Complete()/Recycle racing WriteImpl for the same message instance - checked _keyCount vs _args.Count both at the top of WriteImpl and immediately before the actual write, across several failing full-suite runs; never inconsistent. - Measure-vs-fill inconsistency in RenderedArgs.Create (i.e. RedisKey.TotalLength()/ RedisValue.GetByteCount() disagreeing between the sizing pass and the fill pass, silently under-filling since Debug.Assert is a no-op in Release) - instrumented directly, zero mismatches across failing runs. - Deferred/non-synchronous span writes in MessageWriter.WriteBulkString(ReadOnlySpan<byte>) reading from an already-recycled buffer - read WriteUnifiedSpan directly, it's a synchronous IBufferWriter<byte> GetSpan/Advance copy in every branch, no deferral. Not yet found: what actually corrupts the wire bytes under full-suite load specifically. Next step would be capturing the actual bytes handed to the socket for a failing call.
WriteTo now counts what it writes and checks that against Count, always, not just in debug. The header has already declared Count arguments by the time it runs, so writing a different number puts a malformed frame on the wire and the complaint comes back later and somewhere else - which is precisely how this bug presented, as an intermittent "Number of keys can't be greater than number of args" from the server. Checking here names the request that caused it. That found it on the first run. ScriptEvaluateResp and friends re-issue the *same message instance* on NOSCRIPT, so the completion that carries the NOSCRIPT error is not the end of the road at all: it recycled the buffer, and the retry then wrote zero arguments under a header claiming one. Recycling is now gated on !IsScriptUnavailable as well as Status, the flag being set by the result processor before the completion that carries the error. This also explains why it only appeared under full-suite load: ScriptingTests calls SCRIPT FLUSH in about ten places and xUnit runs classes in parallel, so it wipes the script cache under other classes and fires the retry. Nothing flushes in isolation, so the retry never ran. The existing NOSCRIPT tests missed it because they pass no keys or values: with Count == 0 there is nothing to release and nothing to notice. Added a case that carries arguments, which reproduces deterministically in isolation - it fails in 93ms with the gate removed, rather than needing the whole suite and two runs in three. Recycle also leaves _count alone now, and zeroes _length before the exchange rather than after. The first is what lets WriteTo check itself; the second means that losing a race with the recycler writes nothing - caught loudly by that check - rather than writing whatever the next renter has since put in the array.
The CI residual was a real leak, not timing: forcing every call down the NOSCRIPT retry gave 20 rented and 0 returned, scaling exactly one per retry. The !IsScriptUnavailable gate did stop the corruption, but the flag is set once and never cleared, so the completion *after* a successful retry still saw it and skipped the recycle for good. That was the second gate on Message.Complete, for the second distinct reason it is not the boundary we wanted - first that a message is queued before WriteImpl runs, then that a NOSCRIPT retry re-issues the same instance. Completion means "this attempt produced an outcome"; the buffer's requirement is "this message will never be written again", and the message object outlives its attempts. Recycle from the result path instead, which knows both things directly. The arrival of a reply proves the write finished, so there is no in-flight WriteImpl left to race and the Status gate is unnecessary. And the reply itself says whether this is the end of the road, so a NOSCRIPT is simply not a final reply - no sticky flag to leak on, and the retry's own reply recycles normally. Message.Complete goes back to non-virtual; the new hook is OnFinalReply, called from RespResultProcessor.SetResult for every reply except NOSCRIPT. Probe: 20 forced retries now leave 2 outstanding, the same as none - that 2 being the connection's own IO buffering at the moment of measurement. Mutation: treating NOSCRIPT as final fails the with-arguments retry test in 89ms. Paths with no reply at all - the connection-failure drain, a write that fails before sending - no longer recycle. Those leak rather than corrupt, as before, and are worth a backstop later; they are rare, and getting one wrong is how both of the previous bugs happened.
Message is the base type of every command in the library, and the hook was meaningful for exactly two of them. RespResultProcessor is used by nothing but those two, so the type test always hits - the virtual was buying generality that nothing else could use, on the type least able to afford the surface. IRenderedArgsOwner instead, next to the thing it exists for, and Message goes back to what it was.
NoteIfScriptUnavailable now returns whether *this* reply was a NOSCRIPT, and the release decision uses that rather than message.IsScriptUnavailable. The flag is set once and never cleared, so it answers "has this message ever seen a NOSCRIPT", which is not the question. A retry that comes back with some other error - a script that fails to compile, say - still reads as unavailable, so the buffer would never be released. Same shape as the leak this branch just fixed, one attempt narrower, and it would not have shown up in any test we had. Covered now: twenty NOSCRIPT-then-compile-error calls leave 2 outstanding, the connection's own IO buffering, and no growth with iterations. Reading the flag instead of the reply puts that at 22 of 22, which is what the mutation shows.
…rray A single RedisKeyOrValue[] (and a single keys/values pair for the script form), overwritten on every iteration and never awaited in between - exactly what a caller renting one array from a pool does, and exactly what breaks if the request still points at that memory when it is eventually written. Queued through a batch rather than fired at the open database, because the latter does not actually test anything: with an uncontended write lock every call is written inline, before the next iteration can overwrite the array, so the first version of this passed even against a deliberately reintroduced read-late implementation. A batch defers every write to Execute(), by which point the array holds only the last iteration - the hazard, deterministically, with no reliance on timing. Verified it discriminates: reinstating the old "hold the caller's memory, read it at write time" behaviour in ExecMessage leaves the hash with 1 field instead of 200, since every queued message writes the last iteration's field and value.
Three separate workarounds existed here purely because the callee's appetite for the caller's memory was unknowable: fire-and-forget bailed out to a real allocation (ToInnerCopy) since there is no completion to hang the return on; the sync path used InvokeAndReturnLease, which deliberately *abandoned* the lease on any exception other than RedisServerException; and the async path held the lease across the entire round trip via ReturnAfterResult. None of that is needed now. These callees render their arguments before they return - before they hand back the task, in the async case - so the lease is ours again the moment the call comes back, however it comes back. A plain finally covers every path, fire-and-forget included, and the async lease comes home immediately rather than at the end of the round trip. Note this now *depends* on that contract rather than defending against its absence, and Inner is an interface: a wrapper that captured the arguments and replayed them later would break it. RetryDatabase is exactly such a wrapper - see the open question about it - so that needs resolving before this composition is sound.
Of the APIs still aliasing caller memory, this was the one most likely to bite: it is described in its own source as "the user-facing per-row import", so reusing one values buffer per row is the intended usage - and it had no guard at all, where StringBitField at least re-checks its shape before writing the header. HashImportSetMessage now renders at construction like the other two. It gets its own result processor rather than sharing DemandOK: HIMPORT is never re-issued, so any reply at all - success or error - is the end of the road for its buffer, and that is not true of the commands DemandOK otherwise serves. The aliasing test grows a HashImport case in the same batch-driven shape. Verified it discriminates: reinstating the alias-the-caller behaviour gives every row the last row's value, "v199" where "v0" was expected. StringBitField is left as-is for now. Its elements are pure value types, so only the array itself aliases and a shallow copy would be a complete fix - no rendering needed - and measuring BitFieldOperation exactly would mean mirroring WriteOperations, which is the measure-versus-fill divergence we have already had to go looking for once.
A replaying database outlives the call it captured. The generated capture held the caller's ReadOnlyMemory directly, so a retry re-rendered from whatever that buffer contained by then - and the whole point of rendering up front is that the caller may reuse it the moment the call returns. RedisDatabase being safe does not help when the wrapper re-reads caller memory later. [AutoDatabase] gains a Replays flag. When set, the generated capture clones Memory/ReadOnlyMemory arguments into a pooled array and implements IDisposable to give it back; the funnel constrains TState to IDisposable, so the call is constrained rather than boxed, and disposes in a finally once the last attempt is done. Captures with nothing to release get an empty Dispose so the constraint is satisfiable; it inlines away. MultiGroupDatabase is deliberately not marked: it forwards once and never replays, so a copy there would be pure cost. Verified in the emitted source - five clone/release pairs, all inside RetryDatabase, none in the other two. Release clears the field before returning the array. A double return is not an error the pool reports: it pools the array twice, and two later rents then hand the same array to different callers - checked, and it does exactly that. The clear is sequential only, which is sufficient because a capture is a local within one async flow. Note the guard is on Length rather than null, because an empty ReadOnlyMemory reports a non-null zero-length array through TryGetArray. The copy is shallow: a RedisValue wrapping the caller's Memory<byte> still points at the caller's bytes. That gap closes when serialization moves out; this covers the buffer a caller is actually likely to reuse. RetryTransaction is left alone for now, with a note. It replays too - and holds a capture from queue time until Execute, so it wants this more - but there is no single point there that means "this op will not run again": ops end via ForwardSuccess, Fault or Observe, and the list is replayed as a unit. Marking it without working that out would trade an aliasing bug for a guaranteed leak.
…snapshot It replays like RetryDatabase, and holds a capture for longer - from the moment an operation is recorded until Execute - so its captured Memory arguments have the same problem and now get the same pooled copy. They are released in ExecuteAsync, the only point that knows an operation will not be replayed again; a transaction that is never executed keeps its captures, and nothing here could know otherwise. _ops and _conditions are now taken off the instance and cleared before anything is replayed, so the loops run over lists nothing else can still be adding to. All three recording paths already refuse to record after execution, but that check and the flag being set are not one operation: a recorder that passes the check just as Execute flips it would otherwise be adding to a collection being enumerated. Now it adds to a detached list that nobody reads - left out rather than mid-enumeration mutation. _ops becomes lazily created while it is there, matching _conditions. The recorded ops hold their capture in a non-readonly field: releasing clears it in place, and a defensive copy would return the array but leave the original pointing at it, so a second release would hand the same array back to the pool twice.
Execute.md taught readers to rent an args array and then work out, from which
exception came back, whether it was safe to return it - a `canReturn` flag, a
`catch (RedisServerException) { throw; }` to mean "the server did get it", and a
bare `catch` to abandon the buffer in case a retry still needed it. That is the
same dance we have just deleted from KeyPrefixedDatabase, for the same reason: it
is no longer true. Arguments are rendered into the request before the call
returns, so the buffer is the caller's again immediately - whether the call
succeeded, threw for any reason, or was fire-and-forget.
Rewritten around what callers can now actually do: a plain try/finally with no
conditions; returning the buffer *before* awaiting, since the async forms render
before handing back the task; and refilling one buffer across a whole batch of
queued calls without waiting for any of them. Scripting.md gains the same for its
keys and values, which had no guidance at all.
Both keep the one caveat that survives: a RedisValue can wrap a
ReadOnlyMemory<byte>, and rendering copies the value rather than the bytes behind
it, so the array is yours again but that buffer is not.
The recommended shape is now covered by a test - queued through a batch, because
against the open database an uncontended write lock writes each call inline and a
read-late implementation passes happily. Confirmed by reinstating one: the hash
ends up with 0 of 200 fields.
It reads as a permanent limitation otherwise, when it is really the last piece of the same problem: once serialization moves onto the calling thread the payload bytes are consumed before the call returns too, and the rule becomes simply "everything you passed is yours again when the call returns".
tl;dr: fix the problem with using
ReadOnlyMemory<...>leases withExecute/ScriptEvaluate:Consider:
Now: when can you return/reuse
lease? It isn't simple.-MOVED, which also means they're written multiple times)WithRetrycomplicates things even moreLong term, we know that the "write" half of the IO core rewrite is designed to solve all of this, but for now, we need it to just not suck, so:
ScriptEvaluate,Execute), pre-render the values to bytes, so we know the values are safeWithRetry), we use safe local copies, removing the problem from the callerWithKeyPrefixworks, since it can remove some defensesWhy
ExecuteResp/ScriptEvaluate*takeReadOnlyMemory<RedisKeyOrValue>/ReadOnlyMemory<RedisKey>/ReadOnlyMemory<RedisValue>and hold the caller's memory until write time. But the request is not necessarily written before the call returns: it can sit in the backlog, or in a batch, and a fire-and-forget caller gets no completion signal at all. So a caller who rents an args array and returns it afterwards — which is exactly what the docs teach for the hot path — is racing the writer and loses silently: whatever is in the recycled array when we get there is what goes on the wire.Rendering at the point of the call means the request stops referring to caller memory. It also moves the formatting off the writer thread, where it currently happens while holding the single-writer lock, so it should help throughput as well as correctness — and it is the direction the IO core rewrite goes anyway, so it is a down payment rather than a detour.
Shape
One buffer per request instead of one per argument. Entries are laid out back to back as a 4-byte length followed by that many payload bytes; a negative length marks a key, with
~lengthgiving the real size. The complement rather than the negation, because-0 == 0cannot distinguish a zero-length key from a zero-length value.RedisKeyandRedisValuecan both already measure and copy themselves exactly, so there is no oversize estimate and no backfilling: measure, rent, fill.Rents from
RequestBufferPoolwhen configured, falling back toArrayPool<byte>.Shared, matching what the write path already does.On lifetime
This is the part that killed
IRequestDisposer, so it is worth being explicit about what is different: the whole thing isinternal, so no caller participates in a lifetime protocol and there is no public surface to get stuck with.Recycletakes the state byrefon purpose. As an instance method it would compile perfectly happily against areadonlyfield and silently operate on a defensive copy, stranding the buffer; as arefparameter that same mistake isCS0192at build time — verified, along with the fact that the instance form produces no warning at all.The invariant
refcannot enforce is that the state must never be copied, since two owners means a double-return to the pool. That is documented on the type.Done
Wiring— done.ExecMessageandScriptEvalMessageto itScriptEvalMessagekeeps the script itself out of the buffer, since it is needed intact for hash management andSCRIPT LOAD.Considerations
PrepareToResendand caller-side timeouts must not recycle; theRecordConnectionFaileddrain,-ERRand normal completion must. NoteMessage.Complete's once-only latch keys offresultBox, which is null for fire-and-forget — so the buffer needs its own latch rather than riding on that one.Execute, so this changes the pool-pressure profile: a large batch will hold a rented buffer per queued command where today it holds references to caller memory.Follow-on: the same guarantee has to hold through every wrapper, not just at the base database
The point of this type is that as soon as an
Execute*call exits — sync, async, fire-and-forget, delayed in the backlog, redirected — the caller's own buffers are done and safe to recycle. That only actually holds if every layer between the caller and this rendering step preserves it. Two places don't yet:KeyPrefixedDatabase'sExecuteResp/ScriptEvaluateResp/ScriptEvaluateReadOnlyRespcurrently hold their own leased, prefixed copy open until the inner call completes with success orRedisServerException, because that used to be the earliest point at which the write was known to be over. Once the inner database renders synchronously at message-construction time (this PR), that copy is fully consumed the moment the inner call is made — the lease can be returned immediately after making that call, without waiting on its result at all. Needs review once the recycle lifecycle below lands, to confirm the assumption and simplify accordingly.KeyPrefixedDatabasealready uses lease/return; we can now simplify the return logic hereRetryDatabasecaptures the caller'sReadOnlyMemory<RedisKeyOrValue>/RedisKey/RedisValueby reference across every retry attempt, with no copy of its own. The first attempt renders synchronously same as any direct call, but a later retry re-reads the same captured memory - and by then the caller may already have recycled it, believing the call was done.RetryDatabaseneeds its own local copy/lease taken once, up front, before the retry loop starts, so a retry always reads back its own snapshot rather than memory the caller has moved on from.HashImportmaybe?