mirror of
https://github.com/redis/go-redis.git
synced 2026-10-28 08:24:54 +08:00
* fix(pipeline): PipelinePoolSize alone should create the pipeline pool
The dedicated pipeline connection pool was created only when
PipelineReadBufferSize or PipelineWriteBufferSize was set. Setting just
PipelinePoolSize therefore produced no pipeline pool at all, with no error and
no log line, and every pipelined operation silently kept using the main
connection pool — competing with ordinary commands for the same connections,
which is the opposite of what the option asks for.
The gating was inverted. The pool size is the primary intent ("give pipelines
their own N connections"); the buffer sizes are tuning. And the buffer sizes
already supplied a default for the size (10), so the dependency only ran one
way.
Any of the three options now creates the pool. Enabling it by size inherits the
regular ReadBufferSize/WriteBufferSize, so a caller who only wants to bound
pipeline connections no longer has to know that a buffer size is secretly
required.
Found while measuring client-side caching over a high-latency link: the CSC
background batched reads go through withPipelineConn, which falls back to the
regular pool when none exists, so they were contending with the very reader
misses they exist to prevent.
Tests cover all four option combinations and were verified to fail with the
gate reverted.
* feat(pipeline): give autopipelined clients a dedicated pipeline pool by default
Autopipelining is pipeline-heavy by definition, yet the dedicated pipeline
pool still had to be enabled separately; without it every batch competes
with regular commands for main-pool connections. Two paths now default it in:
Config time: Options.init defaults PipelinePoolSize to DefaultPipelinePoolSize
(new exported const, 10) when AutoPipelineOptions is set. Cluster node clients
do not inherit AutoPipelineOptions (the cluster autopipeliner lives at cluster
level and dispatches into the node clients' pipeline hooks), so clientOptions
applies the same default there. Sentinel and failover node clients inherit
AutoPipelineOptions and are covered by Options.init; Ring does not support
autopipelining. A negative PipelinePoolSize opts out explicitly.
Runtime: the common pattern configures the autopipeliner after NewClient
(AutoPipelineWithOptions on a plain client), where pool creation has already
happened. ensurePipelinePool now builds the pool lazily when an autopipeliner
is created. baseClient.pipelinePool becomes a pointer to an atomic slot
(pool + pool-name ref) so the lazy publication is race-free against
concurrent Pipelined callers on the hot path, and WithTimeout clones share
the slot — two sharers cannot install two different pools (CAS; the loser
closes its candidate). Streaming-credentials and maintnotifications hooks are
wired before publication; otel registration happens for the winner. Conn, Tx
and SentinelClient never allocate the slot and keep using the main pool.
The extraction into buildPipelinePool also surfaced that NewFailoverClient
kept a duplicated creation block with the pre-fix gate (buffer sizes only),
so PipelinePoolSize alone silently created no pipeline pool on failover
clients; the shared helper fixes that, with a regression test. Stale
RingOptions/FailoverOptions doc lines still describing the old gate are
corrected.
* feat(pipeline): 64KiB default buffers and no pre-dial for the pipeline pool
The options the dedicated pipeline pool is built with are now resolved by
pipelinePoolOptions, a pure function with pinned tests:
Buffers default to DefaultPipelineBufferSize (new exported const, 64 KiB)
rather than inheriting the regular 32 KiB. Pipeline connections move whole
batches per round trip and earn bigger buffers than per-command traffic:
measured on the autopipeline engine, throughput plateaus around 64 KiB,
gains nothing past ~128 KiB, and very large buffers can regress it. An
explicitly larger regular buffer is kept rather than shrunk, and explicit
pipeline buffer sizes always win. The RESP3 minimum clamp applies as on the
main pool.
MinIdleConns is forced to 0 on the pipeline pool. It previously inherited
the main pool's value through the options clone, so a client with
MinIdleConns set pre-dialed that many pipeline connections at pool creation
— a silent doubling of the idle footprint. The pipeline pool is burst
capacity: its connections dial on demand, and the pool size
(PipelinePoolSize or DefaultPipelinePoolSize) is only a cap, so an unused
pipeline pool holds zero connections.
* test: make pipeline-conn specs aware of the dedicated pipeline pool
The autopipeline-subjects job builds its suite subject by calling
AutoPipelineWithOptions on the shared rawClient, which now (correctly)
creates the dedicated pipeline pool lazily - so client.Pipelined and
client.Pipeline run on pipeline-pool connections, not the main pool's.
Two specs assumed the old sharing:
should Auth asserted main-pool stats (Hits=2/Misses=1/TotalConns=1) after
two Pipelined calls. It now branches on PoolStats.PipelineStats: when the
dedicated pool exists, the two pipelines account there (first dials,
second reuses) and the main pool holds only the BeforeEach FlushDB dial;
in the default subject mode PipelineStats is nil and the original
assertions run unchanged.
should ClientSetInfo issued CLIENT SETINFO through a pipeline and then read
CLIENT INFO through rawClient - with a pipeline pool those are different
connections, and SETINFO is per-connection state. It now reads CLIENT INFO
through the same pipeline that issued the SETINFOs, which is equivalent on
a single-pool client and correct on a split-pool one.
Verified against redis:8 in all three GOREDIS_TEST_SUBJECT modes
(default, ap-blocking, ap-async): Ran 2 of 1543 Specs, 2 Passed.
* test: warm the pipeline-pool conn in AP tests that instrument connections
Two autopipeline unit tests assumed batches execute on the main pool's
already-initialized connection; with the dedicated pipeline pool now
created lazily at autopipeliner creation, a batch's first dispatch dials
and initializes a fresh pipeline-pool connection instead. Both tests now
warm that connection with a real two-command batch first (a lone command
takes the solo fast path through Process on the main pool and would not
touch the pipeline pool):
TestAutoPipelineRetriesOnNetworkError counted the pipeline pool's first
dial as if it were a retry redial (3 dials observed, 2 expected). The
warm-up runs before the write failure is armed, so the count again
measures exactly the one redial the retry performs.
TestAutoPipelineHookPostNextErrorPartialBatch installed a hook that
unconditionally injects a post-next error before the pipeline-pool
connection existed. Connection init runs its handshake pipeline through
the client's hook chain (newConn shares hooksMixin), so the hook failed
the pipeline conn's init itself — retries exhausted, the injected error
was stamped on every command, and the test measured init poisoning
rather than the post-next outcome rule it pins. The warm-up initializes
the connection before the hook is installed.
* docs: TODO(v10) — ClientTracking* and ClientMaintNotifications are stateful
CLIENT TRACKING and CLIENT MAINT_NOTIFICATIONS mutate per-connection
state, exactly like CLIENT SETNAME/SETINFO, but live on cmdable: on a
pooled client they land on an arbitrary pool connection that is
immediately returned to the pool, so the flag applies to a connection
the caller cannot address again. They belong on statefulCmdable, next
to ClientSetName — but the move removes the methods from
Client/UniversalClient/Cmdable (and defining them on both cmdable and
statefulCmdable makes every Pipeline selector ambiguous), so it is a
breaking change deferred to v10. Documented now; the move happens in a
follow-up PR. No behavior change.
* docs: drop the TODO(v10) notes on the per-connection commands
The ClientTracking*/ClientMaintNotifications move to statefulCmdable ships
as its own PR against master (feature/stateful-conn-commands) rather than
waiting for v10, so the TODO notes here are obsolete. Docs only.
* feat(pipeline): always create the pipeline pool; spill on exhaustion
The dedicated pipeline pool is now created unconditionally at
NewClient/NewFailoverClient — like the pubsub pool — instead of behind a
matrix of triggers (pipeline options set, AutoPipelineOptions declared,
or lazily when an autopipeliner was built). It is pure burst capacity:
no pre-dialing (MinIdleConns forced to 0), a small cap
(DefaultPipelinePoolSize) and 64 KiB buffers, so an unused pipeline pool
holds zero connections and costs nothing, while pipelines stop competing
with regular commands for main-pool connections. PipelinePoolSize < 0
opts out and restores the old pipelines-on-the-main-pool behavior.
To keep this from capping heavy concurrent Pipelined callers — who
previously shared the much larger main pool — withPipelineConn spills to
the main pool when the pipeline pool is exhausted (ErrPoolTimeout):
bursts wider than the cap keep their old capacity, spilled pipelines
simply run with regular buffers, and total connections stay bounded by
PoolSize + PipelinePoolSize.
Pipeline-pool connections are excluded from CLIENT TRACKING during init.
Pipelined commands never consult or populate the built-in client-side
cache (only the single-command cached path on main-pool connections
does), so tracking pipeline reads only grew the server's tracking table
and produced invalidation pushes for keys the cache does not hold.
With creation unconditional, the lazy machinery is deleted: the atomic
slot and CAS publication, ensurePipelinePool, the autopipeliner-factory
hooks, the Options.init AutoPipelineOptions defaulting and its cluster
clientOptions mirror. pipelinePool is a plain field set before the
client is visible to any goroutine; WithTimeout clones share it. Net
diff of this commit is negative.
Tests: the creation-rule table becomes always-on plus opt-out; new e2e
coverage for the spill (a pipeline pool of one, held busy by a pipelined
BLPOP, must not queue a second pipeline behind PoolTimeout) and for the
tracking exclusion (CLIENT LIST shows the main-pool connection tracked
and the pipeline-pool connection untracked).
* test: keep HImport mock choreography on the main pool; doc the pool default
The HImport mock tests choreograph exact per-connection sequences — a
pool of one, booms armed to fire on the Nth replayed PREPARE, session
counts pinned per connection. The always-on pipeline pool adds a second
connection whose init replays fieldsets (correct production behavior:
HIMPORT pipelines work on pipeline-pool connections precisely because of
that replay), which fired the armed booms during the pipeline conn's
init instead of the reissue under test and shifted the session counts.
Opt every client in the file out with PipelinePoolSize: -1: the tests
pin HIMPORT reissue/session semantics, not pool routing.
Also states the PipelinePoolSize default plainly in the option docs:
DefaultPipelinePoolSize (10) connections (review feedback).
* test: skip conn-init handshakes in the cluster shard pipeline hooks
The shard-level pipeline hooks in the cluster hook-order specs asserted
Expect(cmds).To(HaveLen(...)) unconditionally. With the always-on
pipeline pool, a node's first pipeline dials a pipeline-pool connection
whose init runs its handshake pipeline through the node client's hook
chain, so the assertion fired on the handshake and the resulting Ginkgo
panic aborted the whole suite. Guard both hooks with the idiom
ring_test.go already uses for the same situation: defer GinkgoRecover()
and pass connection-initialization pipelines (hello/client) through.
The cluster-level hooks are untouched — node connection init never runs
through the cluster client's chain.
* test: adapt plain-pipeline tests to the always-on pipeline pool
TestNoPipelinePoolStats pinned the stats shape of a client without a
pipeline pool by creating a client without pipeline options — a premise
the always-on pool inverts. Having no pool now requires the explicit
opt-out, so the test sets PipelinePoolSize: -1, which is exactly the
configuration whose stats shape it verifies.
TestPipelineRetriesOnNetworkError is the plain-pipeline twin of the AP
dial-count test adapted earlier: pipelines now run on the dedicated
pipeline pool, so the first Exec dialed a fresh pipeline-pool connection
and the test counted it as a phantom retry redial. Warm the pipeline
connection with one Pipelined round trip before arming the write
failure, so the count again measures exactly the one redial the retry
performs.
* fix(pipeline): address #3959 review (spill, caps, UniversalOptions)
- UniversalOptions now exposes PipelineReadBufferSize / PipelineWriteBufferSize /
PipelinePoolSize and maps them through Cluster()/Failover()/Simple(), so
UniversalClient users can size or opt out (PipelinePoolSize < 0) of the now
always-created pipeline pool (ofek's CHANGES_REQUESTED blocker).
- withPipelineConn no longer double-counts the Limiter on spill: it acquires the
main-pool conn directly (not via withConn) and records which pool owns the
conn, so the single deferred ReportResult+release runs once, in order (cursor
HIGH).
- The pipeline pool no longer inherits MaxActiveConns (reset to 0, so the ceiling
is MaxActiveConns + PipelinePoolSize, not ~2x) and uses a short
DefaultPipelinePoolTimeout so a saturated pipeline pool spills promptly instead
of queueing; spill also triggers on ErrPoolExhausted (defensive) and the doc is
corrected. Stale "lazy ensurePipelinePool" comments reworded (eager, set-once).
Adds tests: spill accounts the Limiter exactly once, the clone's cap/timeout
overrides, and the UniversalOptions passthrough.
* fix(pipeline): review follow-ups (docs, de-flake, init-spill, stats)
- Options/FailoverOptions/RingOptions docs: pipeline pool is created by default
(opt out PipelinePoolSize < 0); buffers default to max(regular, 64 KiB).
- pipeline_pool_gate_test.go: poll PoolStats instead of a fixed Sleep (de-flake).
- withPipelineConn now spills to the main pool on a pipeline-conn INIT failure
too (e.g. a fresh dial refused with maxclients), not just Get saturation;
non-saturation Get errors (ctx cancelled / pool closed) still do not spill.
- ClusterClient.PoolStats / Ring.PoolStats aggregate each node/shard pipeline
pool into PoolStats.PipelineStats.
* fix(pipeline): review follow-ups (docs, de-flake, init-spill, stats)
- Options/FailoverOptions/RingOptions docs: pipeline pool is created by default
(opt out PipelinePoolSize < 0); buffers default to max(regular, 64 KiB).
- pipeline_pool_gate_test.go: poll PoolStats instead of a fixed Sleep (de-flake).
- withPipelineConn now spills to the main pool on a pipeline-conn INIT failure
too (e.g. a fresh dial refused with maxclients), not just Get saturation;
non-saturation Get errors (ctx cancelled / pool closed) still do not spill.
- ClusterClient.PoolStats / Ring.PoolStats aggregate each node/shard pipeline
pool into PoolStats.PipelineStats.
- Ring.PoolStats now folds WaitCount/WaitDurationNs/StaleConns into both the
main-pool and pipeline-pool accumulators, matching the cluster fold (were
dropped before).
- pipelinePoolOptions caps the pipeline PoolTimeout at DefaultPipelinePoolTimeout
but honors a caller's SHORTER PoolTimeout (min), so a client tuned to spill
faster is not forced to wait the full default before falling back.
* fix(pipeline): accept pipeline pool settings in URL options
ParseURL/ParseClusterURL/ParseFailoverURL now consume pipeline_pool_size,
pipeline_read_buffer_size and pipeline_write_buffer_size, so a URL such as
redis://host:6379?pipeline_pool_size=-1 applies the documented opt-out instead of
being rejected as an unexpected option. Adds TestParseURLPipelinePoolOptions.
* ci(govulncheck): use stable Go to pick up security patches
setup-go's "1.26.x" resolved to go1.26.5, which govulncheck flags for two
standard-library vulnerabilities fixed in go1.26.6: GO-2026-6090 (crypto/tls)
and GO-2026-5972 (encoding/asn1). Track the latest stable toolchain so future
security patches are picked up automatically instead of pinning a patch.
* fix(pool): size maintnotif defaults for the pipeline pool too
Handoff worker and queue defaults were derived from PoolSize alone, but
the manager hooks the dedicated pipeline pool as well, so its
connections competed for capacity sized as if only the main pool
existed. Options.init now derives the defaults from the combined
ceiling (PoolSize + effective PipelinePoolSize) and widens the
MaxActiveConns bound the same way, since the pipeline pool sits outside
it.
---------
Co-authored-by: ofekshenawa <104765379+ofekshenawa@users.noreply.github.com>
918 lines
29 KiB
Go
918 lines
29 KiB
Go
package redis_test
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/redis/go-redis/v9"
|
|
"github.com/redis/go-redis/v9/maintnotifications"
|
|
)
|
|
|
|
// himportMockServer is a minimal RESP2 server implementing the HIMPORT
|
|
// session semantics: fieldsets live per connection, RESET wipes them, and a
|
|
// BOOM command drops the connection. It lets the lazy-replay logic be
|
|
// exercised end to end without a Redis 8.10 server.
|
|
type himportMockServer struct {
|
|
ln net.Listener
|
|
|
|
// resp3 makes HELLO succeed so the client negotiates RESP3.
|
|
resp3 bool
|
|
|
|
mu sync.Mutex
|
|
hashes map[string]map[string]string
|
|
prepareCount int
|
|
// sessions tracks the per-connection fieldset maps of live connections,
|
|
// so tests can observe fieldsets surviving on sessions the client is not
|
|
// currently using.
|
|
sessions map[*himportMockSession]struct{}
|
|
// pushBeforeSetReply, when armed, makes the next HIMPORT SET reply be
|
|
// preceded by an out-of-band RESP3 push frame.
|
|
pushBeforeSetReply bool
|
|
// boomBeforeNextPrepare, when armed, drops the connection when the next
|
|
// HIMPORT PREPARE arrives, before replying.
|
|
boomBeforeNextPrepare bool
|
|
}
|
|
|
|
// himportMockSession is one connection's fieldset state; guarded by the
|
|
// server mutex.
|
|
type himportMockSession struct {
|
|
fieldsets map[string][]string
|
|
}
|
|
|
|
func newHImportMockServer(t *testing.T) *himportMockServer {
|
|
t.Helper()
|
|
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
|
if err != nil {
|
|
t.Fatalf("listen: %v", err)
|
|
}
|
|
srv := &himportMockServer{
|
|
ln: ln,
|
|
hashes: make(map[string]map[string]string),
|
|
sessions: make(map[*himportMockSession]struct{}),
|
|
}
|
|
go func() {
|
|
for {
|
|
conn, err := ln.Accept()
|
|
if err != nil {
|
|
return
|
|
}
|
|
go srv.serve(conn)
|
|
}
|
|
}()
|
|
t.Cleanup(func() { _ = ln.Close() })
|
|
return srv
|
|
}
|
|
|
|
func (s *himportMockServer) addr() string { return s.ln.Addr().String() }
|
|
|
|
func (s *himportMockServer) prepares() int {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
return s.prepareCount
|
|
}
|
|
|
|
func (s *himportMockServer) hash(key string) map[string]string {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
return s.hashes[key]
|
|
}
|
|
|
|
// totalSessionFieldsets counts fieldsets across all live connection
|
|
// sessions — the server-side footprint the lazy discard replay must clean up.
|
|
func (s *himportMockServer) totalSessionFieldsets() int {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
n := 0
|
|
for session := range s.sessions {
|
|
n += len(session.fieldsets)
|
|
}
|
|
return n
|
|
}
|
|
|
|
func (s *himportMockServer) hashKeys() []string {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
keys := make([]string, 0, len(s.hashes))
|
|
for k := range s.hashes {
|
|
keys = append(keys, k)
|
|
}
|
|
return keys
|
|
}
|
|
|
|
func (s *himportMockServer) armPushBeforeSetReply() {
|
|
s.mu.Lock()
|
|
s.pushBeforeSetReply = true
|
|
s.mu.Unlock()
|
|
}
|
|
|
|
func (s *himportMockServer) armBoomBeforeNextPrepare() {
|
|
s.mu.Lock()
|
|
s.boomBeforeNextPrepare = true
|
|
s.mu.Unlock()
|
|
}
|
|
|
|
func (s *himportMockServer) consumeBoomBeforeNextPrepare() bool {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
armed := s.boomBeforeNextPrepare
|
|
s.boomBeforeNextPrepare = false
|
|
return armed
|
|
}
|
|
|
|
func (s *himportMockServer) consumePushBeforeSetReply() bool {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
armed := s.pushBeforeSetReply
|
|
s.pushBeforeSetReply = false
|
|
return armed
|
|
}
|
|
|
|
// readCommand parses one RESP2 array-of-bulk-strings request.
|
|
func readCommand(rd *bufio.Reader) ([]string, error) {
|
|
line, err := rd.ReadString('\n')
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
line = strings.TrimSuffix(strings.TrimSuffix(line, "\n"), "\r")
|
|
if len(line) == 0 || line[0] != '*' {
|
|
return nil, fmt.Errorf("unexpected request line %q", line)
|
|
}
|
|
n, err := strconv.Atoi(line[1:])
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
args := make([]string, 0, n)
|
|
for i := 0; i < n; i++ {
|
|
sizeLine, err := rd.ReadString('\n')
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
sizeLine = strings.TrimSuffix(strings.TrimSuffix(sizeLine, "\n"), "\r")
|
|
if len(sizeLine) == 0 || sizeLine[0] != '$' {
|
|
return nil, fmt.Errorf("unexpected bulk header %q", sizeLine)
|
|
}
|
|
size, err := strconv.Atoi(sizeLine[1:])
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
buf := make([]byte, size+2)
|
|
if _, err := io.ReadFull(rd, buf); err != nil {
|
|
return nil, err
|
|
}
|
|
args = append(args, string(buf[:size]))
|
|
}
|
|
return args, nil
|
|
}
|
|
|
|
func (s *himportMockServer) serve(conn net.Conn) {
|
|
defer conn.Close()
|
|
rd := bufio.NewReader(conn)
|
|
|
|
// Per-connection session state, visible to tests through the server.
|
|
session := &himportMockSession{fieldsets: make(map[string][]string)}
|
|
s.mu.Lock()
|
|
s.sessions[session] = struct{}{}
|
|
s.mu.Unlock()
|
|
defer func() {
|
|
s.mu.Lock()
|
|
delete(s.sessions, session)
|
|
s.mu.Unlock()
|
|
}()
|
|
|
|
reply := func(msg string) bool {
|
|
_, err := conn.Write([]byte(msg))
|
|
return err == nil
|
|
}
|
|
|
|
var (
|
|
inMulti bool
|
|
queued [][]string
|
|
)
|
|
|
|
for {
|
|
args, err := readCommand(rd)
|
|
if err != nil {
|
|
return
|
|
}
|
|
switch strings.ToUpper(args[0]) {
|
|
case "HELLO":
|
|
if s.resp3 {
|
|
if !reply("%3\r\n$6\r\nserver\r\n$5\r\nredis\r\n$7\r\nversion\r\n$6\r\n8.10.0\r\n$5\r\nproto\r\n:3\r\n") {
|
|
return
|
|
}
|
|
continue
|
|
}
|
|
// Pre-RESP3 server: the client falls back to RESP2.
|
|
if !reply("-ERR unknown command 'HELLO'\r\n") {
|
|
return
|
|
}
|
|
case "MULTI":
|
|
inMulti = true
|
|
queued = queued[:0]
|
|
if !reply("+OK\r\n") {
|
|
return
|
|
}
|
|
case "EXEC":
|
|
inMulti = false
|
|
var b strings.Builder
|
|
fmt.Fprintf(&b, "*%d\r\n", len(queued))
|
|
for _, q := range queued {
|
|
b.WriteString(s.commandReply(q, session))
|
|
}
|
|
queued = queued[:0]
|
|
if !reply(b.String()) {
|
|
return
|
|
}
|
|
case "RESET":
|
|
s.mu.Lock()
|
|
session.fieldsets = make(map[string][]string)
|
|
s.mu.Unlock()
|
|
if !reply("+RESET\r\n") {
|
|
return
|
|
}
|
|
case "BOOM":
|
|
// Simulate a dropped connection: close without replying.
|
|
return
|
|
case "HOLD":
|
|
// Occupy this connection long enough for a concurrent caller to
|
|
// be forced onto another pooled connection.
|
|
time.Sleep(150 * time.Millisecond)
|
|
if !reply("+OK\r\n") {
|
|
return
|
|
}
|
|
default:
|
|
if inMulti {
|
|
queued = append(queued, args)
|
|
if !reply("+QUEUED\r\n") {
|
|
return
|
|
}
|
|
continue
|
|
}
|
|
resp := s.commandReply(args, session)
|
|
if resp == "" {
|
|
// Sentinel from an armed connection drop.
|
|
return
|
|
}
|
|
if strings.ToUpper(args[0]) == "HIMPORT" && strings.ToUpper(args[1]) == "SET" &&
|
|
strings.HasPrefix(resp, "+OK") && s.consumePushBeforeSetReply() {
|
|
// Out-of-band push frame squeezed in right before the SET
|
|
// reply (after the injected PREPARE's reply): the client
|
|
// must drain it instead of consuming it as the SET reply.
|
|
// The payload is sized like a real notification so
|
|
// PeekPushNotificationName's initial peek window (36 bytes)
|
|
// is satisfied without waiting for the read deadline.
|
|
resp = ">2\r\n$8\r\ntestpush\r\n$32\r\n" + strings.Repeat("x", 32) + "\r\n" + resp
|
|
}
|
|
if !reply(resp) {
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// commandReply produces the RESP reply for one command; used both for direct
|
|
// dispatch and for commands queued inside MULTI/EXEC.
|
|
func (s *himportMockServer) commandReply(args []string, session *himportMockSession) string {
|
|
if strings.ToUpper(args[0]) == "HIMPORT" {
|
|
return s.himportReply(args, session)
|
|
}
|
|
return "+OK\r\n"
|
|
}
|
|
|
|
func (s *himportMockServer) himportReply(args []string, session *himportMockSession) string {
|
|
switch strings.ToUpper(args[1]) {
|
|
case "PREPARE":
|
|
if s.consumeBoomBeforeNextPrepare() {
|
|
// Simulate the connection dying mid-replay: the caller closes
|
|
// the conn when it sees the empty sentinel.
|
|
return ""
|
|
}
|
|
name, fields := args[2], args[3:]
|
|
seen := make(map[string]struct{}, len(fields))
|
|
for _, f := range fields {
|
|
if _, dup := seen[f]; dup {
|
|
return "-ERR duplicate field name in fieldset\r\n"
|
|
}
|
|
seen[f] = struct{}{}
|
|
}
|
|
s.mu.Lock()
|
|
session.fieldsets[name] = append([]string(nil), fields...)
|
|
s.prepareCount++
|
|
s.mu.Unlock()
|
|
return "+OK\r\n"
|
|
case "SET":
|
|
key, name, values := args[2], args[3], args[4:]
|
|
s.mu.Lock()
|
|
fields, ok := session.fieldsets[name]
|
|
s.mu.Unlock()
|
|
if !ok {
|
|
return "-ERR no such fieldset\r\n"
|
|
}
|
|
if len(values) != len(fields) {
|
|
return "-ERR value count does not match fieldset field count\r\n"
|
|
}
|
|
hash := make(map[string]string, len(fields))
|
|
for i, f := range fields {
|
|
hash[f] = values[i]
|
|
}
|
|
s.mu.Lock()
|
|
s.hashes[key] = hash
|
|
s.mu.Unlock()
|
|
return "+OK\r\n"
|
|
case "DISCARD":
|
|
s.mu.Lock()
|
|
_, ok := session.fieldsets[args[2]]
|
|
delete(session.fieldsets, args[2])
|
|
s.mu.Unlock()
|
|
if ok {
|
|
return ":1\r\n"
|
|
}
|
|
return ":0\r\n"
|
|
case "DISCARDALL":
|
|
s.mu.Lock()
|
|
n := len(session.fieldsets)
|
|
session.fieldsets = make(map[string][]string)
|
|
s.mu.Unlock()
|
|
return ":" + strconv.Itoa(n) + "\r\n"
|
|
}
|
|
return "-ERR Unknown subcommand\r\n"
|
|
}
|
|
|
|
// TestHImportLazyReplay drives the client against the mock server and pins
|
|
// the lazy-replay contract: PREPARE runs at most once per connection session
|
|
// (NF.2), a session that lost its fieldsets ("no such fieldset" after RESET)
|
|
// is transparently re-prepared and retried once, and a brand-new connection
|
|
// is prepared on first use.
|
|
func TestHImportLazyReplay(t *testing.T) {
|
|
srv := newHImportMockServer(t)
|
|
ctx := context.Background()
|
|
|
|
client := redis.NewClient(&redis.Options{
|
|
Addr: srv.addr(),
|
|
Protocol: 2,
|
|
PoolSize: 1, // deterministic: every command runs on the same connection
|
|
// This file choreographs exact per-connection sequences (armed booms,
|
|
// session counts) on the MAIN pool; keep pipelines there too.
|
|
PipelinePoolSize: -1,
|
|
DisableIdentity: true,
|
|
})
|
|
defer client.Close()
|
|
|
|
if err := client.HImportPrepare(ctx, "fs", "a", "b").Err(); err != nil {
|
|
t.Fatalf("prepare: %v", err)
|
|
}
|
|
if err := client.HImportSet(ctx, "k1", "fs", "1", "2").Err(); err != nil {
|
|
t.Fatalf("set k1: %v", err)
|
|
}
|
|
if err := client.HImportSet(ctx, "k2", "fs", "3", "4").Err(); err != nil {
|
|
t.Fatalf("set k2: %v", err)
|
|
}
|
|
if got := srv.prepares(); got != 1 {
|
|
t.Errorf("prepares after two sets on one session = %d, want 1 (NF.2: replay at most once)", got)
|
|
}
|
|
if h := srv.hash("k1"); h["a"] != "1" || h["b"] != "2" {
|
|
t.Errorf("k1 = %v, want a=1 b=2", h)
|
|
}
|
|
|
|
// RESET wipes the server session behind the client's back; the next SET
|
|
// gets "no such fieldset", which must trigger one re-prepare + retry.
|
|
if err := client.Do(ctx, "reset").Err(); err != nil {
|
|
t.Fatalf("reset: %v", err)
|
|
}
|
|
if err := client.HImportSet(ctx, "k3", "fs", "5", "6").Err(); err != nil {
|
|
t.Fatalf("set k3 after reset: %v", err)
|
|
}
|
|
if got := srv.prepares(); got != 2 {
|
|
t.Errorf("prepares after reset recovery = %d, want 2", got)
|
|
}
|
|
if h := srv.hash("k3"); h["a"] != "5" || h["b"] != "6" {
|
|
t.Errorf("k3 = %v, want a=5 b=6", h)
|
|
}
|
|
|
|
// BOOM drops the connection; the pool dials a fresh one whose empty
|
|
// session must be prepared before its first SET.
|
|
if err := client.Do(ctx, "boom").Err(); err == nil {
|
|
t.Fatal("boom should surface a connection error")
|
|
}
|
|
prepBefore := srv.prepares()
|
|
if err := client.HImportSet(ctx, "k4", "fs", "7", "8").Err(); err != nil {
|
|
t.Fatalf("set k4 on fresh connection: %v", err)
|
|
}
|
|
if got := srv.prepares(); got != prepBefore+1 {
|
|
t.Errorf("prepares after fresh connection = %d, want %d", got, prepBefore+1)
|
|
}
|
|
|
|
// Pipeline on another fresh connection: one injected PREPARE covers all
|
|
// SETs of the same fieldset in the batch.
|
|
if err := client.Do(ctx, "boom").Err(); err == nil {
|
|
t.Fatal("boom should surface a connection error")
|
|
}
|
|
prepBefore = srv.prepares()
|
|
pipe := client.Pipeline()
|
|
set5 := pipe.HImportSet(ctx, "k5", "fs", "9", "10")
|
|
set6 := pipe.HImportSet(ctx, "k6", "fs", "11", "12")
|
|
if _, err := pipe.Exec(ctx); err != nil {
|
|
t.Fatalf("pipeline: %v", err)
|
|
}
|
|
if set5.Err() != nil || set6.Err() != nil {
|
|
t.Fatalf("pipeline sets: %v, %v", set5.Err(), set6.Err())
|
|
}
|
|
if got := srv.prepares(); got != prepBefore+1 {
|
|
t.Errorf("prepares after pipeline on fresh connection = %d, want %d", got, prepBefore+1)
|
|
}
|
|
if h := srv.hash("k6"); h["a"] != "11" || h["b"] != "12" {
|
|
t.Errorf("k6 = %v, want a=11 b=12", h)
|
|
}
|
|
|
|
// Discard drops the client-side registry entry: the next SET is a raw
|
|
// pass-through and surfaces the server error unchanged.
|
|
if err := client.HImportDiscard(ctx, "fs").Err(); err != nil {
|
|
t.Fatalf("discard: %v", err)
|
|
}
|
|
err := client.HImportSet(ctx, "k7", "fs", "13", "14").Err()
|
|
if err == nil || !strings.Contains(err.Error(), "no such fieldset") {
|
|
t.Errorf("set after discard = %v, want no-such-fieldset pass-through", err)
|
|
}
|
|
}
|
|
|
|
// TestHImportPipelineRecoversAfterSessionLoss pins the pipeline recovery
|
|
// contract (NF.4): HIMPORT SETs of a registered fieldset that fail with "no
|
|
// such fieldset" after the session was wiped (RESET) are re-prepared and
|
|
// re-issued once within the same Exec — the error never reaches the caller,
|
|
// and only the SETs run again.
|
|
func TestHImportPipelineRecoversAfterSessionLoss(t *testing.T) {
|
|
srv := newHImportMockServer(t)
|
|
ctx := context.Background()
|
|
|
|
client := redis.NewClient(&redis.Options{
|
|
Addr: srv.addr(),
|
|
Protocol: 2,
|
|
PoolSize: 1,
|
|
// This file choreographs exact per-connection sequences (armed booms,
|
|
// session counts) on the MAIN pool; keep pipelines there too.
|
|
PipelinePoolSize: -1,
|
|
DisableIdentity: true,
|
|
})
|
|
defer client.Close()
|
|
|
|
if err := client.HImportPrepare(ctx, "fs", "a", "b").Err(); err != nil {
|
|
t.Fatalf("prepare: %v", err)
|
|
}
|
|
if err := client.HImportSet(ctx, "k1", "fs", "1", "2").Err(); err != nil {
|
|
t.Fatalf("set k1: %v", err)
|
|
}
|
|
|
|
// Wipe the server session behind the client's back; the connection's
|
|
// prepared flag is now stale.
|
|
if err := client.Do(ctx, "reset").Err(); err != nil {
|
|
t.Fatalf("reset: %v", err)
|
|
}
|
|
|
|
prepBefore := srv.prepares()
|
|
pipe := client.Pipeline()
|
|
other := pipe.Set(ctx, "plain", "x", 0)
|
|
set2 := pipe.HImportSet(ctx, "k2", "fs", "3", "4")
|
|
set3 := pipe.HImportSet(ctx, "k3", "fs", "5", "6")
|
|
if _, err := pipe.Exec(ctx); err != nil {
|
|
t.Fatalf("pipeline exec: %v", err)
|
|
}
|
|
if other.Err() != nil || set2.Err() != nil || set3.Err() != nil {
|
|
t.Fatalf("pipeline cmds after transparent recovery: %v, %v, %v",
|
|
other.Err(), set2.Err(), set3.Err())
|
|
}
|
|
if got := srv.prepares(); got != prepBefore+1 {
|
|
t.Errorf("prepares after recovery = %d, want %d (single replay)", got, prepBefore+1)
|
|
}
|
|
if h := srv.hash("k3"); h["a"] != "5" || h["b"] != "6" {
|
|
t.Errorf("k3 = %v, want a=5 b=6", h)
|
|
}
|
|
}
|
|
|
|
// TestHImportPipelineReissueTransportErrorScoped pins the failure scoping of
|
|
// the pipeline re-issue: when the retry round trip itself dies on a
|
|
// transport error, the batch is neither re-executed nor failed wholesale —
|
|
// commands whose results were delivered in the first round trip keep them,
|
|
// the failed SETs keep their errors, and the suspect connection is discarded.
|
|
func TestHImportPipelineReissueTransportErrorScoped(t *testing.T) {
|
|
srv := newHImportMockServer(t)
|
|
ctx := context.Background()
|
|
|
|
client := redis.NewClient(&redis.Options{
|
|
Addr: srv.addr(),
|
|
Protocol: 2,
|
|
PoolSize: 1,
|
|
// This file choreographs exact per-connection sequences (armed booms,
|
|
// session counts) on the MAIN pool; keep pipelines there too.
|
|
PipelinePoolSize: -1,
|
|
MaxRetries: -1,
|
|
DisableIdentity: true,
|
|
})
|
|
defer client.Close()
|
|
|
|
if err := client.HImportPrepare(ctx, "fs", "a").Err(); err != nil {
|
|
t.Fatalf("prepare: %v", err)
|
|
}
|
|
if err := client.HImportSet(ctx, "k1", "fs", "1").Err(); err != nil {
|
|
t.Fatalf("set k1: %v", err)
|
|
}
|
|
if err := client.Do(ctx, "reset").Err(); err != nil {
|
|
t.Fatalf("reset: %v", err)
|
|
}
|
|
|
|
// The first round trip delivers: plain SET OK, HIMPORT SET fails with
|
|
// "no such fieldset". The re-issue round trip then dies (the mock drops
|
|
// the connection on the replayed PREPARE).
|
|
srv.armBoomBeforeNextPrepare()
|
|
pipe := client.Pipeline()
|
|
plain := pipe.Set(ctx, "plain", "x", 0)
|
|
set2 := pipe.HImportSet(ctx, "k2", "fs", "2")
|
|
_, _ = pipe.Exec(ctx)
|
|
|
|
if plain.Err() != nil || plain.Val() != "OK" {
|
|
t.Errorf("delivered plain SET = %q, %v; must keep its first-round result", plain.Val(), plain.Err())
|
|
}
|
|
if err := set2.Err(); err == nil || !strings.Contains(err.Error(), "no such fieldset") {
|
|
t.Errorf("failed HIMPORT SET = %v, want its own no-such-fieldset error", err)
|
|
}
|
|
|
|
// The half-read connection was discarded; the next command dials fresh
|
|
// and the registered fieldset replays.
|
|
if err := client.HImportSet(ctx, "k3", "fs", "3").Err(); err != nil {
|
|
t.Fatalf("set k3 on fresh connection: %v", err)
|
|
}
|
|
if h := srv.hash("k3"); h["a"] != "3" {
|
|
t.Errorf("k3 = %v, want a=3", h)
|
|
}
|
|
}
|
|
|
|
// TestHImportTxSurfacesSessionLoss pins the documented transaction
|
|
// limitation: an executed MULTI/EXEC cannot be partially re-run, so the "no
|
|
// such fieldset" error surfaces — but the stale flag is invalidated, and the
|
|
// caller's retry of the transaction succeeds.
|
|
func TestHImportTxSurfacesSessionLoss(t *testing.T) {
|
|
srv := newHImportMockServer(t)
|
|
ctx := context.Background()
|
|
|
|
client := redis.NewClient(&redis.Options{
|
|
Addr: srv.addr(),
|
|
Protocol: 2,
|
|
PoolSize: 1,
|
|
// This file choreographs exact per-connection sequences (armed booms,
|
|
// session counts) on the MAIN pool; keep pipelines there too.
|
|
PipelinePoolSize: -1,
|
|
DisableIdentity: true,
|
|
})
|
|
defer client.Close()
|
|
|
|
if err := client.HImportPrepare(ctx, "fs", "a").Err(); err != nil {
|
|
t.Fatalf("prepare: %v", err)
|
|
}
|
|
if err := client.HImportSet(ctx, "k1", "fs", "1").Err(); err != nil {
|
|
t.Fatalf("set k1: %v", err)
|
|
}
|
|
if err := client.Do(ctx, "reset").Err(); err != nil {
|
|
t.Fatalf("reset: %v", err)
|
|
}
|
|
|
|
tx := client.TxPipeline()
|
|
set := tx.HImportSet(ctx, "k2", "fs", "2")
|
|
_, _ = tx.Exec(ctx)
|
|
if err := set.Err(); err == nil || !strings.Contains(err.Error(), "no such fieldset") {
|
|
t.Fatalf("tx exec after session loss = %v, want no-such-fieldset", err)
|
|
}
|
|
|
|
// The stale flag was invalidated: the retried transaction replays the
|
|
// PREPARE and succeeds.
|
|
tx = client.TxPipeline()
|
|
set = tx.HImportSet(ctx, "k2", "fs", "2")
|
|
if _, err := tx.Exec(ctx); err != nil {
|
|
t.Fatalf("tx retry: %v", err)
|
|
}
|
|
if set.Err() != nil {
|
|
t.Fatalf("tx retry set: %v", set.Err())
|
|
}
|
|
if h := srv.hash("k2"); h["a"] != "2" {
|
|
t.Errorf("k2 = %v, want a=2", h)
|
|
}
|
|
}
|
|
|
|
// TestHImportLazyDiscardPropagation pins the lazy discard contract: a
|
|
// DISCARD/DISCARDALL executes on one pooled connection, and other sessions
|
|
// still holding the fieldset replay the discard before their next HIMPORT
|
|
// command, so post-discard behavior is deterministic and no zombie fieldsets
|
|
// survive on connections the client keeps using.
|
|
func TestHImportLazyDiscardPropagation(t *testing.T) {
|
|
srv := newHImportMockServer(t)
|
|
ctx := context.Background()
|
|
|
|
client := redis.NewClient(&redis.Options{
|
|
Addr: srv.addr(),
|
|
Protocol: 2,
|
|
PoolSize: 2,
|
|
// This file choreographs exact per-connection sequences (armed booms,
|
|
// session counts) on the MAIN pool; keep pipelines there too.
|
|
PipelinePoolSize: -1,
|
|
DisableIdentity: true,
|
|
})
|
|
defer client.Close()
|
|
|
|
// runBoth occupies both pooled connections concurrently (HOLD keeps each
|
|
// busy long enough for the other goroutine to be forced onto the second
|
|
// connection) and runs one HIMPORT SET on each; it returns the SET
|
|
// errors.
|
|
runBoth := func(fieldset, keyPrefix string) [2]error {
|
|
var wg sync.WaitGroup
|
|
var errs [2]error
|
|
for i := 0; i < 2; i++ {
|
|
wg.Add(1)
|
|
go func(i int) {
|
|
defer wg.Done()
|
|
pipe := client.Pipeline()
|
|
pipe.Do(ctx, "hold")
|
|
set := pipe.HImportSet(ctx, fmt.Sprintf("%s:%d", keyPrefix, i), fieldset, "v")
|
|
_, _ = pipe.Exec(ctx)
|
|
errs[i] = set.Err()
|
|
}(i)
|
|
}
|
|
wg.Wait()
|
|
return errs
|
|
}
|
|
|
|
// Prepare the fieldset on both pooled connections.
|
|
if err := client.HImportPrepare(ctx, "fs", "f").Err(); err != nil {
|
|
t.Fatalf("prepare: %v", err)
|
|
}
|
|
for i, err := range runBoth("fs", "seed") {
|
|
if err != nil {
|
|
t.Fatalf("seed set %d: %v", i, err)
|
|
}
|
|
}
|
|
if got := srv.totalSessionFieldsets(); got != 2 {
|
|
t.Fatalf("sessions holding fs = %d, want 2", got)
|
|
}
|
|
|
|
// DISCARD executes on one borrowed connection; the other session still
|
|
// holds the fieldset for now.
|
|
if err := client.HImportDiscard(ctx, "fs").Err(); err != nil {
|
|
t.Fatalf("discard: %v", err)
|
|
}
|
|
if got := srv.totalSessionFieldsets(); got != 1 {
|
|
t.Fatalf("sessions holding fs right after discard = %d, want 1", got)
|
|
}
|
|
|
|
// The next HIMPORT command on each connection replays the DISCARD: the
|
|
// leftover session copy is removed and every SET fails deterministically.
|
|
for i, err := range runBoth("fs", "late") {
|
|
if err == nil || !strings.Contains(err.Error(), "no such fieldset") {
|
|
t.Errorf("set %d after discard = %v, want no-such-fieldset", i, err)
|
|
}
|
|
}
|
|
if got := srv.totalSessionFieldsets(); got != 0 {
|
|
t.Errorf("sessions holding fs after replayed discard = %d, want 0", got)
|
|
}
|
|
|
|
// Same for DISCARDALL, via the epoch: prepare on both connections, wipe
|
|
// through one, and let the other session replay the wipe.
|
|
if err := client.HImportPrepare(ctx, "fs2", "g").Err(); err != nil {
|
|
t.Fatalf("prepare fs2: %v", err)
|
|
}
|
|
for i, err := range runBoth("fs2", "seed2") {
|
|
if err != nil {
|
|
t.Fatalf("seed2 set %d: %v", i, err)
|
|
}
|
|
}
|
|
if got := srv.totalSessionFieldsets(); got != 2 {
|
|
t.Fatalf("sessions holding fs2 = %d, want 2", got)
|
|
}
|
|
if err := client.HImportDiscardAll(ctx).Err(); err != nil {
|
|
t.Fatalf("discardall: %v", err)
|
|
}
|
|
if got := srv.totalSessionFieldsets(); got != 1 {
|
|
t.Fatalf("sessions right after discardall = %d, want 1", got)
|
|
}
|
|
for i, err := range runBoth("fs2", "late2") {
|
|
if err == nil || !strings.Contains(err.Error(), "no such fieldset") {
|
|
t.Errorf("set %d after discardall = %v, want no-such-fieldset", i, err)
|
|
}
|
|
}
|
|
if got := srv.totalSessionFieldsets(); got != 0 {
|
|
t.Errorf("sessions after replayed discardall = %d, want 0", got)
|
|
}
|
|
}
|
|
|
|
// TestHImportRingFanOut drives the ring (whose shards are plain standalone
|
|
// servers, so two mock servers suffice) through the fan-out API: PREPARE
|
|
// registers once and eagerly prepares one connection per shard, SETs on any
|
|
// shard need no further replay, DISCARD/DISCARDALL clean every shard, and a
|
|
// deterministic PREPARE rejection withdraws the registration.
|
|
func TestHImportRingFanOut(t *testing.T) {
|
|
srv1 := newHImportMockServer(t)
|
|
srv2 := newHImportMockServer(t)
|
|
ctx := context.Background()
|
|
|
|
ring := redis.NewRing(&redis.RingOptions{
|
|
Addrs: map[string]string{
|
|
"shard1": srv1.addr(),
|
|
"shard2": srv2.addr(),
|
|
},
|
|
PoolSize: 1,
|
|
// This file choreographs exact per-connection sequences (armed booms,
|
|
// session counts) on the MAIN pool; keep pipelines there too.
|
|
PipelinePoolSize: -1,
|
|
DisableIdentity: true,
|
|
})
|
|
defer ring.Close()
|
|
|
|
// Fan-out PREPARE: exactly one connection per shard is prepared.
|
|
if err := ring.HImportPrepare(ctx, "fs", "a", "b").Err(); err != nil {
|
|
t.Fatalf("prepare: %v", err)
|
|
}
|
|
if p1, p2 := srv1.prepares(), srv2.prepares(); p1 != 1 || p2 != 1 {
|
|
t.Fatalf("prepares after fan-out = %d/%d, want 1/1", p1, p2)
|
|
}
|
|
|
|
// SETs route by key hash to some shard; the fan-out already prepared
|
|
// those connections, so no further PREPARE is sent.
|
|
for i := 0; i < 20; i++ {
|
|
key := fmt.Sprintf("ring:%d", i)
|
|
if err := ring.HImportSet(ctx, key, "fs", "1", "2").Err(); err != nil {
|
|
t.Fatalf("set %s: %v", key, err)
|
|
}
|
|
}
|
|
if p1, p2 := srv1.prepares(), srv2.prepares(); p1 != 1 || p2 != 1 {
|
|
t.Errorf("prepares after sets = %d/%d, want 1/1 (no replay needed)", p1, p2)
|
|
}
|
|
if total := len(srv1.hashKeys()) + len(srv2.hashKeys()); total != 20 {
|
|
t.Errorf("hashes across shards = %d, want 20", total)
|
|
}
|
|
|
|
// Fan-out DISCARD cleans both shard sessions; the return value reports
|
|
// the registry lifecycle.
|
|
removed, err := ring.HImportDiscard(ctx, "fs").Result()
|
|
if err != nil {
|
|
t.Fatalf("discard: %v", err)
|
|
}
|
|
if removed != 1 {
|
|
t.Errorf("discard = %d, want 1", removed)
|
|
}
|
|
if got := srv1.totalSessionFieldsets() + srv2.totalSessionFieldsets(); got != 0 {
|
|
t.Errorf("session fieldsets after fan-out discard = %d, want 0", got)
|
|
}
|
|
err = ring.HImportSet(ctx, "ring:after", "fs", "1", "2").Err()
|
|
if err == nil || !strings.Contains(err.Error(), "no such fieldset") {
|
|
t.Errorf("set after discard = %v, want no-such-fieldset pass-through", err)
|
|
}
|
|
|
|
// A deterministic server rejection (duplicate field) withdraws the
|
|
// registration: no replay is attempted afterwards.
|
|
err = ring.HImportPrepare(ctx, "dup", "f", "f").Err()
|
|
if err == nil || !strings.Contains(err.Error(), "duplicate field name") {
|
|
t.Fatalf("duplicate prepare = %v, want duplicate-field error", err)
|
|
}
|
|
err = ring.HImportSet(ctx, "ring:dup", "dup", "v").Err()
|
|
if err == nil || !strings.Contains(err.Error(), "no such fieldset") {
|
|
t.Errorf("set after rejected prepare = %v, want raw no-such-fieldset", err)
|
|
}
|
|
|
|
// Fan-out DISCARDALL reports the registry count and cleans both shards.
|
|
if err := ring.HImportPrepare(ctx, "fs2", "g").Err(); err != nil {
|
|
t.Fatalf("prepare fs2: %v", err)
|
|
}
|
|
count, err := ring.HImportDiscardAll(ctx).Result()
|
|
if err != nil {
|
|
t.Fatalf("discardall: %v", err)
|
|
}
|
|
if count != 1 {
|
|
t.Errorf("discardall = %d, want 1", count)
|
|
}
|
|
if got := srv1.totalSessionFieldsets() + srv2.totalSessionFieldsets(); got != 0 {
|
|
t.Errorf("session fieldsets after fan-out discardall = %d, want 0", got)
|
|
}
|
|
}
|
|
|
|
// TestHImportInjectedPrepareWithPushNotification pins the RESP3 read
|
|
// sequence: a push frame arriving between the injected PREPARE's reply and
|
|
// the SET's reply must be drained as a notification, not consumed as the
|
|
// SET's reply.
|
|
func TestHImportInjectedPrepareWithPushNotification(t *testing.T) {
|
|
srv := newHImportMockServer(t)
|
|
srv.resp3 = true
|
|
ctx := context.Background()
|
|
|
|
client := redis.NewClient(&redis.Options{
|
|
Addr: srv.addr(),
|
|
Protocol: 3,
|
|
PoolSize: 1,
|
|
// This file choreographs exact per-connection sequences (armed booms,
|
|
// session counts) on the MAIN pool; keep pipelines there too.
|
|
PipelinePoolSize: -1,
|
|
MaxRetries: -1, // fail BOOM fast; the injected PREPARE needs no retries
|
|
DisableIdentity: true,
|
|
// The mock is not a real cluster; keep maintenance-notification
|
|
// machinery out of the connection lifecycle.
|
|
MaintNotificationsConfig: &maintnotifications.Config{Mode: maintnotifications.ModeDisabled},
|
|
})
|
|
defer client.Close()
|
|
|
|
if err := client.HImportPrepare(ctx, "fs", "a", "b").Err(); err != nil {
|
|
t.Fatalf("prepare: %v", err)
|
|
}
|
|
|
|
// Drop the connection so the next SET runs on a fresh session and gets
|
|
// the PREPARE injected in front of it.
|
|
if err := client.Do(ctx, "boom").Err(); err == nil {
|
|
t.Fatal("boom should surface a connection error")
|
|
}
|
|
|
|
prepBefore := srv.prepares()
|
|
srv.armPushBeforeSetReply()
|
|
if err := client.HImportSet(ctx, "k1", "fs", "1", "2").Err(); err != nil {
|
|
t.Fatalf("set with interleaved push notification: %v", err)
|
|
}
|
|
if got := srv.prepares(); got != prepBefore+1 {
|
|
t.Errorf("prepares = %d, want %d (PREPARE must be injected on the fresh connection)", got, prepBefore+1)
|
|
}
|
|
if h := srv.hash("k1"); h["a"] != "1" || h["b"] != "2" {
|
|
t.Errorf("k1 = %v, want a=1 b=2", h)
|
|
}
|
|
|
|
// The connection must stay aligned for subsequent commands.
|
|
if err := client.HImportSet(ctx, "k2", "fs", "3", "4").Err(); err != nil {
|
|
t.Fatalf("follow-up set: %v", err)
|
|
}
|
|
if h := srv.hash("k2"); h["a"] != "3" || h["b"] != "4" {
|
|
t.Errorf("k2 = %v, want a=3 b=4", h)
|
|
}
|
|
}
|
|
|
|
// TestHImportPipelineInjectedReplyFailureStampsBatch pins the error stamping
|
|
// when the injected-reply read dies on a transport error before any batch
|
|
// reply was consumed: Exec surfaces the error and every command of the batch
|
|
// carries it. The outer retry loop stamps errors only on its exit branch,
|
|
// not when the attempt budget runs out mid-loop, so the pipeline path must
|
|
// stamp before returning — otherwise a batch that keeps dying here reports
|
|
// Err() == nil on every command while Exec errors.
|
|
func TestHImportPipelineInjectedReplyFailureStampsBatch(t *testing.T) {
|
|
srv := newHImportMockServer(t)
|
|
ctx := context.Background()
|
|
|
|
client := redis.NewClient(&redis.Options{
|
|
Addr: srv.addr(),
|
|
Protocol: 2,
|
|
PoolSize: 1,
|
|
// This file choreographs exact per-connection sequences (armed booms,
|
|
// session counts) on the MAIN pool; keep pipelines there too.
|
|
PipelinePoolSize: -1,
|
|
// Exhaust the budget on the first attempt: stamping must not
|
|
// depend on a later attempt reaching the read path.
|
|
MaxRetries: -1,
|
|
DisableIdentity: true,
|
|
})
|
|
defer client.Close()
|
|
|
|
if err := client.HImportPrepare(ctx, "fs", "a").Err(); err != nil {
|
|
t.Fatalf("prepare: %v", err)
|
|
}
|
|
// Drop the connection so the next batch runs on a fresh session and
|
|
// gets the PREPARE injected ahead of it.
|
|
if err := client.Do(ctx, "boom").Err(); err == nil {
|
|
t.Fatal("boom should surface a connection error")
|
|
}
|
|
|
|
// The mock drops the connection when the injected PREPARE arrives: the
|
|
// batch was fully written, not one reply arrives.
|
|
srv.armBoomBeforeNextPrepare()
|
|
pipe := client.Pipeline()
|
|
plain := pipe.Set(ctx, "plain", "x", 0)
|
|
set := pipe.HImportSet(ctx, "k1", "fs", "1")
|
|
if _, err := pipe.Exec(ctx); err == nil {
|
|
t.Fatal("exec must surface the transport error")
|
|
}
|
|
if plain.Err() == nil || set.Err() == nil {
|
|
t.Errorf("batch commands must carry the exec error, got %v / %v",
|
|
plain.Err(), set.Err())
|
|
}
|
|
|
|
// Same contract on the transaction path.
|
|
if err := client.Do(ctx, "boom").Err(); err == nil {
|
|
t.Fatal("boom should surface a connection error")
|
|
}
|
|
srv.armBoomBeforeNextPrepare()
|
|
tx := client.TxPipeline()
|
|
txSet := tx.HImportSet(ctx, "k2", "fs", "2")
|
|
if _, err := tx.Exec(ctx); err == nil {
|
|
t.Fatal("tx exec must surface the transport error")
|
|
}
|
|
if txSet.Err() == nil {
|
|
t.Error("tx command must carry the exec error")
|
|
}
|
|
}
|