Skip to content

Spike: render request keys and values into one pooled buffer up front - #3211

Draft
mgravell wants to merge 5 commits into
mainfrom
marc/flattened-args
Draft

Spike: render request keys and values into one pooled buffer up front#3211
mgravell wants to merge 5 commits into
mainfrom
marc/flattened-args

Conversation

@mgravell

@mgravell mgravell commented Sep 9, 2026

Copy link
Copy Markdown
Collaborator

Draft — spike, not for merge. The codec plus its write and routing halves, now wired to ExecMessage/ScriptEvalMessage.

Why

ExecuteResp/ScriptEvaluate* take ReadOnlyMemory<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 ~length giving the real size. The complement rather than the negation, because -0 == 0 cannot distinguish a zero-length key from a zero-length value.

RedisKey and RedisValue can both already measure and copy themselves exactly, so there is no oversize estimate and no backfilling: measure, rent, fill.

Rents from RequestBufferPool when configured, falling back to ArrayPool<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 is internal, so no caller participates in a lifetime protocol and there is no public surface to get stuck with.

Recycle takes the state by ref on purpose. As an instance method it would compile perfectly 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 — verified, along with the fact that the instance form produces no warning at all.

The invariant ref cannot 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 ExecMessage and ScriptEvalMessage to it — done. ScriptEvalMessage keeps the script itself out of the buffer, since it is needed intact for hash management and SCRIPT LOAD.

Still to do

  • The recycle-on-definitely-final-completion rule. PrepareToResend and caller-side timeouts must not recycle; the RecordConnectionFailed drain, -ERR and normal completion must. Note Message.Complete's once-only latch keys off resultBox, which is null for fire-and-forget — so the buffer needs its own latch rather than riding on that one.
  • Batches and transactions hold queued messages until 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's ExecuteResp/ScriptEvaluateResp/ScriptEvaluateReadOnlyResp currently hold their own leased, prefixed copy open until the inner call completes with success or RedisServerException, 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.
  • RetryDatabase captures the caller's ReadOnlyMemory<RedisKeyOrValue>/RedisKey/RedisValue by 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. RetryDatabase needs 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.

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

pairbit commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

Excellent. However, I would use ROS<byte>.
First, it avoids the need to pre-calculate the total length of the request.
Second, it eliminates the need to allocate memory for excessively large arrays.

For the final public API version, I would like to see a RespRequest structure containing a ROS<byte>.
This could completely eliminate the ROM<RedisKey> and ROM<RedisValue> types.

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?

@mgravell

mgravell commented Sep 9, 2026

Copy link
Copy Markdown
Collaborator Author

@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

@mgravell

mgravell commented Sep 9, 2026

Copy link
Copy Markdown
Collaborator Author

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 <T> for the args/state, and an IRespWriter<T> that is responsible for write the args. This allows, for example, simple things like (42, "abc", DateTime.Now) as the args, with the writer dealing with the .WriteKey(args.Foo) etc - noting that in many cases, it is actually possible to use a Roslyn generator to do all the tricky bits! I have a plan, basically.

…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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants