mirror of
https://github.com/redis/go-redis.git
synced 2026-10-28 08:24:54 +08:00
* 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 commit45f0159217. * 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 of5e50f2b3: - .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 sincefd4d5649: 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.fd4d5649then 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 on8345b0f0(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 of8345b0f0on #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 on87e1344e(#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 on87e1344e(#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>
139 lines
4.8 KiB
Go
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)
|
|
}
|
|
}
|