Files
Nedyalko Dyakov 74d9bb03f8 feat(command): add zero-copy GetToBuffer and SetFromBuffer (#3834)
* feat(command): add zero-copy GetToBuffer and SetFromBuffer

Adds two new methods on the StringCmdable interface that let callers read
and write Redis string values directly into/from pre-allocated byte
buffers, eliminating the per-call payload allocation that the regular
Get/Set methods incur.

Public API:
  GetToBuffer(ctx, key, buf) *ZeroCopyStringCmd
  SetFromBuffer(ctx, key, buf) *StatusCmd
  ZeroCopyStringCmd { Val() int; Bytes() []byte; Result() (int, error) }

Available on *Client, *ClusterClient, *Ring, *Conn and Pipeliner via

* fix(proto): avoid stale line[0] after bufio refill in ReadStringInto

ReadLine() returns a slice into bufio.Reader's internal buffer (the
ReadSlice fast path). The follow-up io.ReadFull / Discard calls on
r.rd can trigger bufio.fill(), which copies new source bytes over the
slot line[0] used to point at. The post-read

    if line[0] == RespVerbatim { ... }

check on line 520 of reader.go could therefore observe a refilled byte
rather than the original response type. When that refilled byte
happened to be '=' (0x3D, RespVerbatim) — for example the head of a
bulk-string payload whose tail forced the refill — a regular `$<n>...`
Fix: capture line[0] into a local `isVerbatim` boolean before any
further read on r.rd, and use that local for both the pre-read length
check and the post-read prefix-stripping branch.

Regression test (internal/proto/reader_test.go): a payload sized to
exactly DefaultBufferSize, all '=' bytes. After ReadLine consumes the
header, bufio holds (bufsize - headerLen) payload bytes; the final
headerLen bytes io.ReadFull asks for force bufio.fill(), which places
payload-tail + trailing \r\n at position 0 of bufio.buf — i.e.
'=' at the slot line[0] points at. Against the buggy code the test
fails with n=32764 wanted 32768; with the fix all 10 ReadStringInto
tests pass.

* chore(ci): bump bench test timeout to 11m

* chore(bench): cap CI bench wrappers at 64 KiB; cover 10 MiB via test

* fix(bench): keep proto Reader benches O(1) in b.N via loopFrameReader

* fix(proto): drain payload on buffer-too-small; drop verbatim handling

Three related cleanups to ReadStringInto and the cmd it backs:

1. Buffer-too-small now drains the unread payload + trailing CRLF before
   returning the error, so the underlying connection stays aligned for
   the next reply. Without this drain, a too-small caller buffer left
   the bulk payload sitting in the bufio stream — the connection would
   then be returned to the pool in a corrupted state, and the next user
   of that connection would parse the leftover bytes as a malformed
   response. The existing test only checked the first call's error and
   missed this; it now feeds two consecutive bulk-string replies and
   asserts the second parses cleanly. Verified failure-mode by reverting
   just the drain: the follow-up read fails with
   `can't parse reply="hello" reading string into buffer`.

2. Drop RespVerbatim handling from ReadStringInto. The only public
   caller is GetToBuffer, which issues GET, and GET never returns a
   verbatim string — RESP3 verbatim replies come from server-info
   commands like CLIENT INFO / DEBUG OBJECT / LOLWUT. Keeping the
   branch re-introduces a hazard class where the response-type byte
   read from a stale line[0] after a bufio refill can be misinterpreted
   as the verbatim format tag (the bug fixed in
   "fix(proto): avoid stale line[0] after bufio refill in ReadStringInto").
   Removing the branch eliminates the class structurally and drops an
   O(n) memmove on the verbatim path (`copy(buf, buf[4:n])`).

   The unit test that previously asserted verbatim parsing is reshaped
   to assert verbatim now surfaces as an error, and the
   BulkStringStartingWithEqRefill regression test is kept (and its
   comment updated) as a guard against re-introducing verbatim support
   without re-introducing the bug.

