Files
go-redis/pipeline_spill_test.go
d907b985fc feat(autopipeline): full duplex flusher (#4002)
* feat(autopipeline): ordered full-duplex dispatch (#3964)

* 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.

* feat(autopipeline): ordered full-duplex dispatch (single-connection, WAN-optimized)

One held pipeline-pool connection with a writer + reader goroutine pair streams
the ordered command stream instead of the half-duplex one-batch-per-round-trip
flusher. On a latency-bound link under many concurrent goroutines this gives
~1 RTT latency at one connection: WAN 50ms w=4096 inflight=5 ~389k ops/s @ 52ms
p50 vs ~207k @ 116ms for the half-duplex ordered path (errors=0).

- Gated by AutoPipelineOptions.FullDuplex; honored only on the async, ordered
  (Unordered:false, MaxConcurrentBatches<=1), single-shard face of a standalone
  *Client with a pipeline pool. Cluster stays half-duplex. Validate rejects the
  contradictory combos; tuning via FullDuplex{Window,IdleTimeout,MaxHold}.
- Connection-failure retries re-issue the unacked ordered tail on a fresh conn,
  honoring shouldRetry/MaxRetries/backoff and the NoRetry flag (parity with the
  half-duplex pipeline's cmdsContainNoRetry).
- Lease/return (idle + max-hold) returns the held conn through the pool so its
  per-conn hooks (streaming-creds re-auth, maintnotifications) run.
- RESP3 push frames demuxed inline (invalidation etc. never misread as a reply).
- Per-command process hooks (redisotel spans/metrics, custom ProcessHooks) fire
  on the FD path; blocking/conn-hostile commands divert to a separate conn.
- Bounded in-flight window applies backpressure (Submit blocks when full).

18 FD tests, all -race. Depends on #3959 (getPipelinePool + always-on pipeline
pool). See AP_ORDERED_FULLDUPLEX_DESIGN.md.

* fix(autopipeline): correct full-duplex teardown, recovery, and shutdown races

Fixes four concurrency defects in the ordered full-duplex dispatch engine:

- failReqs called cmd.Err(), which awaits batch.done -- the very channel
  failReqs then closes -- deadlocking the engine goroutine on retry
  exhaustion / Close. Use rawErr() (the same trap hostHook already documents).
- On a connection-error session end the deque was taken (closeRecover)
  concurrently with the reader, so a command the reader had completed but not
  yet advanced could be handed to the retry loop and re-executed (and, on the
  hooked path, double-close its hookDone channel -- a panic). Split into
  hardClose + takeRemaining and take the tail only after the reader has exited.
- A desynced connection was returned to the pool via Put() unless the error
  was classified bad-conn, poisoning the pool for the next caller (e.g.
  errFDReaderGone, a write timeout). Remove on any fdConnErr session end.
- submit()'s send could win the race against the shutdown drain and enqueue a
  request that was never completed, hanging the caller forever. Gate the send
  with an RWMutex that drainQueue takes before draining.

Also closes the readerDone/clean-result race: a reader protocol error during
an idle/recycle/graceful return now removes the conn instead of Put-ing it
back poisoned, and fails any stranded in-flight tail so no caller hangs.

Adds pure unit tests for the failReqs deadlock and the deque ownership
invariant. Existing full-duplex suite (conn-kill, close-while-backpressured,
ctx-cancel, goroutine-leak) passes under -race.

* style(autopipeline): fix lint in full-duplex dispatch

Clears the three pre-existing staticcheck failures in the ordered full-duplex
file (present before the teardown/recovery fix; that commit added none):

- ST1008: reorder attempt() and session() return values so the error is the
  last argument -- (unacked, result, aerr) instead of (unacked, aerr, result).
  Pure reordering; the two call sites are updated to match. error and fdResult
  are distinct types, so any misorder fails to compile.
- unused: //nolint:unused on fdInflight.peakLen, which is exercised only by the
  full-duplex backpressure tests (the repo lints with run.tests=false, so
  test-only usage is invisible to the unused check).

* fix(autopipeline): full-duplex hardening (retry, recover, ctx, drain, bytes)

Full-duplex review follow-ups (all in autopipeline_fullduplex.go):
- retry read/write timeouts: shouldRetry(aerr, true), matching the cluster
  pipeline retry paths, so a single deadline miss no longer fails the whole
  unacked tail (cursor High); fdReqsNoRetry still excludes non-idempotent cmds.
- recover a panicking user ProcessHook in hostHook (fail cmd + close batch), so
  it can neither crash the process nor leave the caller blocked.
- close the conn on the connection-error path so a reader blocked in WithReader
  unblocks at once instead of stalling up to the read deadline (cursor High).
- honor a disabled ReadTimeout (<0) as no-deadline instead of clamping to 30s.
- log (not propagate) a push-drain error, matching the workers path, so a custom
  push processor error no longer fails an unrelated in-flight command.
- return a clean conn via releaseConnToPool so pending push notifications are
  drained before Put (no delayed handoff).
- acquire under ap.ctx so Close aborts a blocked pool.Get (init and I/O stay on
  Background so accepted commands still complete); backpressured submit honors
  the caller ctx.
- apply a soft MaxBatchBytes cap in the writer drain loop.

* fix(autopipeline): full-duplex hardening (retry, recover, ctx, drain, bytes)

Full-duplex review follow-ups (all in autopipeline_fullduplex.go):
- retry read/write timeouts: shouldRetry(aerr, true), matching the cluster
  pipeline retry paths, so a single deadline miss no longer fails the whole
  unacked tail (cursor High); fdReqsNoRetry still excludes non-idempotent cmds.
- recover a panicking user ProcessHook in hostHook (fail cmd + close batch), so
  it can neither crash the process nor leave the caller blocked.
- close the conn on the connection-error path so a reader blocked in WithReader
  unblocks at once instead of stalling up to the read deadline (cursor High).
- honor a disabled ReadTimeout (<0) as no-deadline instead of clamping to 30s.
- log (not propagate) a push-drain error, matching the workers path, so a custom
  push processor error no longer fails an unrelated in-flight command.
- return a clean conn via releaseConnToPool so pending push notifications are
  drained before Put (no delayed handoff).
- acquire under ap.ctx so Close aborts a blocked pool.Get (init and I/O stay on
  Background so accepted commands still complete); backpressured submit honors
  the caller ctx.
- apply a soft MaxBatchBytes cap in the writer drain loop.
- honor opt.Limiter per session: Allow() before the session pool.Get,
  ReportResult() before release (report-before-release), 1:1 per session
  (the Limiter gates conn acquisition; FD acquires one conn per session).
- on a retryable Redis error (LOADING/READONLY/CLUSTERDOWN/…) or a redirect
  (MOVED/ASK), re-run the command on the client normal path (pipeliner.process)
  off the reader goroutine (tracked by retryWg so Close waits) and settle the
  caller with that result; ordering across the divert is not promised (same as
  the blocking-command divert), NoRetry commands keep their error.
- record the native per-command OTel metric (RecordOperationDuration, write->reply)
  from the reader, mirroring process; skipped for retry-diverted commands (they
  emit via process). fdReq carries its submit ctx + write timestamp.
- divert managed HIMPORT (PREPARE/SET/DISCARD/DISCARDALL) off the FD pipe to the
  normal Process path: the FD writer streams raw commands and never replays the
  registered PREPARE (connection-session state), so an HIMPORT SET on the pipe
  could fail "no such fieldset". Process injects the PREPARE from the registry and
  keeps it current on PREPARE; the half-duplex sharded path injects inline
  (himportInjectedCmds) and is left on the pipeline. FD-scoped: gated on ap.fd.
- wait for per-command hook-host goroutines during Close: a ProcessHook doing work
  after next() returns runs on a host goroutine that is the only code closing that
  command's batch. Track it in a hostWg that run() waits (so Close, which waits
  ap.wg, waits it too), restoring the drain-before-return contract. The hostWg.Add
  is gated behind submitMu+closed exactly like the ch send, so run()'s hostWg.Wait
  never races a live Add (every run() return is preceded by drainQueue).
- stamp the FD hook host as the batch executor (dispGid) so a ProcessHook that
  reads its own command result after next() (cmd.Err()/cmd.String()) gets the
  just-executed view instead of self-deadlocking on the batch only that goroutine
  closes (cursor P1). Mirrors runOutsidePipeline's async dispatch guard.

* test(autopipeline): consolidate full-duplex tests into one file

Merge the per-topic full-duplex test files (limiter, retry, otel, himport,
close, hookdeadlock) into autopipeline_fullduplex_test.go and remove them.
No test logic changes; the imports are unioned (adds internal/otel).

* fix(search): read the full RESP3 map reply in FTHybridCmd (#3956)

FTHybridCmd.readReply used ReadSlice, which reads a RESP3 map header's
length as an element count and consumes only half its frames, leaving the
connection desynced. Peek the type and read a map with ReadReply, like the
other FT.* parsers.

* fix(autopipeline): full-duplex review round 3 (read-timeout, panic, retry split)

- Honor a disabled ReadTimeout in the FD reader. Options.init maps a user's
  ReadTimeout:-1 to 0; the reader clamped 0 -> 30s, forcing a deadline on a
  caller who disabled it (regression). It now passes opt.ReadTimeout through:
  WithReader/deadline() treat <= 0 as "no deadline", while a default client keeps
  its positive per-reply timeout. A stuck read is still unblocked by the conn
  Close on the error path.
- On a ProcessHook panic BEFORE next(), the hook host now waits for the reader to
  finish writing the command (<-hookDone) before closing the batch. The command
  was already streamed, so closing immediately let the reader write cmd.err (an
  unsynchronized field) after the caller observed the recovered failure — a data
  race, and a non-idempotent command left half-settled. Mirrors the short-circuit
  path, which already waited.
- Split the unacked tail at the first NoRetry command on a connection failure
  (fdFirstNoRetry): replay the retryable prefix, fail that command and everything
  ordered after it, instead of failing the whole tail. Ordinary commands before a
  NoRetry keep their network retries; the NoRetry command is never re-sent —
  matching the half-duplex split of contiguous retry-policy runs.
- Recover a panic in reply decoding on the FD reader goroutine (e.g. a
  RawWriteToCmd whose user io.Writer panics mid-stream). Without recovery it would
  crash the process; now the session is marked failed (fdConnErr), the unacked
  tail is recovered and the desynced conn removed — mirroring recoverDispatchPanic
  on the half-duplex dispatchers.

Tests (autopipeline_fullduplex_test.go): pre-next-panic settles with an error and
does not hang; fdFirstNoRetry split index across leading/middle/trailing NoRetry;
reader-decoder panic is recovered and the engine keeps serving.

* fix(autopipeline): advance completed FD commands before reader-panic recovery

The reader-panic recover (round 3) marked the session failed but did not advance
the commands already completed in the panicking frontBatch snapshot. Recovery
then re-owned and re-completed them, overwriting good results and double-closing
hookDone (a second panic) when process hooks are installed.

The reader now hoists `done` (completed-but-not-yet-advanced in the current
snapshot) to goroutine scope, reset at the top of each iteration, and the recover
calls inflight.advance(done) before failing the session — so recovery only
re-owns genuinely-unacked entries. A frontBatch panic advances nothing (done 0).

Test: TestFullDuplexReaderPanicMidBatchNoDoubleComplete — several completed
hooked GETs precede a decoder-panicking command in one batch; the engine must
survive without a double-close.

Also sharpen the FullDuplex doc: a hook that short-circuits (returns without
next) does NOT block an already-streamed command on the FD path, so a
policy/blocking hook must run on a plain client or the half-duplex autopipeline;
observability hooks are unaffected. Adds a TODO for opt-in write-gating.

* fix(autopipeline): full-duplex review fixes

- Limiter denial (fdDenied) and lease/init failure (fdLeaseErr) fail-fast carry +
  the fd.ch backlog after retries via a non-closing failQueue, instead of hanging
  accepted commands; a mid-session drop stays fdConnErr so buffered work survives
  a reconnect.
- submit starts the hook host only after admission; submit-reject paths return the
  completedBatch sentinel (no host leak; raw Process reports a canceled submit).
- graceful Close flushes the accepted fd.ch backlog (flushBacklogForClose).
- recovered replay tail re-issued in MaxBatchSize/MaxBatchBytes chunks
  (writeCarryChunked/fdBatchEnd).
- reader-panic recover advances completed commands before recovery (no double
  complete); honor a disabled ReadTimeout; split the unacked tail at the first
  NoRetry; use the shared initPooledConn; max-hold returns fdIdle when drained;
  Len() counts fd.ch; retry-divert / short-circuit-hook ordering documented.

* fix(autopipeline): recover full-duplex carry suffix; lease lazily

- writeCarryChunked pushed each chunk into the in-flight deque before writing it,
  so a multi-chunk carry/backlog whose write failed partway left the un-written
  suffix in neither fd.ch nor the deque — those accepted commands never completed
  and their callers hung. Push the suffix on a write error so the whole set is
  recoverable (replayed on the conn-error path, failed on Close).
- On a Close-flush write error, close the broken connection and fail the tail
  (like the connection-error path) instead of closeGraceful, which left the
  reader waiting on replies that never arrive (hanging until ReadTimeout).
- Lease the FD connection lazily, on the first command, instead of eagerly at
  startup: an unused FD autopipeliner no longer dials / hits the Limiter in the
  background or spins on a lease failure against an empty queue.

* 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(autopipeline): flush Close backlog between sessions; lease only with work

- run()'s terminal shutdown drains now call shutdownFlush: carry plus the fd.ch
  backlog are dispatched as one batch on the client's normal pipeline path and
  completed, honoring the "accepted => completes" Close contract on the
  between-sessions paths exactly like the in-session flushBacklogForClose,
  instead of failing accepted work ErrClosed. takeQueue replaces drainQueue as
  the gate-closing shutdown drain. Adds
  TestFDShutdownFlushCompletesBetweenSessionsBacklog.
- The wait-for-work is hoisted to the top of run()'s loop: with an empty carry
  (initial entry, fdIdle, and the fail-fast exits after fdDenied / exhausted
  fdLeaseErr / a failed fdConnErr tail) the engine blocks for the next command
  before touching the Limiter or dialing, so a failure against an empty queue
  cannot spin the lease loop. Non-blocking when work is queued, so fdRecycle
  with pending work still re-leases immediately.

* fix(autopipeline): FD shutdown, panic, retention and retry-divert fixes

- The fdLeaseErr and fdDenied branches check ap.ctx first: when Close is what
  interrupted the acquisition, run() flushes carry + the fd.ch backlog via
  shutdownFlush instead of failing accepted work with the cancellation error.
- writeBatch recovers a command-encoding panic (user BinaryMarshaler) and
  converts it to a connection error, mirroring recoverDispatchPanic on the
  half-duplex dispatchers; the conn-error path replays or fails the batch.
- fdInflight.advance zeroes the consumed prefix before reslicing, so a drained
  burst does not retain a window of completed fdReq values (args, contexts)
  while the engine idles.
- The reader's divert distinguishes redirects from retries: MOVED/ASK always
  divert, retryable Redis errors divert only when MaxRetries > 0 — with retries
  disabled the error surfaces instead of process() re-sending attempt zero.

* fix(autopipeline): bound off-pipe FD retries at the window

Under a sustained retryable stream (LOADING/READONLY on every reply) each
diverted reply spawned an independent normal-path retry goroutine while the
reader kept advancing the window and admitting more writes — unbounded
goroutines parked in backoff/pool acquisition. retryOnNormalConn now acquires a
window-sized semaphore slot before spawning; a full semaphore blocks the READER,
which stops advancing the deque, fills the window, and blocks the writer and
submitters — end-to-end backpressure, worst case window on-pipe + window
off-pipe. No cycle: retries drain on the main pool, independent of the reader.

* fix(autopipeline): split FD retry budgets; metric parity; writer priority

- run() keeps two budgets, each counting only consecutive failures of its own
  kind: leaseAttempts (fdLeaseErr/fdDenied) and retryAttempts (fdConnErr tail
  replays). leaseAttempts resets whenever a session actually runs and both reset
  on a clean end, so transient lease failures can no longer consume the
  reconnect budget and leave a genuine drop with zero replay attempts.
- The reader's inline completion emits the native error metric
  (classifyCommandError + pool.GetMetricErrorCallback) for non-retryable Redis
  errors, matching the normal command path.
- The writer runs a non-blocking readerDone priority check before its main
  select, so randomized select can no longer write another batch to a
  connection the engine already knows has no reader.

* feat(autopipeline): enable full-duplex on the blocking face

Drop the !blocking term from the fdOn gate: FullDuplex is now honored on the
blocking AutoPipeline face too. No new machinery was needed — submit()'s fd
branch already skips setReady on the blocking face (its contract) and
processBlocking waits on the returned batch, the same shape as a half-duplex
enqueue; the dispGid self-deadlock guard and everything downstream are
face-agnostic.

A single blocking caller gains nothing (one command in flight — nothing to
overlap; only held-conn overhead); the benefit appears with many concurrent
blocking callers, whose commands overlap on the shared pipe like the async
face (~1 RTT each instead of batch phase-locking). The FullDuplex GoDoc states
this and notes the blocking face's per-goroutine ordering is unaffected by the
retry divert (each caller waits per command by construction).

Adds TestFullDuplexBlockingFace.

* fix(autopipeline): FD retry-panic recovery, window cap, carry stop

- retryOnNormalConn recovers a panic from process (hooks/encoders are user
  code): the command gets the panic as its error and always completes, so the
  caller and a hooked command's host cannot hang — matching the writer, reader
  and hostHook recovery discipline.
- The writer's drain is capped by the remaining window room
  (min(MaxBatchSize, window - inflight)), so FullDuplexWindow smaller than
  MaxBatchSize can no longer be blown through by a single batch.
- writeCarryChunked observes readerDone between chunks (carry replay and the
  Close flush both pass it): if the reader exits mid-replay the un-written
  remainder is pushed to the deque for recovery instead of being written to a
  reader-less connection.

* fix(autopipeline): emit error metrics for failReqs-terminated FD commands

Commands terminated through failReqs (lease failure, retry exhaustion, a
NoRetry tail, Close) never reach the reader's inline completion, so they were
invisible to the native error callback. failReqs now classifies the shared
failure once and emits the callback per command. No duration metric: these
commands have no meaningful write-to-reply span (many were never written).

* fix(autopipeline): FD metric parity, Validate gap, chunked shutdown flush

- failQueue emits the native error callback per drained request, mirroring
  failReqs: on fdLeaseErr/fdDenied both halves of the failure (carry and the
  accepted channel backlog) are now visible to extra/redisotel-native.
- Validate rejects FullDuplex with a user-set NumShards > 1 (one held FIFO
  connection is a single stream) instead of silently falling back to
  half-duplex; the cluster wiring's internal contentSharded flag is exempt,
  preserving the documented cluster fallback.
- shutdownFlush re-issues the between-sessions Close backlog in the same
  MaxBatchSize/MaxBatchBytes chunks as normal FD writes (fdBatchEnd) instead of
  one unchunked pipeline that ignored MaxBatchBytes.

* fix(autopipeline): FD metric parity, Validate gap, chunked shutdown flush

- failQueue emits the native error callback per drained request, mirroring
  failReqs: on fdLeaseErr/fdDenied both halves of the failure (carry and the
  accepted channel backlog) are visible to extra/redisotel-native.
- Validate rejects FullDuplex with a user-set NumShards > 1 (one held FIFO
  connection is a single stream) instead of silently falling back to
  half-duplex; the cluster wiring's internal contentSharded flag is exempt,
  preserving the documented cluster fallback.
- shutdownFlush re-issues the between-sessions Close backlog in the same
  MaxBatchSize/MaxBatchBytes chunks as normal FD writes instead of one
  unchunked pipeline that ignored MaxBatchBytes; it stops (failing the
  remainder) after a transport-class chunk failure instead of re-running the
  retry cycle per chunk against a dead endpoint, and recovers an encoder panic.
- The reconnect retry budget resets when a session completed work (including a
  successful carry replay): a drop after real progress is a fresh failure, not
  a consecutive one.
- The submit queue is capped at min(window, 4096): a buffered channel
  allocates its capacity eagerly (~5 MiB per engine at the default window);
  backpressure is enforced by the in-flight deque, so the window keeps its
  meaning as the in-flight bound.

Also compacts the review-round comments in the touched files (invariants and
contracts preserved; narration removed).

* fix(autopipeline): replay the FD tail after recovered panics

Recovered panics (reply decode, batch encode) surfaced as plain errors, so
shouldRetry rejected them and the connection-error path permanently failed the
whole unacked tail — mostly commands the panic never touched. Wrap them with an
errFDPanicRecovered sentinel and treat it (and errFDReaderGone) as replayable:
the conn is desynced exactly like a transport error, so the tail replays on a
fresh connection with the NoRetry split and MaxRetries budget unchanged.

* fix(autopipeline): make the FD retrySem acquire interruptible

The reader acquired a retry slot with a bare channel send, so with the
semaphore full it parked where it could no longer observe hardClose or
the client context, and session teardown waited on readerDone for as
long as a slot took to free. The acquire now selects on the semaphore
and the autopipeliner context: on Close the request fails with
ErrClosed instead of parking the reader past teardown. Outside of Close
the wait stays bounded — every slot holder is a retry running through
process(), whose timeouts guarantee the slot frees.

* fix(autopipeline): address FD review findings

Four fixes to the ordered full-duplex engine from PR review:

- Recycle the session promptly on a maintenance handoff. The writer now
  observes cn.ShouldHandoff() (set when the reader drains a MOVING/FAILING_OVER
  push) and ends the session with a clean recycle, so the pool queues the
  handoff at Put instead of the connection taking writes to a moving node until
  idle/max-hold and missing the handoff deadline.

- Initialize the held connection with the session-initiating caller's context
  (values only, via WithoutCancel) instead of context.Background(), so a
  CredentialsProviderContext can resolve context-derived credentials. FD holds
  one connection for many callers, so credentials are session-scoped (the first
  caller's), not per-call — the same limitation any shared/pooled connection has.

- Enforce FullDuplexWindow while flushing the backlog on graceful Close:
  writeCarryChunked now applies the same between-chunk window backpressure as
  the serve loop, so Close cannot exceed the documented in-flight bound (~2x)
  when replies are slow.

- Preserve accepted retries during Close: an accepted command that came back
  with a retryable/redirect reply now completes its normal-path retry (tracked
  by retryWg, sem-free during teardown) even when Close races a full retry-sem,
  instead of being failed with ErrClosed.

Adds TestFullDuplexHandoffRecyclesPromptly and a curConn test hook.

* fix(autopipeline): FD review round-2 fixes

Follow-up review fixes on the ordered full-duplex engine:

- Close no longer hangs on the backlog-flush window wait. writeCarryChunked
  bounds its wait for reader drain (a stuck reader with ReadTimeout disabled no
  longer blocks Close forever) and caps each chunk to the remaining window, so
  in-flight cannot exceed FullDuplexWindow during graceful Close.

- The writer polls cn.ShouldHandoff() inside the window gate too, so a MOVING
  mark under sustained backpressure recycles promptly instead of waiting for
  max-hold.

- Off-pipe retries (retryOnNormalConn) run on the caller's context with
  cancellation removed, so a CredentialsProviderContext resolves the same
  identity as the FD session init, instead of context.Background().

- hostHook tracks whether hookDone was awaited, so the panic-recover path never
  re-awaits it (a closed channel makes the double-await harmless today, but the
  flag removes the ambiguity).

* fix(maintnotifications): skip endpoint DNS detect when mode is disabled (#3969)

* Update options.go

* Update options_test.go

* Update options.go

* fix flaky test

* fix(maintnotifications): skip endpoint DNS detect when mode is disabled

* perf(proto): add zero-copy semantics to scan, remove redundant data c… (#3972)

* perf(proto): add zero-copy semantics to scan, remove redundant data conversions in reader

* chore: unpin govulncheck version

* chore: require govulncheck to use latest go version

* chore: pin govulncheck and resolve go-version to stable

---------

Co-authored-by: Nedyalko Dyakov <1547186+ndyakov@users.noreply.github.com>

* fix(autopipeline): FD review round-3 fixes

Follow-up review fixes on the ordered full-duplex engine:

- The FD reader's inline per-command error metric now attributes to the
  caller's request context. The error callback used the background context
  while the duration callback already used req.ctx; both now share req.ctx
  (falling back to bg only when nil) so exemplars and context-scoped
  attributes correlate, matching process().

- The streaming re-auth worker semaphore is sized for the COMBINED ceiling of
  the main pool plus the dedicated pipeline pool. The same hook drives both,
  so sizing to only the main PoolSize serialized pipeline re-auths behind
  PoolSize workers and left pipeline capacity unavailable past reAuthTimeout
  after a credential rotation. Adds NewManagerWithWorkers for explicit sizing.

- PipelinePoolSize doc clarifies the pipeline buffers default to the larger of
  the regular buffer size and DefaultPipelineBufferSize (64 KiB), so each
  pipeline connection allocates at least 64 KiB per direction.

* fix(autopipeline): FD review round-4 fixes

Follow-up review fixes on the ordered full-duplex engine:

- Carry replay now stops when the held connection is marked for handoff. A
  MOVING/FAILING_OVER push drained by the reader marks the conn, but carry
  replay runs BEFORE the serve loop and never polled ShouldHandoff, so a large
  replay kept streaming to a moving node and could miss the handoff deadline.
  writeCarryChunked polls cn.ShouldHandoff() between chunks and in the window
  gate; on a mark it pushes the unwritten remainder and returns the new
  errFDConnMoving sentinel, which run() treats as replayable (like reader-gone
  and panic-recovered) so the whole accepted tail replays on a fresh connection
  instead of being failed. Pinned by TestWriteCarryChunkedStopsOnHandoff.

- Carry replay's window gate is now a predicate loop, matching the serve loop,
  instead of a one-shot if. inflight.room is a cap-1 channel that can hold a
  stale signal (the reader popped, the writer refilled without consuming it); a
  one-shot wait could consume that stale signal with the deque still full,
  compute zero remaining room, leave the chunk cap at MaxBatchSize, and write
  past FullDuplexWindow. Rechecking inflight.len() after every wake fixes it.

- The between-sessions shutdown flush initializes each chunk with a backlog
  request's own context (cancellation removed) instead of the engine background
  context, so a CredentialsProviderContext resolves the right tenant when the
  flush has to initialize a fresh pooled connection. A chunk can mix callers;
  the first request in the chunk is the representative, matching how the
  diverted-retry and session-init paths pick one context for a batch.

- The dedicated pipeline pool spills to the main pool on ErrPoolExhausted too,
  not only ErrPoolTimeout. pipelinePoolOptions clones the client options, so a
  positive MaxActiveConns smaller than the pipeline PoolSize makes Get return
  the hard-ceiling ErrPoolExhausted immediately; failing there broke the
  advertised spill behavior even while the main pool was idle.

* fix(autopipeline): FD review round-5 fixes

Follow-up review fixes on the ordered full-duplex / pipeline-pool paths:

- The pipeline-pool spill no longer charges the Limiter twice. withPipelineConn
  already calls Limiter.Allow and reports the result in its defer, but the spill
  routed through withConn, which repeats getConn.Allow + releaseConn.ReportResult.
  A token-bucket limiter then consumed two tokens per spilled pipeline (or
  rejected the second admission despite idle main-pool capacity) and circuit
  breakers counted the operation twice. Spill now goes through a new
  spillToMainPool helper that uses _getConn/releaseConnToPool, so the operation is
  accounted exactly once.

- Push-notification handler context now reports the pool that owns the connection.
  With a dedicated pipeline pool, a RESP3 notification received while running an
  ordinary pipeline arrives on a pipeline-owned connection, but
  pushNotificationHandlerContext hard-coded ConnPool to the main pool, so a
  handler that operates on the source pool modified the wrong one. It now resolves
  ConnPool via poolForConn(cn) (guarding nil cn).

- FullDuplex GoDoc corrected: a ProcessHook that calls next but MUTATES the
  command (e.g. cmd.Args) or sets its result BEFORE calling next races the
  writer's serialization and the reader's completion, because the write is already
  queued when the host starts. Only observing hooks (read after next) are
  unaffected. The prior "hooks that always call next are unaffected" was too
  broad; gating publication on next would make wire order depend on per-hook
  pre-next latency and break the ordered guarantee, so the constraint is
  documented instead.

* fix(autopipeline): FD review round-6 fixes

- Handoff during carry replay no longer re-executes already-sent commands.
  When the held connection is marked for handoff (MOVING/FAILING_OVER) while a
  recovered carry is still being written, writeCarryChunked now stops and returns
  the UNWRITTEN suffix out-of-band instead of pushing the whole tail for replay.
  The session drains the already-written prefix to completion (those callers get
  their real replies), Puts the connection so the maintnotifications OnPut hook
  performs the seamless handoff (queueHandoff + MarkQueuedForHandoff), and replays
  ONLY the never-sent suffix on the next lease. Previously the whole tail — the
  written prefix included — was replayed at-least-once on a still-alive node,
  re-running non-idempotent commands and missing the seamless OnPut handoff.
  Pinned by TestWriteCarryChunkedStopsOnHandoff (suffix returned out-of-band, not
  pushed) and TestFullDuplexHandoffRecyclesPromptly.

- The dedicated pipeline pool re-resolves MaxConcurrentDials against its own size.
  Options.init caps it to the main pool size, so a pipeline pool larger than the
  main pool (e.g. PoolSize:1 with the default 10-slot pipeline pool) dialed one
  connection at a time, serializing an initial burst into PoolTimeout or a spill
  despite idle pipeline slots. An explicitly lower limit is preserved.

- FullDuplex GoDoc documents that Options.Limiter is admitted once per held
  session, not per command — inherent to the held-connection model. Callers that
  need strict per-command admission/circuit-breaking should not enable FullDuplex.

- An FD command diverted to the normal path after a RETRYABLE reply
  (LOADING/READONLY/TRYAGAIN) no longer exceeds the retry budget by one. The FD
  socket attempt was already spent, so the divert now starts the normal-path retry
  loop at attempt 1 (processStartingAt / processWithRetry startAttempt), keeping
  the total at MaxRetries+1 instead of MaxRetries+2. MOVED/ASK redirects still get
  the full budget (they did not execute on the FD socket). Pinned by
  TestRetryStartAttempt.

* chore(deps): bump rojopolis/spellcheck-github-actions (#3967)

Bumps [rojopolis/spellcheck-github-actions](https://github.com/rojopolis/spellcheck-github-actions) from 0.63.0 to 0.66.0.
- [Release notes](https://github.com/rojopolis/spellcheck-github-actions/releases)
- [Changelog](https://github.com/rojopolis/spellcheck-github-actions/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rojopolis/spellcheck-github-actions/compare/0.63.0...0.66.0)

---
updated-dependencies:
- dependency-name: rojopolis/spellcheck-github-actions
  dependency-version: 0.66.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* fix(autopipeline): FD review round-7 fixes

Follow-up review fixes on the ordered full-duplex / pipeline-pool paths:

- The pipeline pool preserves an EXPLICIT MaxConcurrentDials that equals PoolSize.
  Options.init rewrites a 0 to PoolSize, which made an explicit dial cap (e.g.
  PoolSize:1, MaxConcurrentDials:1) indistinguishable from the default, so
  pipelinePoolOptions widened it to the pipeline pool size — permitting the dial
  burst the option was meant to prevent. init() now records whether the value was
  set explicitly (maxConcurrentDialsSet) and pipelinePoolOptions honors it.

- Successful full-duplex connection-error replays now report real retry_attempts.
  A carried tail that succeeds on a replacement connection reported attempts=1 (and
  terminal failReqs reported zero), unlike the normal command path. fdReq carries an
  attempt count (1 at submit, incremented per replay) fed to the OTel duration and
  error callbacks; the never-sent clean-recycle suffix stays at 1 (first attempt).

- The pipeline-pool spill no longer waits out PoolTimeout. pipelinePool.Get blocked
  up to PoolTimeout (6s default) before returning ErrPoolTimeout, so an eleventh
  concurrent pipeline could stall for seconds even while the main pool was idle.
  Added ConnPool.TryGet (non-waiting turn acquisition, still dials when there is
  room) and the spill uses it, falling back to the main pool immediately at capacity.

- Close no longer fails accepted work on a mid-flush handoff. When a MOVING push
  marks the held conn while Close flushes the backlog, flushBacklogForClose returns
  errFDConnMoving on a LIVE conn; the graceful path treated it as a hard write
  failure (hardClose + failReqs + Remove), skipping the seamless OnPut handoff. It
  now clean-recycles like the serve-loop/carry-replay paths: drain the written
  prefix, Put the conn (OnPut handoff), and complete the never-sent suffix on
  another connection via shutdownFlush.

* fix(autopipeline): correct cached-divert retry budget and spill count

Round-8 review fixes (cursor r3809014600 / codex r3809017107;
codex r3809017101).

F -- retry budget on the cached full-duplex divert:
A cacheable command (for example GET) can ride the FD pipe uncached. On
a retryable non-redirect reply (LOADING, READONLY, TRYAGAIN) it diverts
with startAttempt=1. processCommand routed it to processCached.
processCached dropped startAttempt and ran processWithRetry from 0. So a
diverted cache miss ran one attempt more than MaxRetries+1. Now
processCommand gives startAttempt to processCached, and processCached
gives it to all five processWithRetry calls (four fallbacks and the
fetch). A cache hit returns before the retry loop, so startAttempt does
not apply to a hit.

A -- distinct non-wait pool sentinel (ErrPoolTryFull):
TryGet returned ErrPoolTimeout when the pipeline pool had no free turn.
So getConn counted every deliberate spill as a pool timeout. It changed
Stats.Timeouts and fired the POOL_TIMEOUT and connection-timeout
callbacks. But nothing waited, and the operation then succeeded on the
main pool. Now TryGet returns ErrPoolTryFull for the non-wait case. A
real wait still returns ErrPoolTimeout. The spill path matches the new
sentinel. getConn keys its timeout count on ErrPoolTimeout, so it no
longer counts a spill.

The client also prints a throttled hint on the spill. The hint prints at
most one time per 30 seconds per pool-set. It tells the operator to
raise PipelinePoolSize. A small PipelinePoolSize that spills by design
prints this hint at that rate.

Test: TestTryGetReturnsTryFullWithoutCountingTimeout checks the sentinel
and the zero timeout count.

* fix(autopipeline): count carried FD attempts in the divert budget

Round-9 review fixes (codex r3811864747, r3811864754).

Divert budget: retryStartAttempt ignored req.attempts. A command carried
after a connection error (req.attempts grows on each fdConnErr replay) and
then diverted on a retryable reply started the normal retry loop from the
base, so its total could exceed MaxRetries+1. The divert now adds
req.attempts-1 to the retryable base. A redirect (MOVED/ASK) keeps the base
0, as in r7: the FD socket did not execute the command, and a redirect is
not charged to the retry budget. processWithRetry clamps the start to
maxRetries, so the command still runs at least once. The common case
(req.attempts == 1) is unchanged.

Cluster pipeline dials: ClusterOptions.init pre-normalized MaxConcurrentDials
to PoolSize. Each node's init then treated the value as explicit, so the
pipeline pool used the node PoolSize as its dial cap instead of widening to
PipelinePoolSize. A cluster whose pipeline pool is larger than the node pool
serialized its pipeline dials. The cluster init no longer normalizes
MaxConcurrentDials; the per-node init still normalizes it for the node's main
pool. Test: TestClusterPipelinePoolWidensDials.

* fix(options): latch MaxConcurrentDials explicitness across reuse

Round-10 review fix (codex r3812523456).

A caller may reuse one *Options across more than one NewClient call. The
first init normalizes an unset MaxConcurrentDials (0) to PoolSize. A second
init then recomputed maxConcurrentDialsSet from the normalized value and
marked it explicit, so the second client's pipeline pool used the node
PoolSize as its dial cap instead of widening to PipelinePoolSize. init now
latches maxConcurrentDialsSet on the first call only; clones copy the latched
flags. Test: TestReusedOptionsPreserveUnsetDials.

* fix(autopipeline): complete Close-handoff suffix on a freed permit

Round-11 review fixes (cursor r3813809211, codex r3813782196).

D -- Close-handoff suffix: on Close during a mid-flush handoff, session()
flushed the never-sent suffix while attempt() still held the pipeline conn and
its per-session Limiter permit. A concurrency-capping Limiter at its cap (or
both pools saturated) then rejected the second Allow and deterministically
failed the accepted suffix. session() now RETURNS the suffix (like the recycle
path); run() completes it after attempt() has Put the conn and reported the
Limiter, so the flush gets a fresh permit and connection.

E -- divert duration metric: a diverted command stamped its operation-duration
start on the retry goroutine, after the FD reply arrived, so the metric
excluded the initial FD attempt while the attempt count included it. The divert
now passes req.writtenAt as the operation start, so the duration spans the FD
write to final completion. Retryable replies only; a redirect keeps start 0.

* fix(csc): retire conn on pre-command drain desync; cap divert budget

Two review fixes.

Pre-command push-drain desync (codex r3813782196 follow-up / round-13 audit):
_process drains pending pushes before executing a command, then reads the
reply on the same conn (and, on the CSC-miss capture path, caches it). The
drain runs under a short hard cap separate from the reply read; a fragmented
RESP3 frame straddling that cap leaves the reader mid-frame. The error was
logged and ignored, so the reply read consumed the residue -- a wrong result,
or a wrong-key cache. peekAndProcessPushNotifications now PROPAGATES a
mid-frame error (built-in ProcessPendingNotificationsBuffered; custom
PeekReplyType-then-propagate) instead of swallowing it, and _process now
closes the conn and retries on a fresh one instead of ignoring the error.

Divert retry budget (codex r3815644334): when a command's retry budget is
already spent on the FD socket (req.attempts > MaxRetries) and it then gets a
retryable reply, the reader diverted it and the startAttempt clamp forced one
over-budget send. The reader now diverts a retryable reply only while the
budget is not spent; otherwise it inline-settles with the true attempt count.

* fix(autopipeline): settle FD redirects inline on the standalone path

Round-14 review fix (codex r3817201953).

The full-duplex engine runs only for a standalone *Client, whose normal path
cannot follow a MOVED/ASK -- it neither re-routes to the target node nor sends
ASKING. On a redirect the FD reader diverted the command through
processStartingAt anyway, which re-hit the same endpoint, wasted a round trip,
and returned the redirect regardless. The reader now settles a MOVED/ASK
inline (surfaces it to the caller), exactly as a plain standalone command does;
only retryable replies (LOADING/READONLY/TRYAGAIN) still divert. A
cluster-capable FD/CSC would route the redirect instead -- a follow-up.

* fix(autopipeline): do not report Close-cancel to the limiter

When Close cancels the full-duplex engine context mid-session, the
in-flight attempt fails with context.Canceled. The per-session Limiter
report treated that as an upstream failure, so a circuit breaker backed
off (or opened) on every graceful Close. Map a Close-driven cancel --
context.Canceled with fd.ap.ctx already cancelled -- to a nil result
before ReportResult. Any other error, including a caller-context cancel
while the engine is still running, is reported as-is.

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: sadd amr3e <saddamr3e@gmail.com>
Co-authored-by: Albert Huynh <huynhalb@gmail.com>
Co-authored-by: Vladislav Kotsev <vladygk8@gmail.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* perf(autopipeline): halve full-duplex allocations (#3970)

* perf(autopipeline): ring-buffer FD in-flight queue

The full-duplex unacked in-flight FIFO (fdInflight) was a grow-only slice: the
writer appended at the back and the reader dropped the front by re-slicing, so
the popped-off prefix was never reused and the backing array reallocated +
copied on every window churn — the single largest allocation site on the FD hot
path (31% of per-op bytes at 2048 sync callers). Replace it with a ring buffer
that reuses one backing array for the life of the session and grows only when
live count exceeds capacity (presized to MaxBatchSize).

All observable semantics are unchanged: frontBatch returns the same wrap-aware
copy into the caller's buffer, advance keeps the load-bearing slot-zeroing (so
drained entries don't pin cmd/ctx/batch), takeRemaining unwraps into the same
contiguous ordered tail.

Result (standalone bench, 1ms, 90% GET / 10% SET, sync callers): 770 -> 530
B/op at 2048 callers. Throughput and latency unchanged.

Adds a randomized ring model test (push/advance/frontBatch/takeRemaining vs a
reference slice, driving snapshots and growth across the wrap seam).

* perf(autopipeline): pool blocking-face batch

The blocking full-duplex face allocated a fresh apBatch (struct + done channel)
per command — 23% of per-op bytes on the FD hot path. That face is the one path
where a batch is a pure single-waiter completion signal: it never setReady()s
the command (so the batch is invisible to await/readyBatch/resultReady) and
discards it right after Wait returns.

Pool those batches. Pooled batches use a buffered(1) done signalled by a
non-blocking send (reusable, unlike a closed channel); close() branches on an
immutable pooled flag so every completer (reader, failReqs, shutdownFlush,
flushBacklogForClose) funnels through the one entry point; the batch is recycled
in processBlocking after Wait, gated on pooled && dispGid==0. The async face and
every shared flush batch keep the close()-once channel untouched.

Result (standalone bench, 1ms, 90% GET / 10% SET, sync callers): 530 -> 353
B/op at 2048 callers (770 -> 353 with the preceding ring change, -54%); CPU/op
7.9 -> 6.6 us at 256 callers. Throughput and latency unchanged.

Adds a concurrent batch-pool reuse test (get/signal/wait/recycle under -race).

* chore(autopipeline): silence unused lint on newFDInflight

The ring change moved production construction to newFDInflightCap; the no-arg
newFDInflight wrapper is now called only from tests, and the repo runs the
unused linter with tests:false, so CI flagged it. Mark it //nolint:unused with
the same rationale as peakLen, which is unused for the identical reason.

* test(autopipeline): sparse-traffic pooled-batch coverage

Adds TestFullDuplexSparseTrafficPooledBatch: drives the blocking full-duplex
face with traffic spaced past the idle timeout, so the engine returns its
connection to the pool and re-leases on every command — each command runs in
its own single-entry batch, repeatedly. This is the path where the pooled
completion batch and the idle-return/re-lease machinery meet, guarding against
a mis-delivered wakeup from a recycled batch under intermittent execution.

Checks ECHO round-trips its own token (no misattribution) and per-caller INCR
is strictly sequential (no lost or duplicated completion), with a watchdog so a
dropped completion fails as a timeout rather than hanging. Complements the
existing idle-return and max-hold-recycle tests, which cover the connection
lifecycle but not per-command result correctness under repeated re-lease.

* fix(autopipeline): guard advance on empty ring

fdInflight.advance clamped n down to the live count but then still computed
head % len(buf), which panics on a never-grown ring (nil backing buffer) when
the clamp leaves n==0. Return right after the clamp, restoring the slice
implementation's tolerance of advance on an empty queue. Adds
TestFDInflightRingAdvanceEmpty (advance on a nil-buf ring, and over-advance on
a drained one).

* fix(autopipeline): bound FD inflight ring and recycle rejected batches

Two allocation fixes on the full-duplex path (codex review):

- The session presized the in-flight ring to MaxBatchSize, an uncapped
  soft per-flush threshold. A large MaxBatchSize with a small window then
  allocated that many entries up front -- tens of MB, or an OOM -- and
  again after every idle/recycle. Live in-flight is bounded by the window
  (the writer blocks once in-flight reaches it and caps each drain by the
  remaining window room), so the ring is now presized to
  min(MaxBatchSize, window): capped by the window (the peak) to kill the
  OOM, and by MaxBatchSize so the small-batch default is unchanged. grow()
  remains the backstop up to the window as load ramps.

- The hook-free blocking face drew a completion batch from the pool before
  admission, but the fd.closed, ctx.Done(), and engine-close reject paths
  returned without recycling it. Under backpressure, repeated context
  timeouts discarded one pooled batch and channel per rejected command,
  defeating the pool. Each post-acquisition reject path now returns the
  batch via putFDBlockingBatch (a no-op for the non-pooled shape).

* perf(autopipeline): gated fast-submit for the full-duplex path (#3980)

Adds AutoPipelineOptions.FullDuplexFastSubmit (opt-in): the submit hot path
tries a non-blocking channel send before the blocking three-arm select,
cutting the selectgo cost (~40% of submit CPU) that dominates at high
producer counts on low-RTT links. The fast path is queue-depth gated -- it
fires only while the submit channel is under 10% full -- so once the queue
bursts deep the fair blocking select takes over and the p99 tail is
preserved. The non-blocking send runs under the SAME submit RLock as the
blocking send, so it cannot land in the channel after the shutdown drain,
and the pooled blocking batch is recycled on the reject paths.

Measured (FD blocking face, latproxy, >=1k concurrent callers): +15-33%
throughput at 1ms RTT and +6-18% at 5ms, tail equal-or-better; a no-op on
high-RTT (WAN) links and at low concurrency. Off by default. Pinned by
TestFullDuplexFastSubmitCloseRace.

* fix(autopipeline): fail FD session on push-drain desync

On the full-duplex reply path the reader drained RESP3 push frames ahead of each
reply and only LOGGED a drain error before calling readReply. A custom
PushNotificationProcessor can return after consuming part of a frame, leaving the
reader desynced, so the next readReply would read shifted bytes and misalign every
later reply in the FIFO — a silent wrong-result/wrong-key-cache cascade. The shared
pre-command drainer already treats a custom-processor error as connection-fatal.

Propagate the drain error instead: wrap it as errFDPushDrainFailed and fail the
session. The error is added to the run() replay predicate (alongside
errFDReaderGone / errFDPanicRecovered), so the unacked tail is REPLAYED on a fresh
connection rather than failed — the tail is mostly commands the drain never
touched, and NoRetry commands are still protected by the existing split.

Addresses the codex P1 push-drain finding on #4002.

* fix(pool): TryGet pipeline spill, retry drain desync

Two request-path fixes from the #4002 review:

withPipelineConn acquired the dedicated pipeline pool with Get(ctx), so a saturated
pipeline waited up to DefaultPipelinePoolTimeout (100ms) and recorded a spurious
Stats.Timeouts before spilling to the main pool. Use TryGet: it returns
ErrPoolTryFull at once (no wait, not counted as a timeout), so the pipeline spills
immediately. The spill trigger now matches on ErrPoolTryFull / ErrPoolExhausted.

The pre-command push-drain failure closed the conn and set retryTimeout, but
retryTimeout only makes shouldRetry retry TIMEOUT errors — a custom-processor
sentinel returned shouldRetry=false and the unrelated command failed instead of
retrying on a fresh conn. Add a forceRetry flag, set only when the conn was already
closed (so replay is safe), and OR it into the retry decision.

Addresses the codex TryGet and force-retry findings on #4002. The cluster/ring
PoolStats finding was verified already-fixed on this head (both fold the pipeline
pool), and the FD lease main-pool spill is deferred (replied on-thread).

* fix(autopipeline): bound FD tail retries per-command

The full-duplex reconnect loop reset the session-level retryAttempts to 0 on any
session progress (advancedTotal > 0), then gated tail replay on that counter. Under
a flaky peer that acknowledges some replies and then drops the connection, the
unacked tail therefore got a fresh MaxRetries budget after every partial success,
so a mutating command whose reply is repeatedly lost could re-execute far beyond
MaxRetries+1.

Bound the replay by the head command's own attempt count instead. unacked[0] is
the oldest unacked command (carried longest, highest attempts) and each replay
bumps every carried command's attempts together, so gating on unacked[0].attempts
caps the whole tail at MaxRetries+1 total attempts regardless of interleaved
progress. retryAttempts still drives the backoff escalation only.

Also keep the push-processor error in the chain: the errFDPushDrainFailed wrapper
now uses %w for both causes (was %v for the processor error), so callers can still
errors.Is / errors.As their processor error after retries are exhausted.

Addresses the codex retry-budget and error-chain findings on #4002.

* docs(autopipeline): clarify FullDuplexFastSubmit tradeoffs

Restructure the FullDuplexFastSubmit doc comment into What / Benefit / Drawback so
the fairness cost is explicit: the non-blocking send lets a producer that finds
room jump ahead of producers already blocked on a full channel, which under a deep
queue would starve them and inflate p99. State that the fdFastSubmitGatePct depth
gate is what bounds it.

Also document that ordering is unaffected: the enqueue is synchronous even on the
async (Submit) face (only the reply is deferred), so a caller's command N is on the
ordered channel before submit returns and before submit(N+1) starts — a goroutine's
own commands keep program order regardless of path. Fast-submit changes only
enqueue wait time and inter-producer interleaving (fairness), never intra-caller
order. No code change.

* fix(autopipeline): treat FD push-drain as fatal reply

Three review findings on the full-duplex reply/shutdown paths.

1) Push-drain classification (regression from the earlier %v->%w wrap). The reader
wraps a push-drain failure as errFDPushDrainFailed with %w so callers keep the
processor cause in the chain. But the reply classifier used !isRedisError, which
walks the chain via errors.As, so a drain error wrapping a Redis-typed or retryable
cause was NOT treated as fatal: it fell through to the retryable-reply divert and
was handled as that command's reply, leaving the unread frame in the stream and
shifting every later reply (FIFO desync) — the exact failure the drain propagation
prevents. Add fdReplyIsFatal: errFDPushDrainFailed is always fatal (abort the
session, replay the tail) regardless of the wrapped cause, while a plain Redis
error stays a normal inline reply. This restores the pre-command/in-session
symmetry (pre-command drain -> forceRetry; in-session drain -> fatal). Unit test
TestFDReplyIsFatalPushDrain pins it, including the %w chain staying intact.

2) Shutdown retry budget. shutdownFlush ran the carried tail through
processPipeline, whose retry loop restarts at attempt zero and grants another
MaxRetries+1 executions; a carried command whose budget was already spent (attempts
can reach MaxRetries+1 on the run() replay path before a Close/ctx-cancel routes it
here) could then far exceed its budget. Fail budget-exhausted carried commands
directly instead of re-sending; freshly queued work is untouched, and NoRetry
chunks are already not retried by generalProcessPipeline.

3) In-flight ring retention. curInflight kept the last session's grow-only deque
(up to fd.window, ~MiB at the default) resident through the idle gap between
sessions and after the engine exited. Release it before parking for the next
command and on run() exit; both are safe against the fdConnErr progress read.

Addresses the cursor push-drain finding and the codex shutdown-budget and
in-flight-ring findings on #4002.

* fix(autopipeline): bound FD replay per-command, not by head

Last round bounded the full-duplex replay tail on the head command's attempt count
(unacked[0].attempts <= MaxRetries) to stop a flaky peer from re-executing the tail
past its budget. That over-corrected: when the oldest command has spent its budget,
the head-only gate also denies retries to NEWER commands written behind it in the
same session. With MaxRetries: 3, command A can reach its 4th attempt, command B is
then written for the first time behind it, and a drop before either reply fails BOTH
— B gets none of its configured retries.

Partition the tail by each command's OWN attempt count instead: fail the commands
that have spent their budget (attempts > MaxRetries), keep the rest eligible to
replay, then apply the existing NoRetry split to the eligible suffix. Carried
commands are the oldest (written first, attempts bumped together), so the exhausted
set is the leading run and the eligible suffix keeps FIFO order. Exhausted commands
are failed with the real connection error (aerr), preserving the MaxRetries: 0
behavior. Each command is failed exactly once. The pure fdPartitionByBudget helper
(shared with shutdownFlush) is unit-tested for this case.

Addresses the codex per-command-retry finding on #4002.

* refactor(cmd): thread explicit retry bound into pipeline flush

generalProcessPipeline hard-coded its retry loop to c.opt.MaxRetries. Add a
maxRetries parameter (both existing callers, processPipeline and processTxPipeline,
pass c.opt.MaxRetries, so their behavior is unchanged) and a processPipelineRetries
entrypoint. The autopipeliner's shutdown flush passes each carried command's
REMAINING budget so replaying it on Close honors its configured MaxRetries+1 total.

* fix(autopipeline): recover Close backlog, cap shutdown retries

Two Close-path findings.

1) Never-sent backlog on a dead conn (cursor eAn1r). On the graceful-Close flush,
when the connection is already dead the fd.ch backlog had been written into the
in-flight deque by writeCarryChunked and then failed wholesale with errFDReaderGone,
even though those commands never touched the broken socket. Return the unacked tail
from that path as fdConnErr instead of failing it inline: it flows through run()'s
standard recovery (per-command budget partition + NoRetry split) and, because ap.ctx
is cancelled, the eligible prefix is executed by shutdownFlush on a fresh connection.
The never-sent backlog is completed by execution; a written-but-unacked NoRetry
command is still failed by the split and never re-executed. takeRemaining holds only
UNACKED commands, so nothing already settled is re-run.

2) Shutdown retry budget (cursor). shutdownFlush ran carried commands through a full
processPipeline retry loop, so a partially-spent command could get another
MaxRetries+1 executions and exceed its budget. Flush each carried command with its
OWN remaining budget instead: carry is post-bump (a command carried at attempts=A has
completed A-1 executions), so it may run MaxRetries+1-A more times
(fdCarryRemainingRetries); a spent command (attempts >= MaxRetries+2) is dropped, and
a never-run command (attempts==1, initial or lease/denied Close race) still gets the
full budget. flushCarryBudgeted groups equal-attempt runs and flushes each with its
bound; fresh queue work keeps the full budget and flushes after carry (FIFO). This
also fixes an off-by-one in the earlier drop (attempts > MaxRetries) that discarded a
command with exactly one execution left. The pre-lease attempt bump can only cause an
early DROP on Close, never an extra execution — within the terminating-Close
best-effort contract.

fdCarryRemainingRetries is unit-tested (TestFDCarryRemainingRetries), including the
MaxRetries==0 rows. Asserting end-to-end execution counts needs a counting client
harness the suite lacks; the shutdownFlush integration tests cover completion.

* style(autopipeline): check flushReqs error on shutdown flush

errcheck flagged the final fd.flushReqs call in shutdownFlush. Its error is already
handled inside flushReqs (it fails the remainder on transport failure) and there is
nothing after it, so explicit-ignore the return value with _ =.

* fix(autopipeline): writer alloc cap, reply-deadline, retry split

Three pre-existing full-duplex findings from the review.

Cap writer scratch (eEHir): the serve-loop drain scratch was sized to MaxBatchSize,
but the writer can never have more than fd.window commands in flight, so a large
MaxBatchSize with a small window over-allocated (up to OOM). Size it
min(fd.maxBatch, fd.window), matching the in-flight ring.

Arm reply deadline post-write (eEHiv): writeBatch pushed the batch into the in-flight
deque BEFORE WithWriter, so the reader could arm its per-reply ReadTimeout while a
slow user encoder (BinaryMarshaler) was still serializing — timing out a healthy
connection and triggering a replay that duplicates a mutating command. Publish only
after a clean serialize+flush; a deferred push still adds the batch on an encoder
panic or partial write so conn-error recovery settles every caller. writeBatch is
called synchronously from the serve loop, and closeGraceful runs only after it
returns, so there is no window where the reader exits before the batch is published;
a reader error during the write still recovers the pushed batch via the tail path.

Split retry policy on shutdown flush (eEHim): flushReqs chunked purely by size, so a
NoRetry command (e.g. RawWriteToCmd) sharing a chunk with retryable commands made
generalProcessPipeline disable retries for the whole chunk (cmdsContainNoRetry),
stripping the retryable ones of their budget. Break each chunk at the first
retry-policy change, isolating NoRetry commands like the half-duplex dispatcher.

The divert-vs-session-limiter finding (cursor eEUTx) is replied as a pre-existing
follow-up: honoring it would thread a limiter-skip through getConn and the shared
process path, disproportionate to a Medium edge that needs a concurrency-capping
limiter at capacity.

* fix(autopipeline): cancel cached engine when shared pools close

A WithTimeout/WithReadTimeout clone shares the base client's pools and onCloseHooks
registry but is a distinct wrapper with its own autopipeliner slots. Closing the
clone set the shared apClosed flag and ran the shared onClose hooks, but never
touched the ORIGINAL wrapper's cached autopipeliners — so their full-duplex run()
loop, and the half-duplex shard flushers (same root cause), parked on ap.ctx
forever. New submits were rejected via isClosed(), so nothing could wake them: a
leaked engine per pool-sharing clone close.

Register a shared onClose hook at ap creation that cancels the cached ap's engine
context. Any sharer's Close now cancels it, so run()/the flushers exit. Cancel (not
Close) so the clone's Close does not block on the original's drain; idempotent with
the wrapper's own Close, which still closes its aps directly. onClose.run() fires
before the pools close, so run()'s shutdown flush still completes accepted work.
Two hook ids (blocking + async) cover the at-most-two cached instances per client;
cluster/ring pass a nil shared flag and are unaffected.

TestFullDuplexEngineReapedOnClonePoolClose asserts the engine goroutines are reaped
after a WithTimeout-clone Close (the existing rejection test does not, since a
parked engine still refuses submits).

* fix(pool): keep TryGet non-blocking on occupied dial slots

TryGet acquires a pool turn without waiting, but when PoolSize > MaxConcurrentDials
and every dial permit is held, it then blocked on the dialsInProgress send in
queuedNewConn (and, the same class, on the drainer wait) until an in-flight dial
finished its whole retry sequence or the ctx expired — violating the non-blocking
contract a pipeline spill relies on. Thread the existing wait flag into
queuedNewConn and the drainer-wait branch: when wait is false, give up immediately
with ErrPoolTryFull (freeing the turn) instead of blocking. Get (wait=true) is
unchanged. The straggler-hold gate (HasFreeCapacity) already guarded this exact
saturation; TryGet now matches.

* fix(conn): retire desynced conn on pipeline drain or panic

Two connection-disposition gaps on the shared Pipeline/TxPipeline path.

Push-drain desync: generalProcessPipeline and pipeline/txPipelineProcessCmds only
LOGGED a pre-write processPushNotifications error and kept writing. A custom
processor can consume part of a fragmented RESP3 push frame, so continuing decodes
leftover bytes as replies and shifts results across commands. Propagate the error
instead, wrapped as errConnUnusable.

Panic mid-write: a user encoder panicking inside writeCmd unwound through
withConn/withPipelineConn with fnErr still nil, so their deferred release Put the
possibly partially-written (desynced) conn back into the pool. Recover, mark the
conn errConnUnusable, then re-panic to preserve the caller's panic.

errConnUnusable is a new marker releaseConnToPool removes on (alongside isBadConn),
so both cases retire the conn instead of poisoning the pool. The batch fails with
the error rather than retrying — shouldRetry is deliberately left untouched, so the
caller sees an error instead of shifted results. This changes what Pipeline.Exec can
return for all users; no MOVED/ASK/ErrClosed matcher mis-routes it (verified in the
cluster and maintnotifications/CSC paths — they inspect typed per-command errors).

* fix(autopipeline): refund attempt for never-sent carry suffix

run() bumps the whole carry's attempts before re-issuing it, charging every command
for a send. When the reader dies or the write fails mid-replay, the never-written
suffix was pushed into the in-flight deque still carrying that bump, so run()'s
per-command budget partition could declare it exhausted a replay early (e.g. with
MaxRetries: 1 a suffix command failed after only its original execution).

Refund the bump (fdRefundUnsentAttempt, floored at 0) for the never-sent suffix at
each dead-conn / write-error push site; the next replay re-bumps it when it is
actually sent, so accounting is net-flat. The handoff (errFDConnMoving) path is
untouched: it returns the suffix out-of-band and fdRecycle does not re-bump, so no
double-refund. TestFDRefundUnsentAttempt pins the helper.

* fix(autopipeline): unique per-engine close-hook id

The round-7 shared-pool close hook (which cancels a cached engine when any
pool-sharing wrapper closes) used a per-slot CONSTANT id and registered per call
outside the getOrCreateAutoPipeliner mutex. Two problems:

- A client and a WithTimeout clone that each cache the same face share the onClose
  registry, so the second registration overwrote the first; closing one sharer then
  cancelled only one engine and leaked the other's run loop / shard flushers.
- Registering outside the mutex raced concurrent first-callers (and registered a
  hook per caller, only one of which ap.Close could later unregister).

Register the hook ONCE, inside getOrCreateAutoPipeliner under its mutex, on the
FRESH build, with an id made unique per engine (apCloseHookSeq). ap.Close
unregisters by that id so the registry stays bounded and a closed engine leaves no
stale callback. Cluster/ring pass a nil onClose (they use a nil shared flag and have
no clone-close leak). TestFullDuplexEngineReapedForClientAndClone asserts closing a
clone reaps BOTH the client's and the clone's cached engines.

* fix(autopipeline): cancel raced engine, skip FD shard buffers

Two autopipeline construction findings.

Close race (eJm7t): a WithTimeout clone creating an engine while another
pool-sharing wrapper closes could pass getOrCreateAutoPipeliner's entry
sharedClosed check, build the engine, and register its close hook only AFTER the
concurrent onClose.run snapshotted its callbacks — so the hook never fired and the
engine's goroutines parked on already-closed pools. baseClient.Close sets
sharedClosed before running onClose, so re-check it after registering (still under
the slot mutex): if set, cancel the just-built engine, detach the possibly-missed
hook, and return ErrClosed instead of caching a doomed instance.

Skip FD shard buffers (eJm71): full-duplex submissions go straight to the FD engine
and no shard flusher runs, yet the constructor preallocated each stripe queue to
MaxBatchSize (times apEnqueueStripes on the blocking face). MaxBatchSize: 1_000_000
therefore allocated ~8M interface slots up front regardless of FullDuplexWindow —
tens of MB or an OOM before any command. Skip the prealloc when fdOn; the queues are
never enqueued to and Len reads an atomic, so a nil queue is safe.
TestFullDuplexSkipsShardQueuePrealloc pins it.

* fix(autopipeline): wait for engine flush on shared-pool close

The shared-pool close hook (round-9) cancelled the cached engine and returned, so a
pool-sharing WithTimeout clone's Close ran baseClient.closeResources — closing the
shared pools — while the engine's shutdown flush was still running against them,
failing accepted work instead of completing it.

Extract cancelAndDrain: cancel the engine and wait (bounded by the drainAll
backstop) for its flushers / shutdown flush / shard sweep, WITHOUT flipping
ap.closed or detaching the hook. The hook now calls it, so a sharer's Close waits
for the flush before the pools close (pool still open during onClose.run). ap.closed
stays false on that path — the engine is rejected through the shared-closed flag,
and a later owner Close still runs the full teardown — so
TestSharedClosedRejectsEveryEntryPoint's contract holds. ap.Close layers the
closed-CAS + hook unregister on top of cancelAndDrain. The wait is bounded, so a
wedged flush cannot hang the closing wrapper.

Addresses the codex shared-autopipeliner-shutdown finding on #4002.

* fix(sentinel): stop failover from rebuilding sentinel after close

The shared-pool autopipeliner close hook now drains the engine (cancelAndDrain),
and that drain can dial. On a NewFailoverClient the failover Close hook runs before
the autopipeliner hook (onClose registration order: failover registers at
construction, the engine hook lazily), so a drain-time dial through
masterReplicaDialer -> MasterAddr rebuilt the SentinelClient + pubsub after the only
failover cleanup had already run, leaking them past the pool teardown.

Add a closed flag to sentinelFailover, set under mu by Close. MasterAddr and
replicaAddrs return pool.ErrClosed instead of running the sentinel discovery
fan-out once closed, so a late dial cannot resurrect sentinel resources. Close stays
idempotent. TestSentinelFailover_ClosedDoesNotRebuildSentinel pins it using the
returned error as the oracle (ErrClosed vs the unreachable-fan-out error), needs no
sentinel infrastructure.

Addresses the codex drain-autopipeliners-before-the-failover-hook finding on #4002.

* fix(autopipeline): sent-aware NoRetry gate, tail recovery, retry cap

Five review fixes on the full-duplex replay core, from the #4002 deep review.

Sent-aware NoRetry gate: fdReq gains a sent marker, stamped in writeBatch before
the flush is attempted (a partial write or encoder panic still counts as
possibly-on-the-wire). fdFirstNoRetry now splits the tail at the first SENT
NoRetry only — a NoRetry command recovered from a dead connection's never-written
backlog is replayed (its first send) instead of failed, so the caller no longer
gets an error for a command the server never saw. A sent NoRetry still fails,
never re-sends. Pinned by TestFDFirstNoRetrySentGate.

Stranded-tail recovery: the graceful-Close final-drain branch failed the whole
unacked tail on a reader error; it now returns the tail (plus the never-sent
handoff suffix) as the fdConnErr recovery set, so run()'s budget partition and
the sent-NoRetry split decide, and shutdownFlush executes the eligible prefix on
a fresh connection — same contract as the backlog-flush failure branch.

Recycle-suffix refund: the recycle-then-reader-fail branch re-entered fdConnErr
recovery without refunding the never-sent suffix's optimistic attempt bump, so
with MaxRetries=1 a command could be declared budget-exhausted after one real
execution. Both recovery branches now build their set via fdRecoverTail (append +
refund the suffix only). Pinned by TestFDRecoverTailRefundsSuffixOnly.

Retry-goroutine cap: retrySem was sized to the FD window (default 65536) — a
retryable-reply storm (LOADING during a restart diverts every reply) could park
tens of thousands of goroutines on the main pool. Clamp to 2x PoolSize (the
divert path serializes on the pool anyway); the reader blocking on a full sem is
the designed backpressure, engaging earlier.

Live-replay window gate: writeCarryChunked's bounded stuck-wait escape assumed
ap.ctx was always cancelled, but the function also runs the ordinary session-
start carry replay; a slow first reply (>1s) there dropped the FullDuplexWindow
bound for the rest of the carry. The live path now waits like the serve loop
(reader death bounds it via ReadTimeout), keyed on ap.ctx so a concurrent Close
switches to the bounded escape.

Addresses the never-sent-NoRetry, stranded-tail, refund, retrySem and
window-gate findings on #4002.

* docs(cluster): document MaxConcurrentDials normalization

ClusterOptions.init deliberately leaves MaxConcurrentDials un-normalized (the
per-node Options resolve it so the pipeline pool can widen its own dial cap), but
the field doc claimed '<= 0 defaults to PoolSize', which reads as an init-time
guarantee. State where resolution happens and that introspecting the field after
init still reports the zero value.

Addresses the codex dial-limit-normalization note on #4002.

* feat(autopipeline)!: per-batch Limiter admission in full-duplex

Move Options.Limiter in the full-duplex engine from per-SESSION to
per-BATCH. Before: attempt() paid Allow once at conn lease and
ReportResult once at release, so one permit spanned the whole session.
Now: writeBatch pays Allow before each written chunk and reports the
write outcome (nil on success) right after the flush.

Why: a lease-long permit pinned a circuit breaker's half-open probe
budget for the session lifetime (probe starvation) and produced near
zero failure-signal density (one report per session, however long it
ran). The Limiter's unit everywhere else in the client is one
connection-acquiring wire operation — a single-command attempt, a
pipeline exec, a half-duplex flush — and the FD equivalent of that unit
is one written chunk.

Deny contract: a denied Allow fails ONLY that chunk's commands, with
the Limiter's own error verbatim (errors.Is-able); the connection is
untouched and the session survives, so the next chunk pays Allow again
— fast-fail while a breaker is open, automatic resume when it closes.
The fdDenied session result and its run() arm are gone (unreachable).

Strict pairing: every successful Allow gets exactly one ReportResult
(including on an encoder panic converted to a write error); a denied
Allow reports nothing. Reply errors do not feed ReportResult — a server
that answers is healthy; transport failures surface through the next
chunk's write and through diverted retries, which pair via getConn.

Pinned by TestFDLimiterPerBatch; TestFullDuplexLimiterPerSession is now
TestFullDuplexLimiterPairing (per-batch balance).

BREAKING CHANGE: Options.Limiter is now consulted once per written
batch in the full-duplex autopipeline, instead of once per FD session.

* fix(autopipeline): per-command sent-stamp, hook snapshot, drain-error stamping

Three review fixes on the full-duplex path plus one documented non-fix.

eTWPc — writeBatch stamped sent/writtenAt for the whole chunk BEFORE the
WithWriter serialize loop, so an early encoder panic left the never-serialized
suffix marked sent; fdPartitionByBudget (keys on attempts) then failed a
never-sent NoRetry without its first execution, and at MaxRetries=0 failed the
whole suffix. Stamp per-command inside the loop (only commands the serializer
reached are sent), and refund the optimistic attempt for every !sent command
before the recovery pushBatch.

eR1Nh — a push-drain error that wraps a redis.Error (a custom
PushNotificationProcessor) was classified by isRedisError, so
generalProcessPipeline skipped setCmdsErr on an unwritten pipeline: direct
Pipeline/TxPipeline returned an exec error while every command reported nil, and
the FD shutdown flush could complete them as successes. errConnUnusable now takes
precedence (pipelineErrShouldStamp), so the commands are stamped.

eTWPh — with hooks installed, submit only recorded that hooks exist and hostHook
loaded the live chain later, so an AddHook between accept and execution
retroactively wrapped an in-flight command. submit now snapshots the composed
process-hook chain (one atomic load) and hostHook uses that snapshot, honoring
"hooks are observed only by commands submitted afterward."

eTWPa — a ProcessHook that calls Close from its own hook-host goroutine stalls
until the 30s close backstop. Not code-fixed: a reentrancy guard is unsafe
(cancelAndDrain is not once-only on the shared-pool path, and an early
hostWg.Done races submit's hostWg.Add — a WaitGroup panic). Documented as an
unsupported anti-pattern on the FullDuplex GoDoc and at the hostWg.Wait site;
the backstop bounds it.

Tests in autopipeline_fd_reviewfixes2_test.go, each with a confirmed red-check.

* fix(autopipeline): report reply-side outcome to the limiter

The per-batch full-duplex Limiter reported each chunk's WRITE outcome, so a peer
that accepts writes but closes/times out before returning replies (and whose
replay writes also succeed) fed the Limiter only successes — a circuit breaker
never opened during a read-side failure, contrary to the contract that
ReportResult reflects the allowed operation's result.

Report the chunk's REPLY-SIDE outcome instead. Allow() stays at write admission;
a clean write mints one fdLimiterReport obligation (CAS-guarded, fires
ReportResult at most once) attached to the chunk's LAST in-flight req. The reader
settles it nil once the reply lands and is not transport-fatal (redis.Nil,
WRONGTYPE, MOVED are reply-level, still nil). A write failure/panic reports the
error immediately and pushes no obligation. Every fdConnErr recovery return
(settleTail at the four takeRemaining sites) and the graceful-Close backlog flush
settle any carried obligation with the transport error and clear it; carry replay
re-runs through writeBatch and mints a fresh Allow + obligation, so exactly-once
1:1 Allow/ReportResult pairing holds across success, read failure, write failure,
panic, Close, and connection-move replay. Diverted retries keep their own getConn
pairing.

Addresses the report-read-side-transport-failures-to-the-limiter finding (ed53z)
on #4002.

* fix(autopipeline): refund replay suffix, recover limiter panic, serialize drains

Four review findings, rebuilt on the master-merged base (has #3998 WaitClosed).

F1 — writeBatch refunds a replay suffix by an ATTEMPT-LOCAL marker (the index
serialization reached this call), not the sticky lifetime sent flag. On a
second-session replay where an earlier encoder panics, the never-reached suffix is
now refunded, so at MaxRetries=1 it is not declared exhausted after only its first
execution. First-session behavior is unchanged.

F2 — the FullDuplex GoDoc now describes the reply-side limiter lifecycle (permit
released when the chunk's replies drain, reply-level errors report nil, transport
failures reported via settleTail) so a custom Limiter author sees the real contract.

F3 — cancelAndDrain runs its body exactly once via drainOnce, memoizing the result
into closeErr (single writer, inside the Once). Two closers can reach it for one
engine — an explicit Close racing the shared-pool close hook, which leaves ap.closed
false — and two concurrent drains raced on closeErr and could trip a WaitGroup
Add-concurrent-with-Wait panic in drainAll's staged sweep. WaitClosed reads closeErr
after closeDone, now race-free.

F4 — Limiter.Allow is user code on the engine writer goroutine; a panic escaped
fd.run and crashed the process. fdAllow wraps it in a recover boundary that fails the
still-unwritten chunk (errFDPanicRecovered) instead — no permit granted on panic, so
no ReportResult (strict pairing).

Tests + red-checks in autopipeline_fd_reviewfixes2_test.go. Addresses the
attempt-local-refund, reply-side-limiter-doc, serialize-concurrent-drains, and
recover-limiter-panic findings on #4002.

* fix(autopipeline): recover panics from Limiter.ReportResult

A custom Limiter.ReportResult that panics ran on the FD engine's background
goroutines with no recover boundary. On a write-failure report it escaped fd.run
and crashed the process; on the reply-success path the reader's broad recovery
caught it and treated it as a session failure BEFORE advancing the request, so a
command whose reply was already consumed got replayed — an INCR could execute
twice. (Limiter.Allow panics were already recovered via fdAllow; ReportResult was
left unguarded, and the reply-side reporting from ed53z made it fire on these
paths.)

fdLimiterReport.settle is the single choke point for every FD ReportResult (the
reader success path, writeBatch's write-failure defer, and settleTail all route
through it). Wrap its ReportResult call in a recover that logs and swallows the
panic; the CAS already fired, so strict Allow/ReportResult pairing holds and the
command/reply proceeds exactly as if ReportResult had returned. A faulty limiter
can no longer crash the engine or turn a completed reply into a replay.

Tests + red-checks in autopipeline_fd_reviewfixes2_test.go (the reply-success
no-replay case is the INCR-twice guard). Addresses the limiter-report-panic
finding (codex + cursor, High) on #4002.

* fix(autopipeline): recover metric-callback panics; fix two doc contracts

The FD reader invokes the user-settable OTel duration + native error callbacks
after applying a reply but BEFORE advancing the req out of the in-flight deque. A
panic in either escaped to the reader's broad recovery, which treats an unadvanced
req as an unacked tail and replays it — a mutating command runs twice. Move those
callbacks into reportReplyMetrics, which recovers (logs + swallows) a callback
panic; the reply outcome is already decided, so the caller completes normally and
the reply is never replayed. Mirrors the fdLimiterReport.settle recover. Pinned by
TestFDReportReplyMetricsRecoversCallbackPanic (red-checked).

Docs (behavior already correct; the GoDoc was stale):
- FullDuplex: a standalone full-duplex client surfaces a MOVED/ASK redirect to the
  caller as the command's error (a standalone Client cannot route it) — it is not
  re-run through Process. Only retryable replies (LOADING/READONLY) divert.
- PipelinePoolSize: a saturated pipeline pool spills to the main pool IMMEDIATELY
  via a non-blocking TryGet; there is no DefaultPipelinePoolTimeout grace wait
  before the spill.

Addresses the callback-panic-replay (Medium), redirect-replay-doc, and
pipeline-spill-timing-doc findings on #4002.

* fix(autopipeline): guard all FD metric callbacks; unblock self-reading duration cb

Two engine-safety fixes on the full-duplex metric callbacks.

A panicking user MetricError callback in failReqs/failQueue ran on the engine
goroutine with no recover — a limiter denial, lease failure, or retry exhaustion
could crash the process (or deadlock hostWg.Wait while callers block on hookDone),
leaving accepted commands unsettled. Route every FD metric-callback emit
(reportReplyMetrics duration+error, failReqs per-req, failQueue per-req) through one
recover-guarded emitMetricsGuarded; SetErr precedes it and complete() follows, so a
panic still settles each command and wakes its caller. Per-req placement keeps a
panic on one req from stranding the rest.

A custom RecordOperationDuration that reads its OWN command (cmd.Err()/String())
blocked on batch.done because the FD reader completes the req only after the
callback returns, and the reader was not the batch executor — wedging the reader and
the FD window. reportReplyMetrics now registers the reader as the batch executor
around the callback, so the accessor guard returns the just-set view without
blocking (same escape a dispatch hook uses). No post-complete reorder — that would
race a hook host that may free the command.

Tests + red-checks in autopipeline_fd_reviewfixes3_test.go. Addresses the
metric-callback-panic (High), error-recorder-panic, and blocking-duration-callback
findings on #4002.

* fix(autopipeline): abort ordered shutdown flush on panic/unusable-conn; harden release drain

Four findings on the Close/shutdown and release paths.

F1 — flushReqs recovered an encoder panic during the Close-time carry flush but had
an UNNAMED return, so it returned nil; flushCarryBudgeted/shutdownFlush then treated
the failed group as success and ran later ordered groups and the fresh queue after
an earlier command never completed. Named return set in the recover defer aborts the
ordered flush like a transport failure.

F2 — flushReqs's per-chunk abort keyed on !isRedisError, so an errConnUnusable
wrapper (a custom push processor returning a redis.Error during the close drain) was
treated as an ordinary reply and later chunks ran though the earlier chunk was never
written. Use pipelineErrShouldStamp so errConnUnusable takes precedence and aborts.

F3 — a release-time push-drain error from a custom processor removed the conn only
when isBadConn recognized it; a partially-drained (desynced) conn could otherwise
return to the pool. Remove unconditionally on a non-nil release-drain error. The
built-in processor returns nil on a benign nothing-consumed timeout, so this only
removes on genuine desync (defense-in-depth over putConn's buffered-data guard).

F4 (cosmetic) — newFDEngine resolved a zero FullDuplexWindow into a local only, so
Config() reported 0 instead of the enforced 65536; write the resolved value back.

Tests + red-checks in autopipeline_fd_reviewfixes4_test.go (a nil-by-default
runPipeline seam gives deterministic redis-free F1/F2 tests; production unchanged).
Addresses the shutdown-flush-panic, shutdown-flush-errConnUnusable, release-drain
conn-remove, and Config-window findings on #4002.

* test(autopipeline): consolidate full-duplex tests into one file

Merge the seven mergeable full-duplex test files into
autopipeline_fullduplex_test.go and delete the sources: the four
autopipeline_fd_reviewfixes*_test.go files plus autopipeline_fdpool/fdring/fdsparse.
Pure move; 70 Test funcs preserved, no duplicate declarations. The two //go:build
fuzz files stay separate (they cannot fold into an untagged file). Also swaps the
obsolete hook-snapshot test for TestFDLiveHookChain, matching the snapshot removal in
the next commit.

* refactor(autopipeline): drop submit-time hook snapshot

The full-duplex hook host captured the hook chain at submit time (hookSnapshot /
withProcessHookSnapshot) so a hook added between submit and host execution could not
retroactively wrap an in-flight command. Process hooks are added at client
construction, before the client serves traffic (user AddHook, or redisotel at setup),
so the deferred host can load live hook state like the synchronous path. Remove
hookSnapshot and withProcessHookSnapshot (hooksMixin + the cmdableClient interface)
and gate the FD host on hookCount() with withProcessHook.

Also simplifies the comments in autopipeline_fullduplex.go to shorter Simplified
Technical English, no behavior change.

* fix(autopipeline): spill full-duplex lease to the main pool; bound close-time retries

Two full-duplex engine resource fixes (same file).

Spill (e7Z0d): fdEngine.attempt leased with a BLOCKING Get on the dedicated pipeline
pool and never fell back, so a saturated pipeline pool (e.g. PipelinePoolSize:1 shared
with regular pipelines) failed already-accepted commands — unlike withPipelineConn,
which TryGets and spills to the main pool. Mirror it: TryGet, then spill to the main
pool on ErrPoolTryFull/ErrPoolExhausted or a pipeline-conn init failure; track the
source pool so remove/release returns the conn to the pool that owns it. With no
dedicated pipeline pool, fd.pool is the main pool and the lease acquires directly.
Cost: a spilled FD session holds a main-pool conn until idle/maxHold.

Close-time retry bound (e6AA8, cursor e7q9z): retryOnNormalConn bypassed retrySem on
ap.ctx cancellation and ran slot-less, so a Close mid retryable-reply storm could
spawn one goroutine per in-flight reply (up to FullDuplexWindow) and OOM. Blocking on
the sem instead can DEADLOCK with the spill above (a spilled FD session pins a main
conn until the reader drains). So on Close: take a slot if free, else fail the command
ErrClosed — never block, never run slot-less. retryWg still covers granted slots.

Pinned by TestFullDuplexSpillsToMainPoolWhenPipelinePoolFull (server-gated) and
TestFDRetryOnNormalConnBoundedDuringClose. Known narrow residual (PoolSize:1, OFF
Close): an off-pipe retry that blocks the reader can deadlock while an FD session pins
the sole main-pool conn until the reader drains. The spill EXTENDS the reachable set —
before, this needed PipelinePoolSize<0 (fd.pool IS the main pool); now a saturated
dedicated pipeline pool reaches it too via the spill. The Close path above is
non-blocking, so it is unaffected. Called out on the review thread; not fixed here.

* fix(redis): replay NoRetry commands after a pre-write drain desync

When a pre-command push-drain fails, _process closes the conn before the command
reaches the wire and sets forceRetry, so replay is the command's FIRST execution and
safe. But processWithRetry still short-circuited on cmd.NoRetry(), so a RawWriteToCmd
or ZeroCopyStringCmd returned the drain error without ever executing, contradicting
the clean-replay the forceRetry comment promised.

_process now surfaces forced (the pre-write desync) distinctly from retry; the gate
becomes cmd.NoRetry() && !forced, so a never-written NoRetry command replays while
NoRetry still blocks ambiguous write/read failures (a possibly-written command).
Mirrors the full-duplex sent-marker rule. A pre-write-drain replay counts as an
attempt in the retry/OTel accounting (each loop pass counts), consistent with the
existing counter. Addresses the retry-NoRetry-after-pre-write-drain finding (e6AAz).

* fix(autopipeline): don't block the reader on retrySem while the session is spilled

When the FD session holds a MAIN-pool connection — a spilled lease (PipelinePoolSize
saturated) or the no-dedicated-pool case — off-pipe retries on a retryable reply wait
for that same connection. Once retrySem filled, retryOnNormalConn's blocking send
parked the reader; the writer's idle/max-hold recycle then waited for the parked reader,
so the session could not release the connection until a retry's pool acquisition timed
out. A burst of retryable replies could stall every in-flight command for PoolTimeout.

Track the session's pool in curConnSpilled (set in attempt before the reader is spawned;
biased true when a lease is undecided so an unknown state never blocks). In
retryOnNormalConn: take a free slot if available; on Close fail ErrClosed (unchanged);
while spilled, run the retry SLOT-LESS rather than block, so the reader keeps draining
and the session can release the pinned conn; otherwise block for backpressure as before.
Residual: up to a window of transient retry goroutines while spilled, reachable only
with a saturated pipeline pool at a small PoolSize — documented, and the lesser evil
versus a hang. Pinned by TestFDRetryOnNormalConnSpilledDoesNotBlockReader.

Addresses the avoid-blocking-retries-behind-their-own-spilled-lease finding (fCCni).

* fix(internal): drain pooled semaphore timer unconditionally

putSemTimer gated its stale-fire drain on `!t.Stop()` and skipped it when Stop
returned true. Stop's return cannot decide whether a fire is pending: it reports
false for an already-expired timer, but a fire can settle into the channel while
Stop runs, and Stop can report true in that window. A timer holding an
undelivered fire then went back to the pool dirty, and the next Acquire's
Reset-and-select read the stale value as an instant timeout.

Drain unconditionally with a non-blocking select/default after Stop. It is safe
under both timer-channel semantics and never hangs: after Stop the timer is
disarmed, so at most one fire can be pending; the default branch runs when none
is.

Observed in CI on Go 1.24 (oldstable, GODEBUG unset) as a spurious timeout in
TestFIFOSemaphoreNoStaleTimerFire. Not reproduced locally on go1.24.13 or
go1.25.3 across tens of thousands of iterations; the fix holds by construction,
since Stop's return cannot detect a pending fire.

* fix(autopipeline): harden full-duplex serve loop and shutdown

Bot-review batch for the full-duplex autopipeline engine.

Recover custom-Cmder user code that runs on run()'s serve loop, which has no
top-level recover — a panic there killed the engine and stranded every in-flight
and future command. cmdApproxBytesSafe wraps the batch-sizing Args() call: on a
panic it fails and DROPS just that command before any bytes are written, instead
of letting it reach writeBatch, whose write-time recover would tear the session
down and replay the poisoned command's innocent batch-mates at-least-once.
Applied at both serve-loop sizing sites. The carry-replay sizing is deliberately
left unguarded: its commands already passed serve-loop sizing, so a deterministic
panicker never reaches it, and guarding there could only tear down a HEALTHY
connection (nothing written yet) to cover a non-deterministic Args() — a deeper
contract violation. fdFirstNoRetrySafe wraps the NoRetry() classification
scan: on a panic it declines to replay and fails the tail conservatively (a
command that cannot be classified, and may be a sent NoRetry, must not be
re-sent).

Make the non-spill retrySem acquire in retryOnNormalConn cancellable: a Close
arriving while the reader is parked on a full sem would deadlock cancelAndDrain,
which waits on the reader to advance the deque. On ctx cancel it now fails
ErrClosed, matching the documented Close rule.

Drain clone engines before detaching the shared-pool close hook: Close now
unregisters via defer AFTER cancelAndDrain, so a pool-sharing wrapper closing the
shared pool concurrently still blocks on this engine's shutdown flush before the
pools are torn down. The defer also survives a drain panic.

Publish the resolved FullDuplexIdleTimeout and FullDuplexMaxHold so Config()
reports what the engine enforces (a zero field -> its default), matching the
window write-back already present.

Anchor the OTel duration on the command's FIRST write, not the last replay: stamp
writtenAt only when zero so the duration spans the whole retry sequence.

Document the AutoPipeliner hook contract (call next; no Close/control call from a
hook; no panic; no state mutation).

Tests pin the recover wrappers (cmdApproxBytesSafe / fdFirstNoRetrySafe) and the
resolved-config reporting. Not covered by a server-driven harness in this batch:
the panicking Args()/NoRetry() paths end-to-end through the live engine, the
non-spill Close race, and the concurrent shared-pool-close teardown timing.

Addresses the serve-loop-sizing-panic (1489), NoRetry-scan-panic (1997),
non-spill-close-deadlock (875), clone-drain-on-shared-close (2023), unreported-
FD-config (555), replay-duration-anchor (1797) and hook-contract (943) findings.

* fix(csc): report the FD attempt on a diverted cache hit

On the full-duplex divert (retryOnNormalConn) a cacheable command spends its
first attempt on the FD socket, then re-enters processCached with startAttempt>0.
If it is served as a cache HIT it returns before processWithRetry's loop — the
only place state.attempts is set — so the OTel duration callback reported zero
attempts, hiding the FD attempt. Seed state.attempts = startAttempt at entry so a
diverted hit reports the attempt it already spent. startAttempt governs the
reported COUNT here, not the retry BUDGET (a hit does no network attempt); a
miss's processWithRetry overwrites the seed.

Addresses the diverted-hit-attempt-count (811) finding.

* docs(client): correct WithTimeout autopipeliner note

The old note said a clone's autopipeliner is NOT stopped by the parent's Close.
That is misleading: the clone's engine registers a close hook on the shared pool,
so closing any pool-sharing wrapper drains and stops every registered engine's
flusher before the shared pools are torn down — no flusher outlives the pool. The
drain does not mark the clone's autopipeliner closed, so an explicit clone Close
still fully tears it down (idempotent).

Addresses the WithTimeout-lifecycle-doc (1052) finding.

* Revert "fix(internal): drain pooled semaphore timer unconditionally"

This reverts commit 45f0159217.

* fix(autopipeline): address Copilot review (config accuracy, hook doc)

Report the EFFECTIVE full-duplex state in Config(): a requested FullDuplex on a
*ClusterClient (or a client with no pipeline pool) falls back to the half-duplex
shard flushers, so publish ap.config.FullDuplex = fdOn — Config() promises what
the engine runs, not the raw request. (fS1cr)

Validate the full-duplex tuning fields (FullDuplexWindow / FullDuplexIdleTimeout /
FullDuplexMaxHold) only when FullDuplex is enabled; they are consumed only then
(newFDEngine), so a leftover negative on an inactive field no longer rejects an
otherwise valid half-duplex config. (fS1be)

Rewrite the AddHook hook contract to split the two dispatch modes: in half-duplex
mode a hook may short-circuit by returning without calling next (a supported
mock/cache pattern); in full-duplex mode the command is already queued on the held
connection, so a short-circuit cannot cancel the write, and a synchronous Close
from a hook blocks until the close backstop (call Close from a separate goroutine).
The prior text claimed 'call next' was mandatory and that a re-entrant Close
returns without waiting, both of which contradicted the implementation. (fS1dg)

* fix(autopipeline): contain panics in the full-duplex engine

The full-duplex engine runs user code (a Cmder's Args()/encoder, Options.OnConnect, a
Limiter, metrics callbacks) on its single background goroutine, which had no top-level
recover on the writer/attempt paths. An unguarded panic killed the whole engine and let
attempt()'s deferred release Put a live or half-initialized connection back into the
pool.

- fdNoRetrySafe wraps the reader's reply-path NoRetry(): a panic there is recovered and
  the command treated as non-retryable, so the already-landed reply is surfaced inline
  instead of reaching the reader's session recover, which would replay an answered
  (possibly mutating) command.
- fdBatchEndSafe wraps carry/Close-backlog sizing: a panicking Args() fails+drops just
  that command and the session continues on the healthy conn, instead of tearing it
  down. Covers the session-start command and the Close backlog, which never pass the
  serve loop's cmdApproxBytesSafe admission.
- attempt gains a recover for the acquisition/init phase: it retires the conn (Remove,
  never Put) and returns the carry as fdLeaseErr, the same disposition an init error
  gets. A session writer-path backstop runs the fdConnErr teardown for any other panic.

Pinned by TestFDNoRetrySafeRecoversPanic, TestFDBatchEndSafeRecoversArgsPanic,
TestFDReaderNoRetryPanicSurfacesInlineNoReplay, TestFDCarryArgsPanicDropsCommandKeepsSession,
TestFDAttemptInitPanicRetiresConnFailsLease.

From the review of #4002.

* fix(sentinel): drain autopipeliner before tearing down Sentinel

onCloseHooks.run invoked close hooks in registration order. The Sentinel failover
teardown registers at construction (first) and an autopipeliner drain registers lazily
(later), so on Close the Sentinel client was torn down before the drain ran: MasterAddr
then returned pool.ErrClosed and the drain could not dial a replacement connection for
accepted-but-unsent work, failing replayable commands even though Redis and the pools
were still up (a sibling pool-sharing clone triggering Close was enough).

Invoke hooks in REVERSE registration order (LIFO): a later-registered consumer runs
before the earlier-registered infrastructure it depends on. The dependency is one-way
(the drain needs discovery; discovery never needs the drain), so LIFO is the correct
teardown order. Order tests updated to the LIFO contract.

From the review of #4002.

* docs(autopipeline): add full-duplex engine spec

Document the FD engine model (run/attempt/session, reader + writer, carry replay), the
panic boundaries, the LIFO close ordering, and the two known limitations tracked as
follow-ups: CSC is not consulted on the FD fast path, and terminal failures emit no
operation-duration metric.

From the review of #4002.

* fix(autopipeline): register off-pipe retries as batch executors

When a retryable full-duplex reply diverts to the normal client path, retryOnNormalConn
runs processStartingAt on its own goroutine. A custom RecordOperationDuration callback
inside that call may read its own command (cmd.Err()/cmd.String()), which awaits
batch.done — closed only by req.complete() AFTER processStartingAt returns. The retry
goroutine then waits on its own completion and wedges, and Close waits for the backstop.

Register the retry goroutine as the batch's executor for the processStartingAt call via
req.batch.enterNodeDispatch(), mirroring the reader-side reportReplyMetrics guard, so the
accessor hands back the just-set view instead of blocking. The reader-side guard did not
cover this off-pipe retry path.

From the review of #4002.

* fix(autopipeline): guard error classification before settling requests

classifyCommandError calls err.Error(), which is user-reachable (an error returned by a
custom Limiter) and can panic. failReqs and failQueue classified the error BEFORE
completing their requests, and the failing chunk is not in the in-flight queue, so an
escaping panic there left every caller blocked forever — session recovery cannot reclaim
a chunk that never reached writeBatch.

Route both through classifyCommandErrorGuarded, which recovers a panic in err.Error() and
falls back to empty classification, so the requests still settle. reportReplyMetrics
already classifies inside emitMetricsGuarded. Pinned by
TestFDFailReqsClassifyPanicStillCompletes.

From the review of #4002.

* docs(autopipeline): correct Close-path panic-isolation in the FD spec

The spec attributed the takeQueue Close backlog to the fdBatchEndSafe drop-and-continue
path. That is wrong: takeQueue feeds shutdownFlush -> flushReqs, an ORDERED flush that
aborts the remainder on any failure (flushCarryBudgeted), which is intentional — later
accepted commands must not complete after an earlier ordered one failed
(TestFDShutdownFlushAbortsAfterRecoveredPanic). Name both Close paths and their different
policies: in-session (flushBacklogForClose -> writeCarryChunked -> fdBatchEndSafe,
drop-and-continue) vs idle (takeQueue -> shutdownFlush -> flushReqs, ordered abort).

Doc only; no behavior change. From the review of #4002 (cursor r3937508950).

* fix(pipeline): contain FD release panic, bound carry-replay hold

Three findings from the #4002 re-review (codex P2 batch):

- attempt's connection release runs LAST (LIFO, after the init recover), so
  releaseConnToPool draining a custom push processor had no outer boundary: a
  panic there escaped fd.run and leaked the leased conn. Wrap the release in a
  nested recover that Removes the conn on panic (a partially-drained conn is
  desynced, so Put would poison the pool).

- Carry replay runs before the serve loop, so a long or backpressured replay
  never reached the serve loop's max-hold select and could pin a connection past
  FullDuplexMaxHold. writeCarryChunked now watches the max-hold timer on both
  LIVE paths (between-chunk poll and the window-gate wait) and returns the
  unwritten suffix out-of-band as errFDMaxHold, routing to the clean fdRecycle
  arm; the Close flush passes a nil timer (Close bounds its own wait).

- Correct the stale DefaultPipelinePoolTimeout GoDoc: it no longer gates the
  main-pool spill (withPipelineConn uses a non-blocking TryGet); its live effect
  is the pipeline connection's maintnotifications drainer-handoff budget.

Pinned by TestFDReleasePanicRetiresConn, TestWriteCarryChunkedStopsOnMaxHold,
and TestWriteCarryChunkedStopsOnMaxHoldUnderBackpressure (each red-checked).

From the review of #4002.

* fix(autopipeline): scope XREAD BLOCK to option section

isBlockingCmd scanned every argument of XREAD/XREADGROUP for a "block"
token, so a stream KEY literally named "block" (which appears AFTER the
STREAMS keyword) was mistaken for the BLOCK option and the non-blocking
command was diverted off the ordered pipe, letting a later command run
first. Stop the scan at STREAMS. Adds TestBlockingDetectionStopsAtStreams.

* docs(autopipeline): correct FD close-flush comments

fdBatchEndSafe and its writeCarryChunked caller claimed they contain a
panicking Args() from the takeQueue Close backlog. They do not:
fdBatchEndSafe is called only from writeCarryChunked (live-session carry
re-issue); the terminal Close backlog flushes through shutdownFlush/
flushReqs, whose outer recover deliberately aborts the ordered flush (every
accepted command still settles). Correct the comments and document the abort
on flushReqs. Comment-only; no behavior change.

* fix(sentinel): close partial client on ctor panic

NewFailoverClient created the main, pub/sub and pipeline pools but, unlike
NewClient, had no deferred cleanup: a panic after the pools exist (e.g. a
PipelinePoolSize overflowing int32 in buildPipelinePool) left them and their
MinIdleConns dialing goroutines with no reference to Close. Add the same
built/defer-Close guard NewClient uses. Adds
TestNewFailoverClientPanicClosesPartialClient.

* feat(autopipeline): full-duplex on cluster clients (#4008)

* feat(autopipeline): full-duplex on cluster clients

FullDuplex was a no-op on a *ClusterClient: the engine needs one held connection
with a writer/reader pair, which only a standalone *Client with a pipeline pool
provides, so the cluster autopipeliner silently fell back to the half-duplex
shard flushers and Config().FullDuplex reported false.

Run it natively per node. Every master's node.Client is a standalone *Client that
gets a pipeline pool by default (PipelinePoolSize>=0). A new clusterFDRouter keeps
one FD child autopipeliner per master (each on that node's node.Client) and routes
each command to the child that owns its slot, resolved against the live cluster
state on every submit. The parent AutoPipeliner keeps diversion (blocking /
fan-out / ReqSpecial / HIMPORT) on the *ClusterClient; router misses (keyless,
unresolved slot, a non-FD node) fall back to ClusterClient.Process.

A construction gate (clusterFDOn) forces a single flusherless shard like the
standalone fd path so no half-duplex flusher runs and enqueue's shard indexing
stays safe. It requires PipelinePoolSize>=0 (so every node.Client has a pipeline
pool) and bails when the client is configured for replica routing (ReadOnly /
RouteByLatency / RouteRandomly): the router always routes to the slot master, so
those options would be silently ignored — fall back to the half-duplex flushers,
which honor the ShardPicker. Config().FullDuplex reports the effective per-node
state (false when the gate bails); NumShards reports 1; the FD tuning defaults
(window / idle timeout / max-hold) are resolved onto the parent config so Config()
reports the effective values rather than the raw zeros, matching the standalone FD
path; Len sums the children;
Close marks the router closed, then closes the children before the parent drain
and before clusterNodes closes the node clients; a child-drain failure is joined
into AutoPipeliner.Close's result (taken from each child's WaitClosed, which is
authoritative even when an application-initiated close won the child's close CAS)
rather than masked by the empty shard sweep. A
submit that races Close sees the closed flag and rejects with ErrClosed rather
than resurrect a swept child, and a child built in that race window is discarded
instead of leaked. A node.Client that already hosts an app-created standalone
autopipeliner (no cluster reprocess) is diverted to Process rather than dispatched
through an engine that cannot follow redirects.

MOVED/ASK and retryable replies are followed rather than surfaced. The FD reply
path already diverted retryable replies to a normal-path reprocess but surfaced
redirects inline because FD was standalone-only. The engine now takes an
injectable reprocess fn (AutoPipelineOptions.clusterReprocess, internal) plus a
redirectAware flag; the router points each child's reprocess at
ClusterClient.process (routes to the target node, ASKING for an ASK, LazyReload on
MOVED, its own retry loop for a retryable reply, all bounded by MaxRedirects) and
flips redirectAware so the reader diverts MOVED/ASK AND retryable replies
(LOADING/READONLY/TRYAGAIN/CLUSTERDOWN/...) there. A MOVED/ASK is followed even for
NoRetry commands (GetToBuffer, RawWriteTo): the reply carries no payload, so there
is nothing to replay and cc.process follows redirects for every command; only
retryable execution errors stay gated on NoRetry. The retryable divert does not
gate on the node client's MaxRetries (cluster node clients default it to -1);
cc.process owns the whole cluster budget. The standalone default is unchanged
(processStartingAt, redirectAware=false).

Also skips the redundant child AutoPipeliner.submit on the hot path (the router
submits straight to the child's FD engine), which the parent already classified.

Known limitations (tracked as follow-ups):
- Hooks registered on the ClusterClient do not wrap FD child submits: the children
  run their own node client's process hooks, so a hook added on the cluster client
  stops firing under cluster FD. Use ClusterOptions.OnNewNode for per-node hooks.
- Connection-fatal failures on the FD socket replay the unacked tail on a fresh
  connection of the SAME node client, bounded by a recovery budget of MaxRedirects
  (the engine uses that, not the node client's MaxRetries, which cluster normalizes
  to -1). They do not route through ClusterClient.process, so a permanently dead
  master is not cross-node failed over the way half-duplex does — its resends
  surface as errors (or, once topology reloads, as MOVED replies that then divert).
  Reply-level retryable errors (incl. MASTERDOWN/CLUSTERDOWN) DO route through
  cc.process.
- Node routing runs on the submitter: the first command on a fresh client may block
  on the initial topology load, and a canceled per-command context can fail routing
  before the engine admits the command (unlike the deferred face's engine-owned
  context). Half-duplex routes in the flusher, off the submit path.
- Redirect/retry accounting: the cluster reprocess drops the FD attempt's
  startAttempt/writtenAt, so the initial on-wire attempt is neither counted against
  MaxRedirects (a redirect/retry gets a fresh full budget) nor reflected in the
  duration/attempt telemetry.
- Per-node children are retained in the router until the parent AutoPipeliner is
  closed: a long-lived cluster with sustained node-address churn (failovers, DNS /
  scaling changes) accumulates closed child engines rather than evicting them.

Tests: FD engaged per master (each child engine carrying retryBudget=MaxRedirects),
300-key mixed GET/SET zero errors, diversion (DBSIZE/BLPOP) intact, clean Close +
reuse, and a forced MOVED both followed and (with MaxRedirects=0) surfaced (these
self-skip without a local cluster). Three pure construction checks need no cluster:
the replica-routing gate reports FullDuplex=false, a default-constructed cluster-FD
autopipeliner reports the resolved FD tuning defaults (nonzero window/idle/max-hold)
from Config(), and the per-node recovery budget is seeded from MaxRedirects.

Refs #4002.

* perf(cmd): drop alloc in command policy resolver

NewDefaultCommandPolicyResolver split the command name on '.' with strings.Split,
allocating a slice on every call. The resolver runs on the per-command hot path
(the cluster routeAndRun/process paths and the autopipeline cluster gates), so the
allocation showed up in CPU profiles. Use strings.IndexByte to detect the single
module.command form without allocating; behaviour is unchanged.

Refs #4002.

* fix(sentinel): guard typed-nil pool on ctor panic

newConnPool returns a nil *pool.ConnPool for an out-of-range PoolSize;
assigning it straight into the pool.Pooler interface field left a
typed-nil that closeResources' `!= nil` check treats as present, so the
ctor-panic cleanup nil-dereffed inside ConnPool.Close and replaced the
intended "failed to create connection pool" panic. Assign through a local
after the error check, as NewClient already does. pubSubPool is a concrete
*pool.PubSubPool whose nil is caught correctly, but assign it the same way
to keep this constructor identical to NewClient.

Refs #4002

* fix(autopipeline): contain FD panic-safe SetErr, cluster spec, node-close race

Three bot findings from the 2026-09-08 re-review of 5e50f2b3:

- .claude/specs/full-duplex.md still described FD as standalone-only and
  cited a nonexistent AutoPipelineConfig type; document the cluster child
  router (clusterFDRouter) it never covered.
- The FD reader's post-reply cmd.SetErr(e) call had no panic guard, unlike
  its NoRetry()/Args() siblings — a panicking custom Cmder there would
  leave the request uncounted and get replayed by the session-failure
  recover, executing a mutating command twice. Add fdSetErrSafe (mirrors
  fdNoRetrySafe) and apply it at all three SetErr call sites on this path,
  including the off-pipe retry goroutine's own recover, which previously
  had no recover of its own for a second panic mid-unwind.
- clusterFDRouter.submit surfaced a raw ErrClosed when a node's per-node FD
  child closed underneath it (cluster topology GC racing a live submit,
  including while backpressured on a full fd.ch) instead of following its
  own documented routing-miss fallback. Detect that specific rejection
  shape and re-resolve the child once before the async face is armed, so
  a GC'd node self-heals or cleanly diverts to Process.

Tests: TestFDSetErrSafeRecoversPanic, TestClusterFullDuplexChildCloseSelfHeals.

* fix(autopipeline): locate BLOCK by position, not value

isBlockingCmd stopped its XREAD/XREADGROUP/TS.READ arg scan on the
first literal "streams" token, treating it as the structural STREAMS
keyword. But STREAMS is only a real terminator for XREAD/XREADGROUP,
and XREADGROUP's GROUP/consumer names (fixed-position, arbitrary
values) sit before the option section — a consumer literally named
"streams" hit that case first and returned non-blocking before ever
reaching a real, later BLOCK option. TS.READ has no STREAMS keyword
at all, so a key or timestamp named "streams" tripped the same case
for no reason. Either way a genuinely blocking raw command could ride
the shared full-duplex pipe and stall it (cursor bugbot on #4002).

Split the scan per command: XREAD starts after the command name,
XREADGROUP skips the fixed GROUP/consumer positions before scanning,
and TS.READ (which never has a STREAMS terminator) just looks for
BLOCK anywhere. Adds coverage for group/consumer/key names that
collide with "streams" or "block".

* fix(autopipeline): panic-safe SetErr, clear stale ErrClosed on retry

Three full-duplex bugs surfaced by bugbot/codex review on #4002:

- hostHook's normal result assignment and its own recovery-defer
  reassignment both called Cmder.SetErr directly. A custom Cmder whose
  SetErr panics would panic on the first call, enter the recovery
  defer, and panic AGAIN on that defer's own SetErr call — a second,
  unrecovered panic that kills the hook-host goroutine (and the
  process) before b.close() ever wakes the caller. Both call sites now
  go through fdSetErrSafe.

- failReqs and failQueue called Cmder.SetErr directly on the sole
  fd.run goroutine, which has no outer recover; a panicking custom
  Cmder there would kill the engine and leave every other req in the
  batch unsettled. Both now go through fdSetErrSafe.

- clusterFDRouter.submit retries once when a child reports ErrClosed
  (self-healing a torn-down node child), but never cleared that stamp
  before the retry. failReqs only stamps a NEW error when rawErr() is
  nil, so a post-retry lease failure, limiter deny, or budget
  exhaustion on the fresh child kept the stale ErrClosed instead of
  the real cause, making a live engine look closed to the caller.
  Clear cmd's error before the retry attempt; a successful retry is
  unaffected either way since the reader's inline completion already
  overwrites it unconditionally.

Adds TestFullDuplexHookHostSetErrPanicRecovers pinning the hostHook
double-panic scenario. The stale-ErrClosed fix is a one-line clear
verified by code-path analysis and the existing self-heal test
(TestClusterFullDuplexChildCloseSelfHeals); a dedicated repro would
need to force a second, distinct synchronous rejection within the
retry's self-heal window, which isn't practical to trigger
deterministically without deeper test scaffolding.

* fix(autopipeline): panic-safe SetErr on retry-slot saturation

retryOnNormalConn's two retrySem-saturation arms called Cmder.SetErr
directly. Both run synchronously on the READER goroutine when Close
cancels fd.ap.ctx while retrySem is full — before retryOnNormalConn's
own recover (which only wraps the retry goroutine spawned further
down) is in play. A panicking custom Cmder there escaped to the
reader's session-failure recovery, tearing down the whole session and
replaying the unacked tail: requests already written would run twice
(codex on #4002).

Route both through fdSetErrSafe. Adds
TestFDRetryOnNormalConnCloseCancelPanicSafeSetErr covering both arms
(ctx already done before the first select, and ctx cancelled while
parked in the blocking inner select) — verified against a temporary
revert that it actually crashes the process without the fix.

* fix(autopipeline): TS.READ positional scan, FD child GC, batch guardrail

Four fixes from PR #4002 review comments:

- isBlockingCmd's ts.read case scanned every argument for "block",
  including args[1]/args[2] (key, timestamp) — TS.READ's two fixed
  positional args before the option section (see TSReadWithArgs). A
  nonblocking TSRead(ctx, "block", ts) call was misclassified as
  blocking and diverted off the ordered pipe, breaking async-face
  ordering (codex — a gap in the earlier STREAMS/BLOCK positional fix,
  da956f24, which covered XREAD/XREADGROUP but not TS.READ). Scan only
  args[3:], mirroring the XREADGROUP fix.

- clusterFDRouter.children was keyed by node *Client and only cleared
  when the parent closes. Topology GC can close a node client (and its
  cached child) while the router stays up; once the cluster's slot map
  stops pointing at that *Client, getOrCreateChild never sees it again,
  so a closed child (its FD engine, submit channel, held connection)
  sat retained in the map for the router's whole life on a cluster that
  scales down or replaces node addresses (cursor bugbot). Sweep stale
  closed entries for OTHER nodes while getOrCreateChild already holds
  the write lock to build a fresh one.

- DefaultPipelinePoolTimeout's GoDoc claimed a residual
  maintnotifications drainer-handoff budget. Traced every acquisition
  against the dedicated pipeline pool (withPipelineConn's borrow, the
  FD engine's session lease) — both use TryGet, never the blocking
  Get, and TryGet's non-wait branch returns before waitForDrainer is
  ever reached. The constant has no live effect there; doc corrected
  (codex). A second, related stale comment in pipelinePoolOptions
  claimed the same wrong "short wait then ErrPoolTimeout" behavior;
  fixed to match the correct comment already 600 lines away in the
  same file.

- MaxBatchBytes defaulted to 0 (unbounded). With FullDuplex, the
  reader cannot start draining replies until the writer finishes
  flushing the WHOLE batch, so a batch with >= 2 large-payload
  commands (e.g. big ECHOs) can deadlock both directions: Redis blocks
  writing an early large reply while the client is still blocked
  writing the rest of the batch, resolved only by WriteTimeout, with
  recovery then possibly replaying an already-executed command
  (codex). MaxBatchBytes now defaults to 128 KiB — a guardrail, not a
  throughput knob; for ordinary small commands it never binds
  (MaxBatchSize's 200-command cap already keeps a typical batch under
  ~15 KiB). There is no "unbounded" setting anymore; pass a
  deliberately large value to opt out. Bumped DefaultPipelineBufferSize
  64 KiB -> 128 KiB alongside it for the matching bufio headroom before
  a write() to a slow-draining peer blocks; the aggregate memory cost
  stays negligible since the pool it backs holds few connections
  (dedicated pipeline pool never pre-dials; full-duplex holds exactly
  one connection per node).

This does not fully close the underlying gap: MaxBatchBytes bounds
REQUEST bytes, not expected REPLY bytes, so a batch of plain GETs
against large values is still exposed regardless of this default. The
real fix — draining inflight incrementally as the writer flushes,
instead of only after the whole batch — is a separate, larger change
to the write/flush protocol and is intentionally out of scope here.

Adds TestClusterFullDuplexEvictsStaleChildOnRebuild,
TestBlockingDetectionTSReadKeyNamedBlock, and
TestAutoPipelineMaxBatchBytesDefaultsToGuardrail. Updates
TestAutoPipelineMaxBatchBytes's uncapped control, which relied on the
old 0-means-unbounded default. All three new/changed fixes verified
against a temporary revert to confirm they actually catch the
regression.

* fix(autopipeline): evict child on node close, size all arg encodings

Two more #4002 review findings, both gaps in fixes shipped in the
previous round:

- The stale-child eviction sweep (fd4d5649) only ran on the SLOW
  construction path inside getOrCreateChild — it fires when building
  a fresh child for a DIFFERENT node than the one currently missing
  from the cache. A cluster whose live traffic only ever hits
  already-cached nodes never takes that path, so a node removed by
  topology GC would sit retained until some unrelated node happened to
  need a rebuild (cursor bugbot). Register a close hook on the node's
  own *Client (the same onClose registry AutoPipelineWithOptions
  already wires the child's own drain to) that prunes this router's
  entry the moment the node itself closes — event-driven, not
  opportunistic. Kept the sweep as a backstop for the brief window
  between caching and registering the hook.

- cmdApproxBytes's default arm charged a flat 8 bytes for any argument
  type it didn't recognize, including *string and
  encoding.BinaryMarshaler — both fully supported by proto.Writer at
  their real encoded length. Several large *string or BinaryMarshaler
  arguments could therefore coalesce past the 128 KiB MaxBatchBytes
  guardrail from the previous round and reopen the large-payload
  write/reply deadlock it exists to bound (codex — fresh evidence
  against that same guardrail). Size both by their actual encoded
  length; a BinaryMarshaler that errors is sized as deliberately large
  (1 MiB) rather than falling back to 8 bytes, since the command fails
  at write time anyway and isolating it into its own chunk is free.

Adds TestClusterFullDuplexEvictsChildOnNodeClose and
TestCmdApproxBytesAccountsForStringPointerAndBinaryMarshaler. Both
verified against a temporary revert to confirm they catch the
regression.

* test(autopipeline): fix TS.READ blocking fixture

TestBlockingSetCoversEveryReadTimeoutHelper failed on every
test-redis-ce matrix entry since fd4d5649:

  isBlockingCmd([ts.read k BLOCK 0]) = false, want true

The fixture dates from the original autopipeline commit (c994cfc8),
when isBlockingCmd looked for BLOCK anywhere in a TS.READ arg list.
fd4d5649 then correctly tightened that scan to start AFTER the two
fixed positionals (key, timestamp) so a key literally named "block"
is not mistaken for the option (codex on #4002) — and added
TestBlockingDetectionTSReadKeyNamedBlock with the real shape — but
left this older fixture behind. It omits the mandatory timestamp, so
its BLOCK now sits in the skipped positional slot and is never seen.
The non-blocking fixture had the same gap and also used COUNT, which
TS.READ does not take (its option is MAX_COUNT).

The detection code is right; only the fixture was wrong. Both entries
now mirror what TSReadWithArgs actually emits
(TS.READ key timestamp [BLOCK ms minCount] [MAX_COUNT n]), with a note
on why the timestamp must be present.

* test(autopipeline): buffer frame in custom release-drain test

Every test-redis-ce matrix entry was red on 8345b0f0 (the merge of
master with #3989) on one deterministic unit test:

  TestReleaseConnRemovesConnAfterCustomDrainError:
    a custom release-drain error must remove, not re-pool, the conn:
    removes=0 puts=1

#3989 changed peekAndProcessPushNotifications to hand a custom
PushNotificationProcessor work only when real bytes are BUFFERED. Bare
socket readability can be a zero-RESP-byte TLS control record, and a
custom processor's blocking loop handed that times out and retires a
healthy connection; the model is frozen in .claude/specs/push.md. The
test wrote a push frame into the socket without buffering it, so under
that gate the processor was never invoked, no drain error surfaced, and
releaseConnToPool Put the conn.

The property the test pins is intact: releaseConnToPool Removes on ANY
drain error, including a redis.Error that isBadConn ignores (finding
F3). Only its setup predated the gate. Pull the frame into the reader
buffer with PeekReplyType before releasing (consumes nothing), assert
the processor ran exactly once, and add
TestReleaseConnKeepsCustomProcessorConnOnBareReadiness for the other
side of the gate: readable but unbuffered, the custom processor is not
invoked and the conn is re-pooled intact.

Verified the fixed test still catches its original regression: with
the isBadConn-only Remove guard restored it fails (removes=0 puts=1);
the new bare-readiness test passes on both.

* fix(autopipeline): size before lock, detach evict hooks

Cursor Bugbot + codex review of 8345b0f0 on #4002: nine threads, six
distinct issues.

1. Half-duplex enqueue sizing panic wedged the shard (cursor High +
   codex). With MaxBatchBytes on by default, enqueue sized every command
   under the stripe mutex, and sizing runs user code (cmd.Args() on a
   custom Cmder, MarshalBinary on a BinaryMarshaler argument). A panic
   there never reached st.mu.Unlock: every later enqueue on the stripe
   parked forever and Close could only time out. Size BEFORE taking the
   lock with cmdApproxBytesSafe — the full-duplex serve loop's helper —
   and fail/drop just that command (completedBatch, like the closed
   path). The chunker's second sizing site runs under the flusher's
   recoverDispatchPanic with no lock held, so it already degraded to
   per-command errors; unchanged. Test:
   TestEnqueueSizingPanicDoesNotWedgeStripe (verified against the
   under-lock version: the panic escaped and Close hit its 30s
   backstop).

2. Cluster FD per-node evict hooks (codex + 2x cursor Medium). Two
   defects: getOrCreateChild registered a clusterFDRouterEvict hook on
   each node client's onClose registry and close() never unregistered
   them, so a cluster that cycles autopipeliners accumulated one hook
   per cycle per node, each keeping the closed router (and through
   parent the AutoPipeliner and its clusterReprocess closure) reachable
   until the node client itself closed; and the hook was registered
   AFTER the child was published and r.mu released, so a topology-GC
   close in that window ran with no hook and the closed child stayed in
   children for the router's life. onCloseHooks.run now records that it
   ran and register returns false afterwards (a hook registered against
   a closing owner could never fire). getOrCreateChild registers under
   r.mu BEFORE publishing — the hook takes r.mu, so a racing close
   either predates the register (register reports false: discard the
   child, divert) or blocks on r.mu until the store is published and
   then deletes it — and records the node in evictHooks; close()
   unregisters every tracked hook, not just those still in children.
   Tests: TestOnCloseHooks_RegisterAfterRunIsRejected,
   TestClusterFDRouterCloseUnregistersEvictHooks.

3. Hook-host readiness ordering (codex). Not fixable by ordering, and
   not fixed: the FD hook host marks itself the batch's executor, so
   await's executor guard makes a pre-next cmd.Err()/cmd.String() on it
   non-blocking whether or not setReady has been stored — blocking there
   would self-deadlock, since only the host closes b.done. A pre-next
   read on the FD face is therefore the not-yet-executed view racing
   the reader's write by construction. Tightened the FullDuplex contract
   doc to forbid pre-next READS of the result alongside the existing
   mutate/set prohibition (the same limitation already documented for
   node-level hooks), and recorded the reasoning at the submit site.

4. Raw HIMPORT bypassed the FD divert (codex). isHImportCmd matched only
   the himportCmder marker, so NewCmd/NewStatusCmd(ctx, "himport", ...)
   rode the shared pipe; after a session recycle, handoff or reconnect a
   raw HIMPORT SET could land where its PREPARE never ran. Match by
   command name too. Test: TestIsHImportCmdMatchesRawByName.

5. PipelineReadBufferSize / PipelineWriteBufferSize docs (codex) still
   said "if > 0 a separate pool is created, otherwise the regular pool".
   The dedicated pipeline pool is always created and an unset buffer is
   the larger of the regular size and DefaultPipelineBufferSize (128
   KiB); documented as such (pipelinePoolOptions).

6. MaxBatchBytes doc (codex) promised "unset/<=0 gets this default"
   while Validate rejects negatives (deliberately: a negative typo must
   not silently select the default). Documented the real contract —
   only 0 selects the default — and refreshed the estimator description
   for exact marshaler sizing. Test:
   TestAutoPipelineOptionsMaxBatchBytesDefaultContract.

* fix(csc): count coalesced attempt in re-run metrics

Closes the TODO(convergence) in processCached that #3989 left for
whichever of #3989/#4002 merged second (codex on #3989, "Count the
failed coalesced attempt in retry metrics"). With #3989 on master and
merged here, processStartingAt's explicit-start plumbing is on this
branch, so the fix can land.

When a coalesced miss reached a session connection and then failed with
a session/transport error, processCached re-ran it through
processWithRetry with the same startAttempt it was called with, so the
re-run started from zero: the failed coalesced attempt disappeared from
state.attempts (the OTel operation-duration metric), from the error
callback's retry count, and from the retry budget — the re-run got
MaxRetries+1 fresh attempts on top of the one already spent, and an
operation that made two attempts reported one.

The bail branch now bumps startAttempt by one when fetch reported a
serving connection. served is non-nil exactly when the request reached
a session connection: the writer attributes every request of a batch to
its conn before the write, and a pre-queue shed or a session that never
acquired a conn leaves it nil. That is the same "one attempt on that
conn" rule the success branch already applies, so the two branches now
agree. processWithRetry seeds both its reported count and its
retry-budget position from startAttempt and clamps it to MaxRetries, so
the re-run itself can never be disabled.

Test: TestProcessCached_CoalescerBailCarriesAttempt drives processCached
through a coalescer whose "session" is a goroutine that dequeues the
request, attributes it to a pipe connection and settles it with a
session error; the re-run then fails at once on a pooler whose Get
always errors (non-retryable), so processWithRetry runs exactly one
iteration. With MaxRetries=1 the seed is observable: 2 attempts when
the coalesced attempt reached a conn, 1 for a pre-queue wire-budget
shed. Verified against the pre-fix code: the session-conn case reports
1 there and fails; the shed control passes on both.

* fix(csc): stop coalesced re-run past retry budget

Two codex findings on 87e1344e (#4002) about the coalesced-attempt
carry added in the convergence fix.

1. Budget overrun. processCached bumped startAttempt for a coalesced
   attempt that reached a session connection and then fell through to
   processWithRetry unconditionally. processWithRetry clamps
   startAttempt back to MaxRetries so a caller can never disable
   execution, which here meant one more execution after the budget was
   spent: an FD attempt plus the coalesced one with MaxRetries=1 ran
   the command three times on a two-attempt budget, and with retries
   disabled (MaxRetries -1, i.e. 0) every coalesced session failure got
   a second execution a plain read never gets. When the carried count
   exceeds MaxRetries, return the coalescer's cause instead: the raw
   error unwrapped from cscSessionError, as processWithRetry returns on
   its own exhaustion, set on the command, plus the error metric the
   skipped re-run would have emitted (settleErr leaves that to the
   re-run so a re-run that succeeds is not flagged). The new
   recordCommandError helper is that emission, shared with the two
   exits in processWithRetry.

2. Telemetry on the takeover-hit return. The bump updated only the
   local startAttempt; state.attempts still held the pre-fetch seed and
   state.lastConn stayed nil. Two exits never reach processWithRetry's
   loop, which is what wrote them: the exhausted-budget return above
   and the cache hit after losing the re-Reserve race to another
   waiter. Both reported zero (or only the FD) attempts and no serving
   connection for a request that did reach one. Record the attempt on
   state at the bump. processWithRetry now also seeds its lastConn from
   state, so a re-run that never acquires a pooled connection keeps the
   session conn instead of reporting none.

Tests: TestProcessCached_CoalescerBailCarriesAttempt gains
budget_exhausted_no_retries and budget_exhausted_after_fd_attempt (no
pool Get, raw cause returned, one error-metric emission on the session
conn) and takeover_hit_reports_attempt (a rival fulfills the key
between the coalescer's cancel and wake; the hit is served, the attempt
still reported). Verified against the pre-fix code: all three fail
there, and the existing session-conn case now also pins lastConn.

* fix(autopipeline): drain engine lost to shared close

Cursor Bugbot on 87e1344e (#4002). getOrCreateAutoPipeliner re-checks
the shared-closed flag after registering the engine's close hook, to
catch a sharer Close that passed the entry check and snapshotted the
hooks without this registration. On that path it called ap.cancel()
and unregistered the hook: the freshly built engine was cancelled and
abandoned while closeResources tore the shared pools down (or was
about to, having missed the hook that would have made it wait), so its
flushers exited against closing pools with nothing waiting for them.
It also ignored register's false return, the direct signal that the
close had already taken its snapshot.

Honor both signals and, on either, run cancelAndDrain instead of a
bare cancel: the engine accepted no work (it was never published), so
this is its flushers observing the cancel, bounded by the drainAll
backstop; drainOnce makes it safe alongside a close that did snapshot
the hook. Nothing of the engine is left running when the getter
returns ErrClosed.

Test: TestGetOrCreateAutoPipelinerDrainsEngineLostToSharedClose drives
the build callback as the racing close (flag set, hooks run or not
yet) and checks drainRuns is 1 and no hook is left registered. With
the bare cancel restored, drainRuns stays 0 in both cases.

* docs(options): document pipeline pool spill to main pool

The PipelineReadBufferSize / PipelineWriteBufferSize docs said pipelines
"never fall back to the regular pool or its buffer size". That is not
the contract: withPipelineConn acquires from the pipeline pool with
TryGet and, when the pool is saturated, spills to the regular pool at
once rather than waiting out PoolTimeout, and that connection has the
regular buffer sizes. State the spill on both fields and point at
PipelinePoolSize as the knob for callers who need every pipeline on the
large buffers (Copilot on #4002).

* fix(sentinel): register failover close hook before pools

NewFailoverClient registered failover.Close on rdb.onClose only after
the pools, the pipeline pool and OTel registration were built. With
MinIdleConns > 0 the main pool starts dialing as soon as it exists, and
masterReplicaDialer then creates the failover's Sentinel client and
pubsub. If a later construction step panicked (pipeline pool size
overflow, OTel, push-processor registration), the panic-cleanup defer
closed rdb, but its onClose hooks did not yet include failover.Close,
so those discovery resources outlived a client nobody would ever hold
(Copilot on #4002).

Register the hook right after rdb.init, before any pool can dial. The
LIFO rationale is unchanged: it is still the first registration, so it
still runs last, after any lazily-registered autopipeliner drain hook
that needs MasterAddr alive.

* fix(autopipeline): host cluster hooks on full-duplex path

clusterFDRouter.submit hands a command straight to the owning node
child's FD engine. That engine's hook host runs only the node client's
chain (OnNewNode hooks), so hooks registered on the ClusterClient — via
ClusterClient.AddHook or the parent AutoPipeliner.AddHook, which the
half-duplex cluster face runs around every batch through
withProcessPipelineHook — never saw a full-duplex command: no
cluster-level tracing, metrics or policy for successful FD traffic
(Copilot on #4002).

Add hostParentHook: with cluster-level hooks installed, the router runs
the parent chain on a per-command host goroutine around the child
batch, with fdEngine.hostHook's contract one level up. The command is
on the wire before the chain starts; next() blocks until the child
batch completes, so an observing hook spans the real write-to-reply
latency and a rewrite is honored before the waiter wakes; a
short-circuit does not stop the already-sent command but its verdict
lands on cmd; a panicking hook is recovered, fails the command and
still wakes the waiter. The host goroutine is the outer batch's
executor, so a hook reading its own command after next() does not
block. Hosts are tracked by a router WaitGroup that close waits after
the children drain; the Add happens under the router mutex while it is
open and close marks it closed under the same mutex first, so a submit
racing close passes the child batch through instead of Adding to a
WaitGroup being waited. No hooks, or a submit-time rejection, start no
host. The blocking face's pooled child batch is recycled by the host,
its single waiter.

Tests: TestClusterFDRouterHostsParentHooks drives the host with a
hand-completed child batch (final result observed, rewrite honored,
short-circuit still awaits execution, panic recovered, passthrough
without hooks or on rejection, close waits and refuses late hosts);
TestClusterFullDuplexParentHooksObserveCommands runs 400 FD commands on
a live cluster and checks the ClusterClient hook ran exactly once per
command and saw each one completed. Both fail with the host bypassed.

* test(autopipeline): replay command suites through FD faces

The test-autopipeline-subjects job replays the parametrized command
suites through the half-duplex AutoPipeliner faces (ap-blocking,
ap-async) via GOREDIS_TEST_SUBJECT. Full-duplex had no such coverage:
its correctness was pinned only by the FD-specific tests, not by every
command's own spec running through it.

Add two subjects, ap-fd-blocking and ap-fd (the same two faces with
FullDuplex on), let the Makefile target take the face list from SUBJECTS
(default: all four), and turn the CI job into a matrix over the subject
so the four faces run in parallel and a red cell names the face.
SUBJECTS reaches make through the step environment, since the shared
action passes make-target as a single word.

The command suites include blocking commands, MULTI/WATCH, CLIENT- and
CONFIG-style same-connection commands, HIMPORT and large values, so this
exercises the FD divert, preflight and byte-cap paths with every
command's own assertions.

* revert(autopipeline): drop cluster FD parent-hook hosting

Round 3 added hostParentHook so hooks registered on the ClusterClient
run around a full-duplex command routed to a node child's engine. codex
found a deadlock in it (P1): the extra completion batch it installed
becomes the command's readiness gate, but the fd reader and the off-pipe
retry register as executors of the child's inner batch, so a node hook
or an operation-duration callback that reads cmd.Err()/cmd.String()
inside cc.process awaits the outer gate it is not an executor of. The
retry then blocks on the outer batch, the outer host blocks on the inner
batch, and the inner batch completes only after the retry returns — a
three-way cycle on async cluster FD with a cluster-level hook and a
redirect or retryable reply.

Revert it. The mechanism served a niche case: the common cluster
instrumentation (redisotel tracing and metrics) attaches per node via
OnNewNode, so it runs on the FD path through the node engine's own host
already, and operation duration/error metrics go through the global
recorder the FD reader emits. Only a process hook registered directly on
the ClusterClient is skipped on the FD fast path, and that is now a
documented limitation with the workaround (register per node via
OnNewNode). Removing the parent-hook host also removes the unbounded
hostWg.Wait in the router's close (cursor Medium): the router's close is
back to closing each node child under its own drain backstop.

This restores autopipeline_cluster_fd.go and its internal test to their
pre-round-3 state (the two hosting tests go with it) and adds two entries
to .claude/specs/full-duplex.md Known limitations: cluster-level process
hooks on the FD fast path, and cluster FD connection-error carries
staying on the origin node (codex P2 — pre-existing; re-resolving them
through cc.process needs the fdConnErr recovery branch restructured).

* fix(autopipeline): spill on pipeline dial error, fail raw sink errors

Three review findings on #4002 (chatgpt-codex-connector), all in the
pipeline-pool / full-duplex acquisition and reply paths.

1. FD lease spill on a pipeline-pool dial error. The full-duplex lease
   acquires from the dedicated pipeline pool with TryGet and spills to the
   main pool when the pipeline pool cannot serve it. The spill test was an
   allow-list: only ErrPoolTryFull / ErrPoolExhausted (saturation) spilled.
   But TryGet also DIALS when the pool has no idle connection, and a
   transient dial failure there is neither sentinel, so the engine returned
   the lease error and retried the same pipeline pool until its budget was
   spent, failing the accepted backlog even when the main pool had an idle
   connection or could dial cleanly.

2. Ordinary pipeline spill on a pipeline-pool dial error. redis.go
   withPipelineConn had the identical allow-list bug, so a plain Pipeline or
   half-duplex autopipeline failed immediately on a pipeline-pool dial error
   even with a healthy main pool.

   Both are now a deny-list: surface only a hard stop as the acquisition
   error — a closed pool (pool.ErrClosed) or a cancelled/expired acquire ctx
   (context.Canceled/DeadlineExceeded) — because the main pool would fail the
   same way. Every other acquisition failure, saturation and a transient dial
   error alike, spills to the main pool.

3. FD reader: treat a RawWriteToCmd sink error as session-fatal. A
   RawWriteToCmd streams the raw reply — including a server error line —
   straight to the caller's io.Writer and returns nil for a server reply, so
   a non-nil error from its readReply is never a server reply: it is a sink
   or socket failure mid-frame, which leaves the payload unread and the
   socket desynced. fdReplyIsFatal classified it by the error, so a sink
   error implementing redis.Error was treated as a benign reply — the reader
   advanced and the next command read the leftover bytes as its own reply.
   Classify a *RawWriteToCmd by command type instead: any non-nil per-reply
   error is fatal, so the reader stops and the unacked tail recovers. The
   command itself is sent + NoRetry, so recovery fails it rather than
   replaying it (no double stream to the caller's writer).

Tests:
  - TestFullDuplexSpillsToMainPoolOnPipelineDialError and
    TestWithPipelineConnSpillsOnPipelineDialError: a pipeline pool whose
    dialer always errors while the main pool stays healthy; assert the work
    spills and completes. Both verified red against the restored allow-list.
  - TestFDReplyIsFatalPushDrain gains RawWriteToCmd cases (a redis-typed
    sink error is fatal); verified red against the pre-fix classification.
    TestFullDuplexReaderRawSinkErrorRecovers drives the raw-sink path
    end-to-end and asserts the engine recovers and reads correct replies.

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: sadd amr3e <saddamr3e@gmail.com>
Co-authored-by: Albert Huynh <huynhalb@gmail.com>
Co-authored-by: Vladislav Kotsev <vladygk8@gmail.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-11 17:54:03 +03:00

139 lines
4.8 KiB
Go

package redis
import (
"context"
"errors"
"net"
"sync/atomic"
"testing"
"time"
"github.com/redis/go-redis/v9/internal/pool"
)
type spillCountingLimiter struct {
allow atomic.Int64
report atomic.Int64
}
func (l *spillCountingLimiter) Allow() error { l.allow.Add(1); return nil }
func (l *spillCountingLimiter) ReportResult(_ error) { l.report.Add(1) }
// TestWithPipelineConnSpillAccountsLimiterOnce drives the spill branch that the
// #3959 fix restructured: with the pipeline pool's only connection held, a
// pipeline must spill to the main pool AND account the Limiter exactly once. The
// pre-fix code re-entered withConn on spill, calling Allow()/ReportResult() a
// second time (2/2). Deterministic: the pipeline pool is genuinely saturated
// (its one conn is held), so Get times out after DefaultPipelinePoolTimeout and
// spills — no timing race.
func TestWithPipelineConnSpillAccountsLimiterOnce(t *testing.T) {
ctx := context.Background()
lim := &spillCountingLimiter{}
c := NewClient(&Options{Addr: ":6379", PipelinePoolSize: 1, Limiter: lim})
defer c.Close()
if err := probeRedis(":6379"); err != nil {
t.Skipf("no redis: %v", err)
}
if err := c.Ping(ctx).Err(); err != nil {
t.Skipf("no redis: %v", err)
}
ref := c.loadPipelinePool()
if ref == nil {
t.Fatal("expected a dedicated pipeline pool")
}
// Hold the pipeline pool's only connection so the next pipeline Get times out
// and withPipelineConn is forced to spill to the main pool.
held, err := ref.pool.Get(ctx)
if err != nil {
t.Fatalf("hold pipeline conn: %v", err)
}
defer ref.pool.Remove(ctx, held, errors.New("test cleanup"))
// Measure only the spill call (the Ping above used the main pool).
lim.allow.Store(0)
lim.report.Store(0)
var ranOn string
err = c.withPipelineConn(ctx, func(_ context.Context, cn *pool.Conn) error {
ranOn = cn.PoolName()
return nil
})
if err != nil {
t.Fatalf("withPipelineConn (expected spill success): %v", err)
}
if ranOn == ref.name {
t.Fatalf("expected the spill to run on the MAIN pool, but it ran on the pipeline pool (%q)", ranOn)
}
if a, r := lim.allow.Load(), lim.report.Load(); a != 1 || r != 1 {
t.Fatalf("spill Limiter accounting: Allow=%d ReportResult=%d, want 1/1 (double-count regression)", a, r)
}
}
// TestWithPipelineConnSpillsOnPipelineDialError pins the spill-on-dial-error fix
// (codex on #4002), the ordinary-pipeline sibling of the full-duplex case: the
// pipeline lease acquires with TryGet, which DIALS when the pool has no idle
// conn. A transient dial failure there is not saturation (ErrPoolTryFull/
// ErrPoolExhausted), so the previous allow-list surfaced it and failed the
// pipeline even with a healthy main pool. The spill is now a deny-list:
// everything except a closed pool or a cancelled/expired ctx spills. Here the
// pipeline pool's dialer always errors while the main pool is healthy, so
// withPipelineConn must spill and run on the main pool.
//
// Red-check: restore the allow-list (spill only on ErrPoolTryFull/
// ErrPoolExhausted) -> TryGet's dial error returns from withPipelineConn and this
// test fails instead of spilling.
func TestWithPipelineConnSpillsOnPipelineDialError(t *testing.T) {
ctx := context.Background()
c := NewClient(&Options{Addr: ":6379", PipelinePoolSize: 1, PoolSize: 8})
defer c.Close()
if err := probeRedis(":6379"); err != nil {
t.Skipf("no redis: %v", err)
}
if err := c.Ping(ctx).Err(); err != nil {
t.Skipf("no redis: %v", err)
}
// Swap the pipeline pool for one whose dialer always fails; the main pool keeps
// its real dialer. A raw pool with a plain failing dialer does a single dial
// attempt (no root-level retry wrapper), so TryGet returns the dial error at
// once instead of the pool-saturation sentinels.
orig := c.pipelinePool
failPool := pool.NewConnPool(&pool.Options{
Dialer: func(context.Context) (net.Conn, error) { return nil, errors.New("pipeline dial boom") },
PoolSize: 1,
MaxConcurrentDials: 1,
MinIdleConns: 0,
PoolTimeout: 100 * time.Millisecond,
DialTimeout: time.Second,
ConnMaxIdleTime: -1,
})
c.pipelinePool = &pipelinePoolRef{pool: failPool, name: "faildial-pipe"}
t.Cleanup(func() {
_ = failPool.Close()
if orig != nil {
_ = orig.pool.Close()
}
})
// Detect the spill: a Get on the MAIN pool.
mainHook := &fdCountHook{}
c.connPool.AddPoolHook(mainHook)
var ranOn string
err := c.withPipelineConn(ctx, func(_ context.Context, cn *pool.Conn) error {
ranOn = cn.PoolName()
return nil
})
if err != nil {
t.Fatalf("withPipelineConn failed instead of spilling on a pipeline dial error: %v", err)
}
if ranOn == "faildial-pipe" {
t.Fatalf("expected the spill to run on the MAIN pool, but it ran on the pipeline pool")
}
if g := mainHook.gets.Load(); g < 1 {
t.Fatalf("pipeline did not spill to the main pool on a dial error (main-pool gets=%d)", g)
}
}