Spike: render request keys and values into one pooled buffer up front - #3211
Spike: render request keys and values into one pooled buffer up front#3211mgravell wants to merge 5 commits into
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.
Draft — spike, not for merge. The codec plus its write and routing halves, now wired to
ExecMessage/ScriptEvalMessage.Why
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.Still to do
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.RetryDatabasecaptures 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.