3. Add a godoc paragraph to ZeroCopyStringCmd.Clone explaining the
   deep-copy semantics (clone gets a freshly allocated buffer, not a
   slice of the caller's memory) and noting that Clone is unreachable
   through normal client flows: it is only called from cluster fan-out
   routing in osscluster_router.go for multi-shard commands like
   DBSIZE / KEYS / FLUSHDB, and ZeroCopyStringCmd is only produced by
   GetToBuffer which issues GET — a single-key command never fanned
   out. Combined with NoRetry()=true blocking the retry path, Clone
   exists only to satisfy the Cmder interface and to keep the cmd usable
   if a future caller does fan out.

* fix(zerocopy): clone fails explicitly; reset cmd.n; update doc

Three small follow-ups to ZeroCopyStringCmd identified during a full
review of the zero-copy buffer code path:

1. Clone() now returns a marked clone whose readReply drains the
   network reply and surfaces an explicit error rather than producing
   silently-wrong results. Cloning a ZeroCopyStringCmd has no
   well-defined semantics: the cmd writes into caller-owned memory
   (the buf passed to GetToBuffer), and a clone can neither share that
   buf (sibling clones would race, last-writer wins) nor allocate its
   own buf (the result would be invisible to whoever asked for the
   original cmd's reply). The previous implementation chose the second
   path and lost the result; this commit chooses to fail loudly.

   The Cmder interface requires Clone, so we still return a clone.
   The clone is flagged via a new private field, and readReply checks
   that flag, calls rd.DiscardNext() to keep the connection aligned,
   and returns a clear error ("redis: ZeroCopyStringCmd cannot be
   cloned (cmd writes into caller-owned memory)").

   Pre-setting cmd.err in Clone would NOT work because pipelineReadCmds
   overwrites it with whatever readReply returns — the drain-and-error
   path is what actually survives the framework.

   In practice this path is unreachable through normal client flows:
   Clone is only called from cluster fan-out routing in
   osscluster_router.go for multi-shard commands (DBSIZE / KEYS /
   FLUSHDB), and ZeroCopyStringCmd is only produced by GetToBuffer
   which issues GET — a single-key command routed to one shard, never
   fanned out. Combined with NoRetry()=true blocking the retry path,
   the clone path is exercised only by explicit caller code.

   A new Ginkgo spec asserts both that processing a clone returns the
   expected error and that a follow-up GetToBuffer on the same client
   succeeds (the drain keeps the connection usable).

2. readReply now resets cmd.n = 0 on entry so that an error after a
   previous successful run can't leak the old data through Bytes().
   Cheap defensive belt-and-braces — the caller is supposed to check
   Err() first, but this removes a stale-state failure mode entirely.

3. The ZeroCopyStringCmd type godoc previously claimed "no data is
   consumed beyond the header line" on the buffer-too-small path.
   That stopped being true after the previous commit added the drain
   to ReadStringInto. Updated to match reality.

Verified end-to-end against Redis 8.8 in Docker:
  orig GetToBuffer: OK
  cloned cmd processing: errored as expected
  follow-up GetToBuffer: OK — connection stays aligned

* docs(string): clarify that SetFromBuffer is API symmetry, not a perf path

SetFromBuffer and Set(ctx, key, buf, 0) dispatch to the same `case []byte`
branch in proto.Writer and produce byte-identical output on the wire. The
"zero-copy on send" property comes from bufio.Writer.Write bypassing its
internal buffer for payloads larger than that buffer — a property of
bufio, not of SetFromBuffer. Set([]byte) gets the same behaviour for
free.

Without this clarification, callers reading the godoc could reasonably
infer that SetFromBuffer is a performance optimisation over Set, choose
it for that reason, and be disappointed when the bench numbers come back
indistinguishable from Set([]byte). The added paragraph spells out the
actual rationale (API symmetry with GetToBuffer for code that uses the
buffer-based pattern on both sides) and tells callers who don't care
about that symmetry that Set(ctx, key, buf, 0) is equally efficient.

No behaviour change.
2026-06-11 16:20:38 +03:00

111 lines
3.4 KiB
Go

// This example demonstrates the zero-copy buffer operations GetToBuffer and
// SetFromBuffer. These methods allow reading and writing Redis values directly
// from/to user-provided byte buffers, avoiding intermediate allocations.
//
// It also shows how these zero-copy commands work with pipelining for
// batching multiple operations in a single round-trip, including reading
// multiple keys into a single shared buffer.
//
// This is especially useful when working with large values (MB+) where
// reducing memory allocations and copies is important.
//
// Prerequisites:
// - A running Redis server on localhost:6379
//
// Run:
//
// go run .
package main
import (
"context"
"crypto/rand"
"fmt"
"log"
"github.com/redis/go-redis/v9"
)
func main() {
ctx := context.Background()
rdb := redis.NewClient(&redis.Options{
Addr: "localhost:6379",
})
defer rdb.Close()
// Clean up example keys on exit.
defer rdb.Del(ctx, "zerocopy:greeting", "zerocopy:largeblob")
// --- Example 1: Basic SetFromBuffer / GetToBuffer round-trip ---
fmt.Println("=== Example 1: Basic round-trip ===")
// Write data from a buffer directly to Redis.
writeData := []byte("Hello, zero-copy world!")
if err := rdb.SetFromBuffer(ctx, "zerocopy:greeting", writeData).Err(); err != nil {
log.Fatalf("SetFromBuffer failed: %v", err)
}
fmt.Printf("SET zerocopy:greeting (%d bytes)\n", len(writeData))
// Read data from Redis directly into a pre-allocated buffer.
readBuf := make([]byte, 100)
cmd := rdb.GetToBuffer(ctx, "zerocopy:greeting", readBuf)
if err := cmd.Err(); err != nil {
log.Fatalf("GetToBuffer failed: %v", err)
}
n := cmd.Val() // number of bytes read
fmt.Printf("GET zerocopy:greeting -> %d bytes: %q\n", n, string(cmd.Bytes()))
// --- Example 2: Large binary data (1 MB) ---
fmt.Println("\n=== Example 2: Large binary data (1 MB) ===")
const blobSize = 1 * 1024 * 1024 // 1 MB
blob := make([]byte, blobSize)
if _, err := rand.Read(blob); err != nil {
log.Fatalf("failed to generate random data: %v", err)
}
// Write 1 MB directly from the buffer — no intermediate string conversion.
if err := rdb.SetFromBuffer(ctx, "zerocopy:largeblob", blob).Err(); err != nil {
log.Fatalf("SetFromBuffer (1MB) failed: %v", err)
}
fmt.Printf("SET zerocopy:largeblob (%d bytes)\n", blobSize)
// Read 1 MB directly into a pre-allocated buffer — no extra allocations.
largeBuf := make([]byte, blobSize+64) // slightly larger than needed
cmd = rdb.GetToBuffer(ctx, "zerocopy:largeblob", largeBuf)
if err := cmd.Err(); err != nil {
log.Fatalf("GetToBuffer (1MB) failed: %v", err)
}
fmt.Printf("GET zerocopy:largeblob -> %d bytes\n", cmd.Val())
// Verify data integrity.
retrieved := cmd.Bytes()
if len(retrieved) != blobSize {
log.Fatalf("size mismatch: expected %d, got %d", blobSize, len(retrieved))
}
for i := range blob {
if blob[i] != retrieved[i] {
log.Fatalf("data mismatch at byte %d", i)
}
}
fmt.Println("Data integrity verified ✓")
// --- Example 3: Handling non-existent keys ---
fmt.Println("\n=== Example 3: Handling non-existent keys ===")
buf := make([]byte, 64)
cmd = rdb.GetToBuffer(ctx, "zerocopy:nonexistent", buf)
if cmd.Err() == redis.Nil {
fmt.Println("Key does not exist (redis.Nil) — as expected")
} else if cmd.Err() != nil {
log.Fatalf("unexpected error: %v", cmd.Err())
}
runPackingExamples(ctx, rdb)
runPipelineExamples(ctx, rdb)
fmt.Println("\nDone!")
}