100 Commits
Author SHA1 Message Date
Nedyalko Dyakov 4fe689525a test: read CLIENT INFO in the SETINFO Exec (#4211) 2026-10-27 15:40:07 +02:00
Nedyalko Dyakov 470a926a08 test(cluster): skip unknown-endpoint spec before 8.4 (#4215)
* test(cluster): skip unknown-endpoint spec before 8.4

On Redis 8.0 and 8.2 the cluster nodes segfault in a module timer
shortly after cluster-preferred-endpoint-type is set to
unknown-endpoint. The spec restores the setting quickly, so it only
fails when the timer fires inside that window, but then every later
cluster spec hits a dead cluster.

* test(cluster): guard unknown-endpoint teardown on skip
2026-10-27 15:04:08 +02:00
Nedyalko Dyakov c9092ec492 fix(auth): make empty-password AUTH opt-in (#4194)
* fix(auth): make empty-password AUTH opt-in

#4161 made a Username with an empty Password send AUTH user "". Redis
OSS accepts it (the nopass default user takes any password), but Redis
Enterprise and Redis Cloud databases with unauthenticated access can
reject it with WRONGPASS, so configs with a username and no password
stopped connecting.

Restore the earlier default (no AUTH without a password) and add
Options.AuthEmptyPassword (auth_empty_password=true) to opt in, on all
options types and credential providers at connect time. Public Hello
again sends AUTH only with a password. The client-side cache namespace
uses the ACL user the connection runs as.

Refs #4161

* fix(auth): keep CSC namespace per username

A Username that sends no AUTH runs as the default user, or as a
TLS-certificate user. Mapping it to the default namespace let clients
with different certificates share cache entries. It now gets its own
namespace per Username ("noauth <name>", which no ACL user can match).

Test: delete the ACL user before closing the admin client, so the user
does not leak into the ACL specs.

* fix(auth): split sentinel empty-password opt-in

Sentinel and data-node credentials are independent, so one flag for
both could force AUTH user "" onto Redis Enterprise data nodes to
authenticate a nopass sentinel user. FailoverOptions and
UniversalOptions gain SentinelAuthEmptyPassword
(sentinel_auth_empty_password=true); AuthEmptyPassword now covers the
master/replica connections only.
2026-10-26 23:32:17 +02:00
Nedyalko Dyakov 454251f54b fix(autopipeline): reroute cluster FD recovery (#4111)
* fix(autopipeline): reroute cluster FD recovery

A cluster full-duplex node child replayed a dropped session's tail only
on a fresh connection to the same node, and failed its carry and queue
once lease retries ran out. If topology change had closed or replaced
that master, every accepted command on it failed instead of reaching
the slot's new owner (codex P2 on #4002, listed as a known limitation).

Add an internal owner seam, AutoPipelineOptions.fdReroute: an engine
with an owner runs that work through the owner, in order, instead.
- connection error: the replayable tail (same budget partition and
  sent-NoRetry split as the replay) goes through the owner;
- lease retries spent: the carry, then the queue, go through the owner
  instead of failing.
The cluster FD router sets it to the ClusterClient pipeline, which
routes by slot against live topology. Standalone FD is unchanged.

* fix(autopipeline): keep retry budget on FD reroute

fdReroute now takes the remaining retry bound. rerouteReqs goes through
flushCarryBudgeted, so each group passes its remaining budget and a
command never runs more than budget+1 times; the cluster child uses
cc.processPipelineRetries with that bound in place of MaxRedirects.

- The Close-time flush (flushReqs) also goes through the owner, so a
  child closed by topology GC does not flush through its dropped node.
- runReroute returns a per-command transport error even when the owner
  returns an earlier Redis error (redis.Nil), so the flush still aborts.
- flushReqsVia reports per-command metrics before completing, as the
  reader does. This also adds them to the standalone Close-time flush.

* test(autopipeline): fold reroute tests into FD tests

* docs(autopipeline): note nested hooks on FD recovery
2026-10-21 19:44:49 +03:00
Nedyalko Dyakov f4461968fc test(pool): assert PoolSize soft cap in limit spec (#4180)
"race limit the number of connections" asserted IdleConns == 0 and
TotalConns == PoolSize after 1000 concurrent Gets with MinIdleConns 50.
A Get that misses while min-idle dials are in flight dials an excess
non-pooled conn, and the late min-idle conns stay idle, so total and
idle both overshoot by the same amount. PoolSize is a soft cap on
pooled conns (see .claude/specs/pool.md), so this is allowed.

Assert the contract instead: no Get errors, in-use conns == PoolSize,
pooled conns <= PoolSize. Also close the spec's pool and nil-guard the
shared AfterEach so the spec can run focused.
2026-10-20 16:33:45 +03:00
Nedyalko Dyakov c44dd63cc7 test(cmd): relax HPTTL TTL assertion (#4172)
HPTTL right after HEXPIRE asserted 10000ms +/- 2ms, which fails on
slow CI runners (seen 9991). TTL only counts down, so assert
9000 < ttl <= 10000 instead.
2026-10-20 09:52:17 +03:00
Nedyalko Dyakov 2821f8801f feat(pool): keep a warm spare for full-duplex (#4101)
* feat(pool): keep a warm spare for full-duplex

The full-duplex engine holds one pipeline-pool connection for its whole
session, and the pipeline pool never kept idle connections. After a
connection error the replacement had to dial first, while every unacked
command waited.

Add Options.PipelineMinIdleConns (default 0, also on Cluster, Ring,
Failover and Universal options and the URL parsers) to keep idle
connections in the pipeline pool. A full-duplex engine raises it to at
least 1 through a new ConnPool.RaiseMinIdleConns; the pool refills the
spare in the background whenever it is leased. A negative value opts
out. Clients that never use full-duplex keep a zero idle footprint.

The spare saves the dial (TCP/TLS), not the connection init
(HELLO/AUTH), which still runs on first lease.

* fix(pool): keep the warm spare fresh

The pool checks ConnMaxIdleTime only when it hands a connection out.
Under LIFO the full-duplex spare is never leased, so it aged past
ConnMaxIdleTime (30m by default) and was discarded by the very lease it
exists for, which then dialed.

In a pool whose min-idle floor was raised, Get/TryGet now replace the
oldest idle connection once it is half-way to ConnMaxIdleTime, but only
when another idle connection serves the lease. The close goes through
the stale path, and the floor refills in the background. One atomic
load for all other pools.

Also fix the warm-spare test race seen on CI: IdleLen counts a min-idle
connection as soon as its dial starts, so the waits returned while the
spare was still dialing. Wait on Len as well.

* fix(pool): add the FD spare only when it fits

Add the warm spare only when the pipeline pool has room for it next to
the engines: PipelinePoolSize must exceed the engine count, or the
session spills to the main pool, and MaxIdleConns (if set) must too, or
every recycle evicts the returned session connection and dials again.

Also refresh the spare before its ConnMaxLifetime ends, not only before
ConnMaxIdleTime; re-check a raise that races an early-return min-idle
check; and fix the pipeline pool docs (permanent raise, the
FullDuplexMaxHold bound, cluster pool lifecycle, pipelinePoolOptions).

* fix(pool): use fdPipeCount after master merge

* fix(pool): refresh the spare after the lease

Closing the aged spare before popIdle could leave this Get with no
connection (a concurrent Get took the other idle one, or the newest one
failed the health check) and force a dial.

* test(pool): fold warm-spare tests into existing files

* docs(pool): note lifetime jitter for the FD spare
2026-10-16 17:00:24 +03:00
Nedyalko Dyakov dc464bf45c fix(lint): mark discarded done receive for errcheck (#4115)
golangci-lint v2.15.0 reports the bare receive from the chan error
req.done in cscMissCoalescer.fetch. The value is intentionally
dropped: the reader's result is in req.servedBy and ctx.Err() is
returned.
2026-10-16 15:51:52 +03:00
Nedyalko Dyakov 16a7904db0 feat(autopipeline): add FullDuplexPipes option (#4107)
NumShards meant two different things: the number of half-duplex queue
shards (raising it usually hurts and needs Unordered on the async face),
and, with FullDuplex, the number of held full-duplex connections
(raising it is the only way past one connection's ceiling).

FullDuplexPipes now names the full-duplex pipe count. NumShards > 1 is
still honored under FullDuplex when FullDuplexPipes is 0, so 9.23.0
configs keep working; Validate rejects the two set to different values.
Unlike NumShards, FullDuplexPipes has no effect when full duplex cannot
engage, so it never turns into unordered half-duplex shards. Cluster
full duplex keeps one pipe per master.
2026-10-16 10:25:55 +03:00
Nedyalko Dyakov b57341e5a0 feat(otel): MultiDB metrics in redisotel-native (#4007)
The MultiDB client (#3954) reports failovers, active database changes,
member circuit breaker transitions and health checks through an
optional capability interface on the OTel recorder. The native recorder
did not implement it, so applications using the repository's standard
instrumentation received none of those events.

Add a "multidb" metric group (part of MetricGroupAll) with six
instruments in the module's redis.client.* naming: failovers (counter)
and failover duration (histogram), active database changes (counter),
circuit breaker state changes (counter), health checks (counter, by
outcome) and health check duration (histogram). Durations share the
operation duration buckets. Databases are identified by the host-only
FQDN the client reports; there is one per member, so it is recorded as
an attribute without a cardinality switch.

The recorder methods match the capability interface's signatures; the
tests pin the method set through a local mirror of that interface until
it reaches master with #3954, when the mirror can become a direct
assertion.
2026-10-15 13:39:45 +03:00
Nedyalko Dyakov 7a7bf2107f test(autopipeline): consolidate test files (#4091) 2026-10-12 11:46:11 +03:00
Nedyalko Dyakov f79ab09081 feat(search): add FILTER and LIMIT aggregate steps (#4076)
* feat(search): add FILTER and LIMIT aggregate steps

FTAggregateStep now supports Filter and Limit, so FILTER and LIMIT can
be placed anywhere in the FT.AGGREGATE pipeline, e.g. FILTER before and
after GROUPBY.

AggregateBuilder.Filter now adds a FILTER at its call position instead
of setting the tail Filter field, and AggregateBuilder.Limit is added.

The tail Filter and LimitOffset/Limit fields are deprecated. Their wire
output is unchanged: they are still sent after Steps, LIMIT then FILTER.

* docs(search): document one SORTBY per GROUPBY segment

The server allows only one SORTBY between two GROUPBY steps and returns
"Multiple SORTBY steps are not allowed" for a second one. The builder
SortBy doc said a SortBy after another step starts a new SORTBY step,
without saying that the server rejects it. State the rule on SortBy,
FTAggregateSortByStep and FTAggregateStep.

* docs(search): leave the one-SORTBY rule to the server

go-redis does not check that only one SORTBY appears between two
GROUPBY steps. Say so in the docs, and add a test that a second SORTBY
is sent as built, so the client keeps working if the server relaxes
the rule.
2026-10-08 13:13:01 +03:00
Nedyalko Dyakov 6f2b2639e5 chore(release): v9.23.0 (#4074)
* chore(release): v9.23.0

* docs(release): scope FD Pipeline, MaxQueuedCommands, LRUClock
2026-10-05 17:51:38 +03:00
Nedyalko Dyakov e3d2548a38 fix(pool): fix ConnStateMachine waiter races (#4072)
notifyWaiters removed a waiter before its CAS and, on failure, pushed it
back as a new list element. AwaitAndTransition still held the old one,
so on timeout Remove was a no-op while waiterCount was decremented: a
dead waiter stayed queued and a later notify moved the conn to its
target state (e.g. UNUSABLE) for nobody. Now the waiter is removed only
after a successful CAS.

When ctx was canceled while the waiter was being served, the caller got
ctx.Err() although the transition happened, and waiterCount was
decremented twice. The timeout path now checks w.done under sm.mu and
returns the served result.

Refs #4028
2026-10-05 17:09:05 +03:00
Nedyalko Dyakov c2002af06c docs(example): add full-duplex examples (#4061)
* docs(example): add full-duplex examples

Three runnable examples next to example/autopipeline: the blocking face
on one held connection with pipelines on it, the async face with
submission windows and Submit, and NumShards > 1 with per-key routing,
pool sizing and the pooled fallback for pipelines that span engines.

* docs(example): fold FD engines, show queue limit

The multi-engine example used the async face too, so the two repeated
each other. autopipeline-fullduplex-async now runs one engine, then
NumShards 4 (pool sizing, routing by first key, what is not ordered,
pipelines that span engines), then compares one engine with four at low
and high load.

The half-duplex tour shows MaxQueuedCommands: a burst over the cap is
rejected with ErrAutoPipelineQueueFull and retried after a backoff. The
full-duplex READMEs say why it limits only diverted commands there: a
full FullDuplexWindow blocks the submitter instead of rejecting.
2026-10-05 15:31:32 +03:00
Nedyalko Dyakov b5f8864de6 feat(autopipeline): add MaxQueuedCommands hard limit (#4070)
* feat(autopipeline): add MaxQueuedCommands hard limit

Half-duplex autopipelining had no bound on accepted commands, so a slow
server and fast producers could grow client memory until OOM.

MaxQueuedCommands > 0 caps commands accepted but not yet completed
(queued, waiting for a batch permit, executing, and diverted commands on
the deferred face). A command at the limit fails at once with
ErrAutoPipelineQueueFull instead of being queued. Slots are released
before the batch completes, on every completion path (batch, solo,
permit timeout, shutdown drain, diverted, panic).

Default 0 keeps the current unbounded behavior. Rejected with FullDuplex,
which already bounds outstanding commands with FullDuplexWindow.

* fix(autopipeline): tighten MaxQueuedCommands accounting

Count commands run outside the pipeline on the blocking face too: each
holds a pooled connection, so leaving them out broke the hard bound.

A rejected submit now consumes an announced arrival only while the
counter is positive. A caller retrying rejections in a loop drove
expectedArrivals far negative, and the next announced wave vanished
into the deficit, splitting flushes into shallow batches.

* fix(autopipeline): reject full queue before sizing

Check MaxQueuedCommands before MaxBatchBytes sizing, so a submit to a
full queue fails without running user Args/MarshalBinary code. The
atomic admission after sizing still makes the final decision.

When a rejection consumes the last expected arrival, wake the shards.
A flusher waiting for that wave only re-checks the count on a wake, so
it otherwise waited out the silence fallback.

* fix(autopipeline): allow MaxQueuedCommands with FD

With FullDuplex, Do and blocking or connection-hostile commands still
run outside the pipeline, one goroutine each on the deferred face, and
FullDuplexWindow does not bound them. Rejecting the combination left FD
users no way to cap them. Allow it: the limit then covers the commands
run outside the pipeline, and the FD stream keeps its window.

Also check the queue before cluster slot routing, not only before
sizing: shardFn reads and stringifies key arguments (user code).

* fix(autopipeline): release slots after setting errors

On the permit-backstop path, release a batch's MaxQueuedCommands slots
only after every drained command has its error, and before the closes
wake the waiters. Releasing first let producers be admitted while the
failing batch was still outstanding.
2026-10-05 14:47:56 +03:00
Nedyalko Dyakov f813adcb85 feat(autopipeline): run N full-duplex engines on one client (#4022)
* feat(autopipeline): spike N full-duplex engines per client

SPIKE, not for merge. Answers whether the ~2x measured for
"multi-connection full duplex" is a property of N engines or an artifact
of the benchmark harness, which fakes multi-conn by building N separate
*redis.Client instances and round-robining across them. That holds six
sockets per client to do I/O on one -- 48 for eight wires -- and keeps
the routing outside the library, so key routing could not be tested.

NumShards>1 under FullDuplex now means N engines on ONE client: one pool,
one hook chain, one invalidation stream, N held wires. Commands route by
key hash, so same-key commands keep one wire and per-key order holds
without an Unordered opt-in. Keyless commands round-robin. A whole
FDPipelined batch pins to one engine, since splitting it would lose the
internal order a pipeline exists to guarantee.

The change is TWO-SITED, which is the finding. Relaxing the Validate
rejection alone silently turns full duplex OFF: fdOn is separately gated
on nShards == 1, so NumShards>1 fell through to half-duplex round-robin
shards, which are unordered, and a same-key write-then-read returned
redis.Nil. That is exactly what the deleted error warned about ("reject
it rather than silently falling back to half-duplex"). Both the
validation and the construction gate have to move together, with
nShards forced back to 1 so the shard array stays flusherless.

Measured at 1024 callers, 10 commands per Exec, one binary:

  proxy, 8 clients    1,686,080 ops/s   48 held sockets
  in-client, 8        1,791,369 ops/s   12 held sockets

Known gap before this could ship: engines lease from the pipeline pool
and DefaultPipelinePoolSize is 10, so more than ~10 engines spill to the
main pool. A real feature must size that pool from the engine count.
cmdFirstKey is also deliberately crude (args[1]) rather than using
declared key positions.

* refactor(autopipeline): document FD sharding, guard the pool

Drops the exploratory framing from the multi-connection full-duplex code
and closes two of its three known gaps.

Key extraction no longer assumes args[1]. It calls
cmdFirstKeyPosWithInfo(cmd, nil), the same helper client-side caching
uses, so keyless commands, the static command table, and eval/evalsha
variants whose key position depends on the runtime numkeys argument all
route correctly instead of hashing a non-key argument.

Engine count is now checked against the pipeline pool. Each engine holds
one pooled connection for as long as it runs, and DefaultPipelinePoolSize
is 10, so 11 or more engines left the surplus permanently spilling onto
the main pool and competing with ordinary commands. Construction fails
with a message naming PipelinePoolSize instead.

The two-sited rule is now stated at both sites: relaxing the Validate
rejection without relaxing the fdOn gate leaves full duplex silently OFF
for NumShards>1, so commands fall through to unordered half-duplex shards
and a same-key write-then-read can read a stale value. Tests cover it
from both directions -- reply correlation and per-key ordering across
eight engines, and Config() reporting FullDuplex true rather than having
fallen back.

Still open, and why this stays draft: NumShards:0 means one engine and
sizing is not automatic. A fixed count above 1 costs low-concurrency
callers about 30% (eight engines at eight callers), so the count should
be derived from observed load, roughly one wire per 256-512 callers.

* fix(autopipeline): fix FD engine routing edges

- The NumShards>1 ordering check exempted any requested FullDuplex.
  Without a pipeline pool full duplex does not engage and construction
  falls back to round-robin shards, which need Unordered. The exemption
  now follows the effective state.
- Keys are read with stringArg, as Ring does, so []byte, *string and
  numeric keys route like the string form, and an empty key routes by
  hash instead of round-robin.
- Engine index uses uint32 modulo; converting to int first goes
  negative on 32-bit platforms.
- Document that N engines keep order per first key only, so multi-key
  commands are not ordered across engines, and that NumShards>1 under
  full duplex is new, so the mode is opt-in.

* docs(autopipeline): FD engine ordering and window

Document that with NumShards > 1 keyless commands, including ones that
change every key (FLUSHDB, SWAPDB), round-robin and are not ordered
against keyed commands; that keys route through stringArg like Ring and
the cluster slot hash; and that FullDuplexWindow applies per engine.

* docs(autopipeline): pool check is per autopipeliner

The NumShards pipeline-pool check counts only the autopipeliner being
built. The blocking and async faces and WithTimeout clones share one
pipeline pool, so it must hold the sum of their engines. Say so; a
client-wide engine count is a follow-up.

* fix(command): route raw FCALL by its first key

FCALL has the EVAL layout (name, function, numkeys, keys...), but the key
resolver only special-cased EVAL. The typed FCall sets its key position;
a raw Do/Process FCALL fell back to position 1 and hashed the function
name. That misrouted it on Ring (wrong shard), on cluster (a MOVED away)
and across full-duplex engines (a following GET of its key could run
first). A zero-key FCALL now resolves as keyless, like a zero-key EVAL.

* docs(autopipeline): document multi-engine NumShards

The exported FullDuplex and NumShards docs still described a single
full-duplex stream and said NumShards > 1 needs Unordered. Describe the
multi-engine mode: engines routed by first key, per-first-key order
only, per-engine window, and the pipeline pool sizing.

* fix(autopipeline): keep FD batches on their keys' engine

With several full-duplex engines, FDPipelined sent the whole batch to
its first command's engine. A later command in the batch whose key
hashes elsewhere could then run ahead of an earlier, unawaited command
for that key on that key's engine.

A batch now rides an engine only when every keyed command hashes to it
(keyless commands ride along). Otherwise FDPipelined returns
ErrFDPipelineSpansEngines and Pipeline() runs an ordinary pipeline.

* docs(autopipeline): note key types in engine routing

With several full-duplex engines a command is routed by a hash of its
first key in string form (fmt for non-string arguments, as Ring does),
so the same Redis key passed as different Go types (true and "1") can
land on different engines. Say so on NumShards.

* test(autopipeline): use the configured test address

The full-duplex shard tests dialed 127.0.0.1:6379 directly and skipped
under the REDIS_PORT override. Use apTestAddr and internalTestRedisAddr
like the other autopipeline tests.

* fix(command): route raw XGROUP and ZUNION by key

The raw-key resolver fell back to args[1] for XGROUP, whose key follows
the subcommand, and for ZUNION, ZINTER and ZDIFF, whose key follows
numkeys. With several full-duplex engines a raw command could then run
on a different engine from earlier writes to the same key, and Ring
picked the wrong shard. The typed constructors already set position 2.

* fix(command): route raw keyed commands by key

The raw-key resolver fell back to args[1] for SDIFFCARD, SUNIONCARD,
TS.NRANGE and TS.NREVRANGE (key after numkeys) and HIMPORT SET (key
after the subcommand), where the typed constructors set position 2.

HSCAN, SSCAN and ZSCAN with a hash tag in MATCH routed by the pattern,
a hint meant for the keyless SCAN. The key decides the node: a cluster
scan took a MOVED hop, a Ring scan read the wrong shard, and a
multi-engine full-duplex scan could pass earlier writes to its key.

* docs(autopipeline): note the cost of FD engines

NumShards is a fixed engine count, and several engines fragment small
batches. Give the measured trade on NumShards: 8 engines were 30%
slower than 1 at 8 callers, broke even at about 128, and were 2.2x
faster at 1024.
2026-10-02 16:49:25 +03:00
Nedyalko DyakovandVladislav Kotsev 349bcf47a9 feat(autopipeline): run Pipeline on the full-duplex connection (#4021)
* feat(autopipeline): add fdQueue.pushBatch for FD batches

FDPipelined submits a pipeline as one contiguous run; pushBatch enqueues
it under a single lock, all or nothing.

* feat(autopipeline): run Pipeline on the full-duplex connection

ap.Pipeline() delegated to the client's pipeliner, so a pipeline built
from a full-duplex autopipeliner quietly left the held connection, took a
pooled one per Exec, and lost the cross-caller coalescing the engine
exists for. It now submits the batch to the engine as one contiguous run.

FDPipelined does the submission: the batch is admitted all-or-nothing, so
its commands occupy consecutive in-flight slots and their replies come
back in submit order, which keeps a pipeline's internal ordering exactly
as a dedicated connection would. Batches that cannot ride the wire -- a
blocking command, a per-command read timeout, anything the divert
predicate claims, or a batch larger than the queue -- fall back to the
ordinary pipeline path.

Measured on a 14-core client against a local server, 10 commands per
Exec, 3 passes:

  callers   pooled       on the engine   active sockets
      256   586,557        871,469       149 -> 1
     1024   516,042        785,191       150 -> 1

So +48.6% and +52.2%, on one socket instead of 149.

Above ~2048 callers a single engine saturates its submit queue and
admission serializes; spreading pipelines across several auto-pipeliners
avoids that (1.6M ops/s across eight), which is an argument for a
supported multi-connection full-duplex mode rather than for this commit.

TxPipeline is deliberately untouched: MULTI/EXEC needs connection
affinity for the whole transaction, which the engine does not offer.

* fix(autopipeline): keep FD pipeline Exec semantics

Pipeline() on a full-duplex autopipeliner runs the batch on the held
connection. Three things differed from an ordinary pipeline:

- A retryable reply (LOADING and the like) was retried per command off
  the reader, so later commands of the pipeline could run before it.
  Pipelined commands now settle retryable replies inline, and a
  retryable reply on the first command re-runs the whole batch the
  ordinary way, as processPipeline does.
- With ContextTimeoutEnabled the caller's deadline did not bound the
  wait. Such clients keep the pooled pipeline path.
- Exec dropped redis.Nil. It now returns the first error, Nil included.

FDPipelined is documented as standalone only.

* fix(autopipeline): close FD pipeline Exec gaps

Pipeline() on a full-duplex autopipeliner still differed from an
ordinary pipeline:

- Hooks, a Limiter or an OTel recorder: an ordinary pipeline runs the
  ProcessPipelineHook chain, takes one Limiter decision per batch and
  records one pipeline duration. The FD path did none of that, so such
  clients now keep the pooled pipeline. Running the FD path under these
  is a follow-up.
- The whole-batch retry on a retryable first reply re-ran pipelines
  holding a NoRetry command. It now stops on cmdsContainNoRetry, as
  generalProcessPipeline does.
- That retry also restarted the full MaxRetries budget, so MaxRetries:1
  allowed three executions. The FD attempt now counts as the first: the
  re-run gets MaxRetries-1 retries, after one backoff.
- A canceled ctx was only checked while waiting for queue room, so a
  pre-canceled Exec still ran. submitBatch now refuses a done ctx before
  admission.

* fix(autopipeline): count FD replays in the retry budget

The whole-batch retry assumed the FD engine issued the pipeline once.
After a connection-error replay it had issued it twice, so a lost first
write, LOADING on the replay and the pooled re-run made three executions
under MaxRetries:1, where an ordinary pipeline stops at two.

The reader now stamps each pipelined command's attempt count on its
batch before completing it, fdPipelined reports the highest, and the
re-run gets MaxRetries minus that, or none when it is spent.

* fix(autopipeline): keep FD pipelines off cluster children

A cluster node's FD child is the node client's cached async
autopipeliner, so ForEachMaster hands it to users, and its engine is
redirect-aware: it follows MOVED/ASK by re-running one command off the
pipe. A Pipeline() on it would let the rest of the batch run ahead of a
redirected command. FDPipelined now refuses redirect-aware engines, so
such a pipeline keeps the node client's ordinary pipeline, as before.

* fix(autopipeline): keep NoRetry batches off the FD pipe

An ordinary pipeline holding a NoRetry command retries nothing after a
connection error. The FD tail recovery replays per command, up to the
first sent NoRetry one, so a pipeline like SET k; RawWriteTo(GET x)
that lost its connection ran the SET twice. FDPipelined now refuses a
batch with a NoRetry command, and Pipeline() runs it the ordinary way.

The pipeline retry budget read fdAttempts, which only the reader
stamped. A command that was replayed and then failed carried no count,
so a pooled re-run could exceed MaxRetries. The batch is stamped at
submit and on every replay.

* fix(autopipeline): run pipeline hooks and metrics on FD

Pipeline() and FDPipelined kept away from the full-duplex path whenever
hooks, a Limiter or an OTel recorder were set, or bypassed them. They
now run on it:

- The batch runs inside the client's ProcessPipelineHook chain, with
  fdPipelineExec as its terminal where processPipeline sits in an
  ordinary pipeline. Fallback and the whole-batch retry happen inside
  the chain, so hooks run once. Batched commands get no per-command
  ProcessHook, as in an ordinary pipeline.
- An FD batch records one pipeline duration and, on failure, one
  pipeline-level error; the reader skips its per-command metrics for
  batched commands.
- A Limiter stays asked per write chunk, which can partly admit a batch
  longer than MaxBatchSize. Documented on FDPipelined.

Only ContextTimeoutEnabled still keeps the pooled pipeline.

* fix(autopipeline): no FD pipeline re-run after a replay

The whole-batch retry re-ran a pipeline whose first command got a
retryable reply even when the engine had already replayed its unread
tail after a connection error, so a command in that tail could run
three times where an ordinary pipeline runs it twice. It now re-runs
only a batch the engine issued once. The Close-time flush stamps the
batch as issued again, so a flushed batch is not re-run either.

The re-run continues the FD attempt's measurement (start and attempts)
through processPipelineRetriesAfter, so one pipeline metric covers the
whole operation, and a ctx that ends in the backoff still records it.

* fix(autopipeline): keep flushed FD pipelines whole

When Close wins against an admitted FD pipeline, shutdownFlush sends
its commands through flushReqs. That split them at MaxBatchSize and at
attempt boundaries, so a retryable first reply retried only the first
chunk. Every batch of one FD pipeline now points at its first batch
(fdGroup), and the flush keeps that run in one chunk, so the pooled
retry re-runs the pipeline as a whole.

The pooled flush already records the pipeline metric. fdPipelined now
reports that the flush ran the batch, and FDPipelined/fdPipelineExec
skip their own metric in that case. failQueue skips the per-command
error for pipelined commands, as failReqs does, since fdPipelineExec
reports the batch once.

* fix(autopipeline): FD batch errors and queue wait

An FD pipeline now returns a transport or protocol failure before any
Redis error, as processPipeline does, so a caller that checks only
Exec's error sees that later commands have no known result.

The whole-batch re-run for a retryable first reply is also ruled out
when any command in the batch failed that way. The engine does not
replay such a command, since it may already have run, so the re-run
could execute it twice; an ordinary pipeline does not retry there.

A batch waiting for more room than one writer take freed put the room
signal back, took it straight back, failed to fit, and spun holding
submitMu.RLock. It now passes a wake it cannot use on once per take,
which keeps the wake chain for smaller waiters without spinning.

* fix(autopipeline): FD pipeline metrics and backoff

The pooled re-run after an FD attempt restarted its retry backoff at the
first step. generalProcessPipelineFrom now continues it from the prior
attempts, so a pipeline sleeps the same steps an ordinary one does.

A pipeline refused before admission (ctx done, or closed) recorded no
pipeline metric; an ordinary pipeline records one when withPipelineConn
fails. It now records one with a single attempt.

The Close flush marked every batch flushed before running any, so a
batch it never ran (a dead endpoint fails the rest) recorded nothing.
It now marks a batch where its chunk runs. And the FD metric is skipped
only when the flush ran the whole pipeline: when it ran only an unread
tail, the FD metric still covers the whole operation.

* fix(autopipeline): skip FD duration with no start

The start time is read only when a pipeline duration callback exists.
A recorder installed while an FD pipeline ran found a zero start, and
fdPipelineMetrics reported time.Since(time.Time{}), a duration of
centuries. It now records no duration for that operation, as an
ordinary pipeline does, which reads the callback once at the start.

Also pin that CSC state commands (SELECT, AUTH, HELLO with a protocol,
RESET, CLIENT TRACKING) never ride the FD pipe: they leave it as
runsOutsidePipeline commands, so Pipeline() falls back to the ordinary
pipeline and its CSC guard, and FDPipelined refuses the batch.

* fix(autopipeline): Close flush budget and retry timing

An FD pipeline flushed on Close can carry commands with different
attempt counts, and runs as one pipeline. It took the retry budget of
its first command, so a later command with more attempts could run past
its MaxRetries+1 executions. It now gets the smallest budget of its
commands, and fails with errFDRetryBudgetExhausted when any has none
left. A command with more budget may get fewer retries than it alone
would, as in an ordinary pipeline, whose budget covers the whole batch.

The pooled re-run of an FD operation that began with no duration
callback (zero start) started its clock at the re-run when a recorder
was installed in between, so the duration covered only the tail while
the count included the FD attempt. generalProcessPipelineFrom now
records no duration for a continued operation with no start.

* fix(autopipeline): keep flush budget groups separate

flushCarryBudgeted grew a group while the next command had the same
attempt count or belonged to the same FD pipeline, so a command in front
of a pipeline joined that pipeline's budget group. The group's smallest
budget then applied to it, and a pipeline command with no budget left
failed the unrelated command too. A group is now one FD pipeline, or a
run of other commands with the same attempt count, never both.

* fix(autopipeline): stamp flush issues where they run

shutdownFlush stamped every queued pipelined batch with one more issue
before either flush ran. When the carry flush hit a dead endpoint the
fresh queue was only failed, never issued, yet its callers saw an issue
count of 2 and reported a retry that never happened. flushReqs now
stamps the issue, with fdFlushed, where it runs a chunk. A batch that
never runs keeps its count; the transport error it is failed with
already rules out a re-run in fdPipelineExec.

* fix(autopipeline): flush a split FD pipeline whole

When the writer took only a prefix of an FD pipeline before the session
failed, Close found the prefix in the carry and the tail still queued,
and flushed them as two pooled pipelines. A retryable first reply then
retried only the prefix, and the tail ran under a full budget of its
own. shutdownFlush now moves the queued tail into the carry, so the
pipeline runs as one at the smallest remaining budget of its commands.

* docs(autopipeline): note lease retries in FD metrics

A full-duplex pipeline's attempts count the times the batch was issued.
Connection-lease retries belong to the engine: a batch queued during
them waits and is issued once, so they are not counted, where an
ordinary pipeline counts each failed connection acquisition. Say so on
fdPipelineMetrics.

* fix(autopipeline): reserve queue room for FD batches (#4057)

* fix(autopipeline): reserve queue room for FD batches

* fix(autopipeline): FD batch head-of-line starvation

* fix(autopipeline): remove unused function

* fix(autopipeline): fail a split FD pipeline whole

Live connection-error recovery partitions the unacked tail per command
(budget spent, after a sent NoRetry, not replayable) and fails part of
it. When the writer had taken only a prefix of an FD pipeline, the rest
of that pipeline, kept for replay or still queued, ran on the next
connection, and Exec reported a transport error for a pipeline whose
later commands executed. A pipeline on a dedicated connection sends
nothing more once it has failed. failPipelines now fails the whole
pipeline: its members kept for replay and its tail at the queue head.

---------

Co-authored-by: Vladislav Kotsev <vladygk8@gmail.com>
2026-10-02 16:15:05 +03:00
Nedyalko Dyakov 8546b33468 perf(autopipeline)!: batch full-duplex reply reads and use a queue instead of chan (#4018)
* perf(autopipeline): batch full-duplex reply reads

The full-duplex reader used one WithReader per reply, which re-armed the
socket read deadline on every reply: 5.85% of all CPU in SetReadDeadline
plus 1.95% in deadline() at ~400k replies/s.

Read one WithReader per already-buffered GROUP of replies instead. Reading
the whole in-flight snapshot in a single WithReader was tried before and was
slower, because it blocks on commands the writer has pushed but not yet
flushed. This cannot do that: after the first reply of a group it continues
only while rd.Buffered() > 0, so it never waits on the socket inside a group
and writer/reader overlap is preserved.

Refs #4014

* perf(autopipeline): replace the FD submit channel with a slice queue

The full-duplex submit path was a buffered channel. Every command cost the
submitter a channel send and woke the writer once, so the writer re-entered
its select roughly once per command: that single select measured 3.25 s,
6.9% of all CPU, at ~450k ops/s, with selectgo at 7.4% and procyield at 4.5%.

fdQueue is a mutex plus a slice. The writer takes a whole wave in ONE lock
and one bulk copy, and park(minDepth) expresses "wake me when the queue holds
N more, or not at all" -- something a channel cannot say, since a channel can
only wake on the next arrival. selectgo and procyield leave the profile
entirely, and total samples fell 47.0 s -> 45.6 s while throughput rose.

Measured against stock 9.23 full-duplex, both WITHOUT a flush delay, so this
isolates the submit path and the batched reads from the accumulation feature
(2 vCPU client, 64B GET/SET 70/30, inflight 1, one wire, 3 passes, medians):

	callers      stock      queue    delta
	    128    199,600    216,000     +8.2%
	    256    243,454    287,049    +17.9%
	    512    323,669    387,301    +19.7%
	  1,024    366,850    443,017    +20.8%
	  2,048    360,880    432,862    +19.9%

Comparing WITH a delay would overstate this commit: stock 9.23 does not
reference MaxFlushDelay in the full-duplex path at all, so a delayed stock arm
is the same configuration as an undelayed one, and the difference would also
contain everything #4014 adds. With the accumulation on top the total against
stock is roughly +40% at 256 callers and above, of which about half is this
commit and about half is the accumulation.

This commit also makes MaxFlushDelay a true CEILING rather than a fixed pause:
the writer arms a short grace (fdAccumGrace) and re-arms it only while the
batch is still growing, so a wave that keeps arriving rides to the cap while a
wave that stops flushes after one grace. That belongs here rather than in
#4014 because it only pays WITH the queue -- ported onto the channel submit
path it measured neutral to -3.2% (0/3 paired passes won at 1024 callers),
since re-arming per arrival costs a select re-entry that the queue's per-wave
take amortises.

The queue methods all tolerate a nil receiver, reproducing nil-channel
semantics, because engines built without one (tests, and any future
construction path) previously relied on a nil channel blocking forever.

Refs #4014

* perf(autopipeline): lower the FD accumulation gate for the queue path

The in-flight threshold that decides whether waiting MaxFlushDelay is
worthwhile depends on the SUBMIT MECHANISM, not only on the workload, so the
queue path wants a different value from the channel path it replaces.

Measured as a one-line diff on each path, 2 vCPU client, 64B GET/SET 70/30,
inflight 1, one wire, 3 paired passes per width (gate 128 against gate 512):

	callers   channel submit      slice queue
	    128    -11.1% (0/3)      +1.3% (2/3)
	    256     +5.2% (3/3)     +21.8% (3/3)
	    512     +1.7% (2/3)     +15.7% (3/3)
	  1,024     -0.5% (1/3)      +1.7% (2/3)
	  2,048     +1.5% (3/3)      -1.4% (1/3)

On the channel path the lower gate costs 11% at 128 callers because waiting
there wakes the writer once per arriving command; #4014 therefore keeps
window>>7. With the queue the writer takes a whole wave per lock, so the same
gate is free at 128 callers and worth +21.8% at 256 and +15.7% at 512, winning
every paired pass, with p50 improving too (851 -> 716us at 256, 1215 -> 1049us
at 512).

Refs #4014

* refactor(autopipeline): drop FullDuplexFastSubmit

The submit channel this option existed for is gone. FullDuplexFastSubmit
skipped a blocking three-arm select on every submit, trading submit
fairness for the selectgo cost. The slice queue replaced that channel
with a mutex plus an append, which has no wait to skip, so the option
became a no-op that only bumped a counter -- a public performance knob
that silently stops meaning anything.

Measured on a 2 vCPU client against managed Redis (RTT ~206us), 7 paired
passes, 128-2048 concurrent callers. While the channel path is in place
the option does fire on essentially every submit (2.9-5.4M fast-path
takes per cell, 0 with it off) and it does pay: +3.95% throughput at 512
callers and +4.85% at 1024, both winning 7 of 7 passes, and -2.7% to
-5.4% CPU per op winning 6-7 of 7 at every width. Its documented fairness
cost did not appear, because the writer keeps the channel shallow enough
that the depth gate never closes and no producer is left blocked to
starve.

The queue supersedes all of it: +12.6% to +37.5% over the same baseline,
7 of 7 at every width, so the option recovered only 1-24% of what
replaces it. On the queue path there is nothing left for it to take.

Only ever shipped in v9.23.0-beta.1; absent from v9.22.0, so removing it
before v9.23.0 is not a break for any stable consumer.

TestFullDuplexFastSubmitCloseRace kept its value as a submit/Close race
test, so it is renamed TestFullDuplexSubmitCloseRace and keeps racing
pushes against the shutdown drain; only the flag and the take-count
assertion are gone.

The 9.23.0-beta.1 release notes still list the option and stay that way:
that beta did ship it, and older entries are left untouched. The removal
belongs in the 9.23.0 notes, written when that release is cut.

Refs #4018

* perf(autopipeline): shrink fdReq by storing the write time as an offset

fdReq is copied at least three times per command -- into the submit queue, out
of it with the wave, and into the in-flight ring -- and runtime.duffcopy
measured 4.5% of all CPU with 46% of it attributed to those copies. time.Time
was the largest field at 24 bytes of 96.

Store nanoseconds since an engine-lifetime epoch instead: fdReq drops to 80
bytes, a 16.7% reduction on every copy.

The offset is taken with now.Sub(epoch) against an epoch captured once when the
engine is built, so it inherits the monotonic reading that time.Time carries and
the OTel duration metric stays immune to a wall-clock step mid-command. Storing
a UnixNano() would also be 8 bytes but would lose exactly that. The epoch must
be a real reading rather than the zero Time: Sub against the zero Time saturates
(the wall clock is ~2000 years past it, well beyond a Duration's ~292-year
range) and time.Since would fall back to the wall clock.

Zero still means "never written", floored at 1ns so a command written within a
nanosecond of the epoch is not mistaken for one. writtenTime() reconstructs the
absolute time for the retry path, which reports it to the normal pipeline.

* perf(autopipeline): bound the FD submit queue by the window

The queue kept the old channel's 4096 cap. That cap existed because a
buffered chan allocates its whole capacity up front -- several MiB per
engine at the default 65536 window, before a command is submitted. The
slice queue starts at 64 entries and grows to the live depth, so the cost
it avoided is gone.

The cap was not free. It bounds ADMISSION, not memory: a submitter whose
batch does not fit parks on the queue's cap-1 room signal and is woken
one at a time. With 4096 slots, 4096 callers pipelining 10 commands each
want 40960 slots, so roughly 3700 of them serialize on that relay.

Measured on a 14-core client against a local server, 4096 callers x 10
commands per pipeline, 3 passes:

  cap 4096      9,232 ops/s   p50 2,932 ms
  window      781,443 ops/s   p50    52 ms

Resident memory rises with it, 221 -> 855 MB, but that is the cost of
actually having 40960 commands outstanding rather than a property of the
bound: the commands live in the in-flight deque, which the window already
bounds. The capped arm was cheap only because it was stalled.

A broadcast wake was tried instead, to keep the 4096 bound and cure the
relay. It recovers most of the throughput (632k) but costs 69% more CPU
(11.00 vs 5.26 us/op) from every waiter waking on each release, and saves
no memory (883 MB). Raising the bound is both simpler and better.

Total outstanding is still bounded, now by 2*window.

* fix(autopipeline): harden the batched FD reader

A fatal reply ended only its read group. The reader then issued another
WithReader on the desynced stream, and advance(done) dropped the failed
command without completing it, so its caller hung. A fatal reply now
ends the whole snapshot, which also keeps done a contiguous prefix.

A read group shares one deadline. A reply that is only partly buffered
needs another socket read and got whatever earlier replies left. The
group now ends once half of ReadTimeout is spent, so the next WithReader
re-arms a full deadline.

* fix(autopipeline): O(batch) FD queue drain

takeInto shifted the whole remaining queue down under the submit lock
on every writer wave. At a full default queue (65536) that moved about
6.8 MB per 200-command wave and blocked every submitter while it did.
The queue now keeps a head offset: a take costs O(batch), the dead
prefix is reset when the queue empties and compacted once it is at
least as long as the live part, so a compaction never copies more than
was taken since the last one.

FullDuplexFastSubmit shipped in v9.23.0-beta.1. Removing it broke code
that sets it; it is back as a deprecated no-op field.

* refactor(autopipeline): drop unused fdQueue.pushBatch

Its only caller is FDPipelined, which arrives with the full-duplex
pipeline change, so on its own this change failed lint (unused). It
moves there. The queue model test and the deep-drain benchmark use
push instead.

* fix(autopipeline): contain FD group panics, free burst buffer

The grouped reader parses several replies before completing them. A
decoder panic on a later reply (a RawWriteTo whose writer panics)
escaped to the reader's recover, which never counted the group's read
replies, so recovery replayed them: a mutation could run twice, and an
already-parsed result was overwritten. The panic is now turned into a
fatal error for that command, so the replies before it complete and
recovery starts at it, as the per-reply loop did.

A drained submit queue kept the backing array a burst had grown (several
MiB at the default window) for the engine's lifetime. It now releases
an array past 4096 slots when it empties.

* refactor(autopipeline)!: drop FullDuplexFastSubmit

The field was kept as a deprecated no-op so code written against
v9.23.0-beta.1 still compiled. It has had no effect since the submit
channel became a queue, and it shipped only in a beta, so remove it.

BREAKING CHANGE: AutoPipelineOptions.FullDuplexFastSubmit is removed.
Delete the field where it is set; it had no effect.

* fix(autopipeline): full deadline per FD socket read

A read group runs under the one deadline its WithReader arms. It went
on while the buffer was not empty and stopped after half the timeout,
so a partly buffered reply reached early in a group still read the
socket under the earlier deadline: it could time out, and the tail be
torn down and replayed, although it arrived within its own ReadTimeout.

The group now goes on only while the next reply is already whole in
the buffer (proto.Reader.HasBufferedReply, a non-consuming scan, about
26 ns and no allocation per reply). A reply that needs the socket
starts the next group, whose WithReader arms a full deadline.

* fix(autopipeline): contain push panics in a read group

The grouped reader drains pending pushes before each reply. A panic in a
push handler (CSC invalidation, a registered or custom processor)
escaped the group's WithReader, so the replies the group had already
read were never completed and session recovery replayed them: a write
read before the push ran twice. fdPushDrainSafe turns the panic into a
fatal drain error at that position, as fdReadReplySafe does for the
decoder, so the replies before it complete.

HasBufferedReply counted a RESP3 attribute nested in an aggregate as an
element, so it could report a reply whose decorated element was only
partly buffered as complete, and the group then read the socket under
the previous reply's deadline. An attribute now prefixes the element
after it.
2026-10-01 18:04:17 +03:00
Nedyalko Dyakov f5ef5ed354 fix(csc): apply invalidation batches in one shard pass (#4033)
* fix(csc): apply invalidation batches in one shard pass

ClientSideCacheInvalidationBatchWindow applied only a fraction of the
invalidations it received under load. apply() looped per key calling
deleteByRedisKeyCollectingHot, and that walks all 16 shards taking each
write lock, so one worker doing 16*N contended acquisitions fell
permanently behind the arriving stream. The rest stayed in the spill
buffer, and the cache went on serving values the server had invalidated
while reporting a ~100% hit rate.

Measured on a 20k/sec invalidation stream: 7,729 of 68,846 enqueued
invalidations applied (15.5%), with nothing dropped by policy
(discarded=0, epochSkip=0).

Invert the loops: take each shard's lock once per batch and handle every
key of the batch under it, so a batch costs 16 acquisitions instead of
16 per key. Per-key semantics (fetch-order guard, recency horizon,
target collection) are unchanged; only the locking granularity differs.
Invalidations applied goes to 100%.

* docs(csc): note that cached reads are not monotonic

The invalidation batch window already documents staleness -- a reader can
see the pre-invalidation value for up to the window. It does not document
the ordering consequence: a caller reading through a non-cached path can
observe a newer value than a concurrent in-flight fetch is about to
publish, and a later cached read then returns the older one.

This is inherent to asynchronous invalidation, not specific to the window.
At publish time the client cannot know its in-flight value is already
stale, because the only signal is a push that has not arrived yet; a guard
that refuses to publish values superseded by an observed invalidation was
tried and never fires. The window widens the exposure, because a published
value stays readable until its invalidation is APPLIED rather than
observed.

Observed roughly once per 44M reads with a 5ms window, and not at all with
the window off.

* fix(csc): keep the read bit on batched refreshes

collectHotAndDeleteBatch built the refresh target without the entry's
second-chance bit, which collectHotAndDelete copies for
stageRefreshAccess. With the invalidation batch window and refresh on,
a refreshed hot key was republished with its bit clear and was the next
eviction victim in a warm shard. Copy it, as the single-key path does.
2026-10-01 17:45:12 +03:00
Nedyalko Dyakov b32512362f perf(autopipeline): honor MaxFlushDelay in FD (#4014)
* perf(autopipeline): honor MaxFlushDelay in FD

The full-duplex writer's drain loop is non-blocking: it flushes whatever
is already queued and never waits. At low concurrency that is correct - a
lone caller pays one round trip and nothing waits on its behalf - but
under load it pays batch and syscall overhead at per-command frequency.

Measured on a 2-vCPU client (64B, GET/SET 70/30, one connection):

  1024 callers, full-duplex   4.6 commands/flush, ~75k write syscalls/s,
                              CPU pinned at 196%
  1024 callers, half-duplex   460 commands/flush, ~670 syscalls/s,
                              130% CPU

MaxFlushDelay was never honored here (no references in
autopipeline_fullduplex.go), so there was no way to coalesce. Honor it,
but gate the wait on in-flight depth: below fdAccumMinFor(window) the
writer never waits, so low-concurrency callers keep their 1xRTT
behaviour. Waiting unconditionally regressed 64 callers from 130k ops/s
at p50 0.48ms to 41k at p50 1.59ms - the same failure documented for the
half-duplex path's old debounce timer.

With MaxFlushDelay=250us:

  callers   before             after
       64   130k, p50 0.48ms   134k, p50 0.45ms   (unchanged)
      256   237k, 160% CPU     228k, 112% CPU     (30% less CPU)
     1024   345k, p99 4.78ms   420k, p99 3.53ms   (+22% ops, -26% p99)
     2048   308k, p99 10.1ms   386k, p99 7.19ms   (+25% ops, -29% p99)

MaxFlushDelay defaults to 0, so the default path is unchanged and the
coalescing is strictly opt-in.

* fix(autopipeline): correct the FD accumulation wait

Three defects in the MaxFlushDelay wait added by this PR.

The wait returned on the first arriving command, so it waited for an
arrival rather than accumulating to a deadline. Measured 4.6 -> 7.9
commands per flush and +1% throughput, against 178 and +22% once the
writer waits out the full window. Arm the timer once per batch, then
re-enter the select until it fires.

The command arm stopped the timer and then received from its channel
unconditionally. Under Go 1.23+ timer semantics a failed Stop() does not
guarantee a pending value, so that receive can block forever and wedge
the writer and Close. The arm no longer touches the timer at all - it
stays armed for the rest of the window - and re-arming drains
non-blockingly.

The wait observed only the submit channel and its own timer, so
ctx.Done(), readerDone and max-hold could not interrupt it: a writer
parked on MaxFlushDelay held its connection past FullDuplexMaxHold and
delayed Close by up to the configured delay. All three now stop the wait
and flush the prefix already taken off the queue, so the existing
graceful, recycle and connection-error paths end the session. maxC comes
from a one-shot timer, so its tick is carried and acted on after the
flush instead of being consumed and dropped.

Adds tests for the threshold helper, the no-wait contract at low
concurrency, and correctness through the armed wait under load.

* perf(autopipeline): raise the FD accumulation gate to window>>7

fdAccumMinFor returned max(64, window>>9) = 128 at the default window, so the
MaxFlushDelay wait armed from 128 concurrent callers. Measured on the channel
engine it runs at 0.93x the undelayed throughput there, because a 250us window
collects too few commands to pay for itself; window>>7 makes it 0.99x and
keeps the win at higher concurrency (1.02x at 512, 1.09x at 1024, 1.13x at 2048).

The trade is explicit: this gives up a 1.07x gain at 256 callers. It is taken
because the wait only raises throughput when CPU is the binding constraint, and
below this threshold there is CPU headroom, so the writer idles out the window
instead. The margin is far larger on the full-duplex slice-queue engine, where
the same threshold costs 0.39x at 128 callers and 0.82x at 256.

TestFDAccumMinFor pins this constant and its floor boundary, so it moves with it.

Refs #4014

* fix(autopipeline): no FD write after the reader exits

The MaxFlushDelay accumulation wait returns when the reader exits, and
the writer then wrote the batch it had taken off the queue. A read
timeout leaves the socket open, so the server ran those commands, and
recovery replayed them as unacknowledged: one command, two executions.
The writer now keeps such a batch unsent and the connection-error path
recovers it behind the unacked tail as never sent (refunded), so it
runs once. A handoff mark seen at the same point keeps the batch unsent
too and recycles the connection, so nothing more goes to a connection
that is being moved.

Also document that AdaptiveDelay applies to the half-duplex batcher
only; the full-duplex writer has its own in-flight-gated wait.
2026-10-01 17:38:32 +03:00
Nedyalko Dyakov 0bc2cb8dc2 perf(csc): build the key list only on a miss (#4037)
* perf(csc): build the key list only on a miss

extractRedisKeys and the namespaced copy ran before the cache-hit
check, but only Reserve uses them and Reserve is on the miss path. At a
94-99% hit rate that allocated a key slice, a namespaced slice and one
string per key on nearly every read and discarded all three -- by
object count about 18% of everything the client allocated.

Object count is what matters here: after cache-key pooling cut
allocated bytes by 30%, memclrNoHeapPointers was still 20% of CPU,
because malloc and zeroing track the number of objects, not their size.

Deferring the "no key list, not cacheable" gate along with it is safe:
extractRedisKeys depends only on the command's shape, so a command with
no key list was never cacheable and can never be a hit. The gate still
runs before anything is cached.

* fix(csc): check key types before the cache key

Deferring the key list to the miss path also deferred the key-type
check, so buildCacheKeyNS encoded the arguments first. For a key the
cache cannot render, such as a BinaryMarshaler, MarshalBinary then ran
twice: once for the cache key and once for the request.

The key positions of every cacheable command are one contiguous span.
processCached now reads that span and checks its types before it
builds the cache key, without allocating, and builds the key strings
only on a miss. extractRedisKeys moves to the tests.

* fix(csc): reject negative key positions

SetFirstKeyPos is public and takes any int8. isCacheable accepted any
nonzero position and cscKeySpan rejected only 0, so a negative one let
cscKeysRenderable index args[-1] and panic the process. The keyArg
bounds check this replaced had treated such a command as uncacheable.
Both now require a position above 0, so the command is sent normally.
2026-10-01 15:22:20 +03:00
Nedyalko Dyakov ac839125d6 perf(csc): pool the cache-key scratch, fuse the namespace (#4036)
* perf(csc): pool the cache-key scratch, fuse the namespace

Building a cache key was 32% of all bytes allocated on the cached read
path. Every call heap-allocated a bytes.Buffer, a proto.Writer (which
itself allocates two 64-byte scratch slices, 16.2% of all bytes on its
own), grew the buffer, and copied it out with String() -- and then the
caller did prefix+key for a second full copy of every key.

Only the final String() is inherent: the key is retained, the scratch
is not. Pool the buffer and writer, and write the namespace into the
buffer before the args so one String() yields the namespaced key
directly. Five allocations become one.

proto.Writer gains Reset so a writer can be re-targeted rather than
reallocated.

Total bytes allocated 9,858MB -> 6,915MB (-30%); NewWriter,
buildCacheKey, cscNamespacedKey and Buffer.grow leave the profile.
Throughput +8.7% at 256 callers, +13.6% at 1024, +37.4% at 10k
invalidations/s, with p99 down in every cell.

* fix(csc): move the un-namespaced cache-key helper to tests

buildCacheKey lost its last non-test caller when the cached read path
moved to buildCacheKeyNS, which builds the namespaced key in one
allocation. golangci-lint runs with run.tests=false, so its remaining
callers in csc_test.go do not count and the unused check fails.

Move it to csc_cachekey_test.go: production keeps a single entry point
and the tests keep the un-namespaced form they assert on.
2026-10-01 14:49:53 +03:00
Nedyalko Dyakov 2ecf759f46 perf(csc): skip the read mark below eviction pressure (#4035)
* perf(csc): skip the read mark below eviction pressure

Even after the second-chance bit replaced the per-hit recency stamp,
the surviving store was 9.7% of all CPU -- 15.24s of 157.84s, against
50ms for the Load that guards it.

It stays hot because entries are invalidated and refetched constantly
and fulfill never set the bit, so every fresh entry's first read finds
it clear; each such store invalidates, across every core, a line the
readers hold SHARED. (Eviction sweeps clearing the bit were ruled out:
raising the entry budget from 150k to 400k left the cost unchanged.)

The bit is read by nothing except eviction, so do not maintain it on a
shard that is nowhere near its cap. len(s.entries) is safe to read on
the hit path: writers hold the write lock, readers the read lock.

cacheShard.get drops from 10.80% of CPU to 1.44%. Throughput +8.5% to
+20.6% and p99 -13% to -44% on an over-provisioned cache; neutral when
the cache sits at its cap (+1.1%/+0.1%, hit rate identical), which is
the case where the gate cannot help by construction.

* docs(csc): note the pressure gate's warm-up trade

A read made while a shard is below the gate threshold leaves no mark,
so the first evictions after a gradual warm-up go by insertion order
among such entries. Recovering those reads would need the per-read
store the gate exists to skip.
2026-10-01 14:37:51 +03:00
Nedyalko Dyakov 58cc03e024 perf(csc)!: mark reads with a second-chance bit (#4034)
* feat(csc)!: always refresh invalidated entries

ClientSideCacheRefreshRecencyWindow bounded refresh to entries read
within a window. It was the only consumer that needed a per-READ
recency timestamp on the cache entry, and that timestamp is the
dominant cost of a cache hit.

Removing the option lets the read path stop stamping entries, which the
following commit does. Refresh now covers every invalidated entry.

BREAKING CHANGE: Options.ClientSideCacheRefreshRecencyWindow is removed.
Refresh-on-invalidate always refreshes every invalidated entry.

* perf(csc): mark reads with a second-chance bit

A cache hit stamped entry.lastAccessNs. The read already touches the
entry's cache line, but a store dirties it, so the line must be held
exclusive and costs a writeback when evicted from the CPU cache. With
many concurrent readers over a working set larger than L2, that store
dominates the cost of a hit.

A read now sets a readSinceSweep bit instead. A load that finds it set
leaves the line SHARED, so every core can hold it at once and only the
first read after an eviction sweep writes. Eviction consumes the bit as
a second chance; ordering still uses lastAccessNs, written on insert.

Measured at 256 concurrent callers, get=90/set=10, 99.94% hit rate:
263,801 -> 328,842 reads/s (+24.7%), CPU 27.0 -> 18.9 us/op, hit rate
unchanged. Removing the recency update entirely measured +40.5%, so
this recovers about three fifths of the headroom while keeping
eviction honest.

* perf(csc): decode simple cached replies without a reader

Serving a hit allocated a proto.Reader (and its bufio buffer) per hit
and re-parsed the cached bytes. applyCachedFast decodes the simple
single-frame replies -- nil, status, bulk string -- straight into the
Cmder, and getShared serves the bytes without the defensive copy where
the caller provably cannot retain them.

* fix(csc): do not evict the entry just fulfilled

fulfill publishes an entry with its second-chance bit clear and then
evicts. In a warm memory-capped shard, where every resident entry has
been read, that entry was the only clear-bit candidate and was evicted
on the spot; and since a clear candidate always existed, the sweep that
resets the others never ran, so the shard admitted nothing new. The
entry just fulfilled is now skipped in its own eviction pass.

A refresh republish also restores the invalidated entry's read bit with
its access token, so a refreshed hot key is not the first victim.

* fix(csc): decline negative bulk lengths other than -1

The fast cached-reply decoder took any negative bulk length as a RESP2
null. The reader rejects lengths below -1, and that rejection is what
makes processCached drop a bad entry and refetch; the fast path turned
it into a false miss. It now handles only -1 and declines the rest.

* fix(csc): reject bare LF in cached status replies

The fast cached-reply decoder rejected a status frame with an interior
CR but took one with a bare LF, such as "+OK\nMORE\r\n", as a valid
string. The reader stops at the first LF and rejects the frame, and
that rejection is what makes processCached drop the entry and refetch.
The fast path now declines an interior CR or LF.

* fix(csc): keep refresh recency in the publish lock

A refresh republish published the entry with its second-chance bit
clear and restored the bit in a second lock. On a warm shard the
republished entry was then the only clear-bit victim, so an insert in
that gap evicted the key the refresh had just fetched. The refresher
now stages the kept recency on its reservation (stageRefreshAccess),
and fulfill applies it in the same lock as the publish.

* docs(csc)!: refresh-on-invalidate refreshes all

ClientSideCacheRefreshOnInvalidate still said it re-fetches recently
read keys. Since the recency window was removed, it re-fetches every
cached entry of an invalidated key, so on a write-heavy keyspace the
refetch traffic covers the whole resident cache.

BREAKING CHANGE: Options.ClientSideCacheRefreshRecencyWindow and
UniversalOptions.ClientSideCacheRefreshRecencyWindow are removed. They
shipped only in v9.23.0-beta.1; delete the field where it is set.

* docs(csc): note reads are not monotonic per caller

A read that loses the reservation twice reads the server directly.
Another caller's fetch, reserved earlier and still running, can store
an older value afterwards, and a later hit returns it until the key's
invalidation arrives. This is within the staleness a hit already has,
so document it rather than drop the older fetch, which would cost its
waiters a miss each under the churn that leads here.
2026-10-01 14:36:58 +03:00
Nedyalko Dyakov a8c360cc45 fix(command): set key position for 16 commands (#4048)
* fix(command): set key position for 16 commands

The shared key resolver routes Ring shards, cluster slots, client-side
caching and full-duplex engines. For typed commands whose key is not
args[1] it fell back to args[1], or, on a warm cluster cache, to the
COMMAND first key, which is 0 for container and numkeys commands:

  BITOP (all BitOp*), MIGRATE           op / host instead of the key
  OBJECT ENCODING/FREQ/IDLETIME/REFCOUNT, XINFO STREAM/GROUPS/CONSUMERS
                                        subcommand instead of the key
  LMPOP, SINTERCARD, ZINTERCARD, ZMPOP  numkeys instead of the key
  BLMPOP, BZMPOP                        timeout instead of the key

Ring sent them to the wrong shard; cluster recovered through MOVED.
The constructors now set the key position, as XGroup*, ZUnion and FCall
already do. A raw FCALL/FCALL_RO also resolves its key after numkeys,
like EVAL, instead of hashing the function name.

* fix(command): route raw forms by their key

The typed constructors of the commands whose key is not args[1] set the
key position; a raw Do / NewCmd of the same commands does not, so the
shared resolver fell back to args[1]: raw XREAD hashed "streams" and raw
LMPOP its numkeys, which picks the wrong Ring shard, the wrong
full-duplex engine, or a cold cluster slot.

The resolver now finds the key for the raw forms: XREAD and XREADGROUP
after STREAMS (XREADGROUP scans past the group and consumer names),
OBJECT and XINFO (HELP is keyless), BITOP, LMPOP, ZMPOP, SINTERCARD,
ZINTERCARD, BLMPOP, BZMPOP, and MIGRATE, including its KEYS form.

* fix(command): walk MIGRATE options for KEYS

The raw MIGRATE KEYS form was located by scanning for the first "keys"
token after the timeout, so an AUTH or AUTH2 operand that reads "keys"
was taken for the clause and the resolver routed by the token after it.
It now walks the option grammar: COPY and REPLACE take no operand,
AUTH takes one and AUTH2 two, and the key follows KEYS.
2026-10-01 14:19:35 +03:00
d907b985fc feat(autopipeline): full duplex flusher (#4002)
* feat(autopipeline): ordered full-duplex dispatch (#3964)

* fix(pipeline): PipelinePoolSize alone should create the pipeline pool

The dedicated pipeline connection pool was created only when
PipelineReadBufferSize or PipelineWriteBufferSize was set. Setting just
PipelinePoolSize therefore produced no pipeline pool at all, with no error and
no log line, and every pipelined operation silently kept using the main
connection pool — competing with ordinary commands for the same connections,
which is the opposite of what the option asks for.

The gating was inverted. The pool size is the primary intent ("give pipelines
their own N connections"); the buffer sizes are tuning. And the buffer sizes
already supplied a default for the size (10), so the dependency only ran one
way.

Any of the three options now creates the pool. Enabling it by size inherits the
regular ReadBufferSize/WriteBufferSize, so a caller who only wants to bound
pipeline connections no longer has to know that a buffer size is secretly
required.

Found while measuring client-side caching over a high-latency link: the CSC
background batched reads go through withPipelineConn, which falls back to the
regular pool when none exists, so they were contending with the very reader
misses they exist to prevent.

Tests cover all four option combinations and were verified to fail with the
gate reverted.

* feat(pipeline): give autopipelined clients a dedicated pipeline pool by default

Autopipelining is pipeline-heavy by definition, yet the dedicated pipeline
pool still had to be enabled separately; without it every batch competes
with regular commands for main-pool connections. Two paths now default it in:

Config time: Options.init defaults PipelinePoolSize to DefaultPipelinePoolSize
(new exported const, 10) when AutoPipelineOptions is set. Cluster node clients
do not inherit AutoPipelineOptions (the cluster autopipeliner lives at cluster
level and dispatches into the node clients' pipeline hooks), so clientOptions
applies the same default there. Sentinel and failover node clients inherit
AutoPipelineOptions and are covered by Options.init; Ring does not support
autopipelining. A negative PipelinePoolSize opts out explicitly.

Runtime: the common pattern configures the autopipeliner after NewClient
(AutoPipelineWithOptions on a plain client), where pool creation has already
happened. ensurePipelinePool now builds the pool lazily when an autopipeliner
is created. baseClient.pipelinePool becomes a pointer to an atomic slot
(pool + pool-name ref) so the lazy publication is race-free against
concurrent Pipelined callers on the hot path, and WithTimeout clones share
the slot — two sharers cannot install two different pools (CAS; the loser
closes its candidate). Streaming-credentials and maintnotifications hooks are
wired before publication; otel registration happens for the winner. Conn, Tx
and SentinelClient never allocate the slot and keep using the main pool.

The extraction into buildPipelinePool also surfaced that NewFailoverClient
kept a duplicated creation block with the pre-fix gate (buffer sizes only),
so PipelinePoolSize alone silently created no pipeline pool on failover
clients; the shared helper fixes that, with a regression test. Stale
RingOptions/FailoverOptions doc lines still describing the old gate are
corrected.

* feat(pipeline): 64KiB default buffers and no pre-dial for the pipeline pool

The options the dedicated pipeline pool is built with are now resolved by
pipelinePoolOptions, a pure function with pinned tests:

Buffers default to DefaultPipelineBufferSize (new exported const, 64 KiB)
rather than inheriting the regular 32 KiB. Pipeline connections move whole
batches per round trip and earn bigger buffers than per-command traffic:
measured on the autopipeline engine, throughput plateaus around 64 KiB,
gains nothing past ~128 KiB, and very large buffers can regress it. An
explicitly larger regular buffer is kept rather than shrunk, and explicit
pipeline buffer sizes always win. The RESP3 minimum clamp applies as on the
main pool.

MinIdleConns is forced to 0 on the pipeline pool. It previously inherited
the main pool's value through the options clone, so a client with
MinIdleConns set pre-dialed that many pipeline connections at pool creation
— a silent doubling of the idle footprint. The pipeline pool is burst
capacity: its connections dial on demand, and the pool size
(PipelinePoolSize or DefaultPipelinePoolSize) is only a cap, so an unused
pipeline pool holds zero connections.

* test: make pipeline-conn specs aware of the dedicated pipeline pool

The autopipeline-subjects job builds its suite subject by calling
AutoPipelineWithOptions on the shared rawClient, which now (correctly)
creates the dedicated pipeline pool lazily - so client.Pipelined and
client.Pipeline run on pipeline-pool connections, not the main pool's.
Two specs assumed the old sharing:

should Auth asserted main-pool stats (Hits=2/Misses=1/TotalConns=1) after
two Pipelined calls. It now branches on PoolStats.PipelineStats: when the
dedicated pool exists, the two pipelines account there (first dials,
second reuses) and the main pool holds only the BeforeEach FlushDB dial;
in the default subject mode PipelineStats is nil and the original
assertions run unchanged.

should ClientSetInfo issued CLIENT SETINFO through a pipeline and then read
CLIENT INFO through rawClient - with a pipeline pool those are different
connections, and SETINFO is per-connection state. It now reads CLIENT INFO
through the same pipeline that issued the SETINFOs, which is equivalent on
a single-pool client and correct on a split-pool one.

Verified against redis:8 in all three GOREDIS_TEST_SUBJECT modes
(default, ap-blocking, ap-async): Ran 2 of 1543 Specs, 2 Passed.

* test: warm the pipeline-pool conn in AP tests that instrument connections

Two autopipeline unit tests assumed batches execute on the main pool's
already-initialized connection; with the dedicated pipeline pool now
created lazily at autopipeliner creation, a batch's first dispatch dials
and initializes a fresh pipeline-pool connection instead. Both tests now
warm that connection with a real two-command batch first (a lone command
takes the solo fast path through Process on the main pool and would not
touch the pipeline pool):

TestAutoPipelineRetriesOnNetworkError counted the pipeline pool's first
dial as if it were a retry redial (3 dials observed, 2 expected). The
warm-up runs before the write failure is armed, so the count again
measures exactly the one redial the retry performs.

TestAutoPipelineHookPostNextErrorPartialBatch installed a hook that
unconditionally injects a post-next error before the pipeline-pool
connection existed. Connection init runs its handshake pipeline through
the client's hook chain (newConn shares hooksMixin), so the hook failed
the pipeline conn's init itself — retries exhausted, the injected error
was stamped on every command, and the test measured init poisoning
rather than the post-next outcome rule it pins. The warm-up initializes
the connection before the hook is installed.

* docs: TODO(v10) — ClientTracking* and ClientMaintNotifications are stateful

CLIENT TRACKING and CLIENT MAINT_NOTIFICATIONS mutate per-connection
state, exactly like CLIENT SETNAME/SETINFO, but live on cmdable: on a
pooled client they land on an arbitrary pool connection that is
immediately returned to the pool, so the flag applies to a connection
the caller cannot address again. They belong on statefulCmdable, next
to ClientSetName — but the move removes the methods from
Client/UniversalClient/Cmdable (and defining them on both cmdable and
statefulCmdable makes every Pipeline selector ambiguous), so it is a
breaking change deferred to v10. Documented now; the move happens in a
follow-up PR. No behavior change.

* docs: drop the TODO(v10) notes on the per-connection commands

The ClientTracking*/ClientMaintNotifications move to statefulCmdable ships
as its own PR against master (feature/stateful-conn-commands) rather than
waiting for v10, so the TODO notes here are obsolete. Docs only.

* feat(pipeline): always create the pipeline pool; spill on exhaustion

The dedicated pipeline pool is now created unconditionally at
NewClient/NewFailoverClient — like the pubsub pool — instead of behind a
matrix of triggers (pipeline options set, AutoPipelineOptions declared,
or lazily when an autopipeliner was built). It is pure burst capacity:
no pre-dialing (MinIdleConns forced to 0), a small cap
(DefaultPipelinePoolSize) and 64 KiB buffers, so an unused pipeline pool
holds zero connections and costs nothing, while pipelines stop competing
with regular commands for main-pool connections. PipelinePoolSize < 0
opts out and restores the old pipelines-on-the-main-pool behavior.

To keep this from capping heavy concurrent Pipelined callers — who
previously shared the much larger main pool — withPipelineConn spills to
the main pool when the pipeline pool is exhausted (ErrPoolTimeout):
bursts wider than the cap keep their old capacity, spilled pipelines
simply run with regular buffers, and total connections stay bounded by
PoolSize + PipelinePoolSize.

Pipeline-pool connections are excluded from CLIENT TRACKING during init.
Pipelined commands never consult or populate the built-in client-side
cache (only the single-command cached path on main-pool connections
does), so tracking pipeline reads only grew the server's tracking table
and produced invalidation pushes for keys the cache does not hold.

With creation unconditional, the lazy machinery is deleted: the atomic
slot and CAS publication, ensurePipelinePool, the autopipeliner-factory
hooks, the Options.init AutoPipelineOptions defaulting and its cluster
clientOptions mirror. pipelinePool is a plain field set before the
client is visible to any goroutine; WithTimeout clones share it. Net
diff of this commit is negative.

Tests: the creation-rule table becomes always-on plus opt-out; new e2e
coverage for the spill (a pipeline pool of one, held busy by a pipelined
BLPOP, must not queue a second pipeline behind PoolTimeout) and for the
tracking exclusion (CLIENT LIST shows the main-pool connection tracked
and the pipeline-pool connection untracked).

* test: keep HImport mock choreography on the main pool; doc the pool default

The HImport mock tests choreograph exact per-connection sequences — a
pool of one, booms armed to fire on the Nth replayed PREPARE, session
counts pinned per connection. The always-on pipeline pool adds a second
connection whose init replays fieldsets (correct production behavior:
HIMPORT pipelines work on pipeline-pool connections precisely because of
that replay), which fired the armed booms during the pipeline conn's
init instead of the reissue under test and shifted the session counts.
Opt every client in the file out with PipelinePoolSize: -1: the tests
pin HIMPORT reissue/session semantics, not pool routing.

Also states the PipelinePoolSize default plainly in the option docs:
DefaultPipelinePoolSize (10) connections (review feedback).

* test: skip conn-init handshakes in the cluster shard pipeline hooks

The shard-level pipeline hooks in the cluster hook-order specs asserted
Expect(cmds).To(HaveLen(...)) unconditionally. With the always-on
pipeline pool, a node's first pipeline dials a pipeline-pool connection
whose init runs its handshake pipeline through the node client's hook
chain, so the assertion fired on the handshake and the resulting Ginkgo
panic aborted the whole suite. Guard both hooks with the idiom
ring_test.go already uses for the same situation: defer GinkgoRecover()
and pass connection-initialization pipelines (hello/client) through.
The cluster-level hooks are untouched — node connection init never runs
through the cluster client's chain.

* test: adapt plain-pipeline tests to the always-on pipeline pool

TestNoPipelinePoolStats pinned the stats shape of a client without a
pipeline pool by creating a client without pipeline options — a premise
the always-on pool inverts. Having no pool now requires the explicit
opt-out, so the test sets PipelinePoolSize: -1, which is exactly the
configuration whose stats shape it verifies.

TestPipelineRetriesOnNetworkError is the plain-pipeline twin of the AP
dial-count test adapted earlier: pipelines now run on the dedicated
pipeline pool, so the first Exec dialed a fresh pipeline-pool connection
and the test counted it as a phantom retry redial. Warm the pipeline
connection with one Pipelined round trip before arming the write
failure, so the count again measures exactly the one redial the retry
performs.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* fix(autopipeline): full-duplex review fixes

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

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

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

* ci(govulncheck): use stable Go to pick up security patches

setup-go's "1.26.x" resolved to go1.26.5, which govulncheck flags for two
standard-library vulnerabilities fixed in go1.26.6: GO-2026-6090 (crypto/tls)
and GO-2026-5972 (encoding/asn1). Track the latest stable toolchain so future
security patches are picked up automatically instead of pinning a patch.

* fix(autopipeline): flush Close backlog between sessions; lease only with work

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

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

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

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

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

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

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

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

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

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

Adds TestFullDuplexBlockingFace.

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

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

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

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

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

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

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

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

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

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

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

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

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

* fix(autopipeline): address FD review findings

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

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

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

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

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

Adds TestFullDuplexHandoffRecyclesPromptly and a curConn test hook.

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

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

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

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

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

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

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

* Update options.go

* Update options_test.go

* Update options.go

* fix flaky test

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

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

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

* chore: unpin govulncheck version

* chore: require govulncheck to use latest go version

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* fix(options): latch MaxConcurrentDials explicitness across reuse

Round-10 review fix (codex r3812523456).

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

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

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

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

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

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

Two review fixes.

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

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

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

Round-14 review fix (codex r3817201953).

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

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

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

---------

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

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

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

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

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

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

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

* perf(autopipeline): pool blocking-face batch

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

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

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

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

* chore(autopipeline): silence unused lint on newFDInflight

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

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

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

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

* fix(autopipeline): guard advance on empty ring

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

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

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

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

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

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

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

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

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

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

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

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

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

Two request-path fixes from the #4002 review:

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

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

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

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

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

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

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

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

* docs(autopipeline): clarify FullDuplexFastSubmit tradeoffs

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Two Close-path findings.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Two autopipeline construction findings.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* docs(cluster): document MaxConcurrentDials normalization

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Four findings on the Close/shutdown and release paths.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* fix(internal): drain pooled semaphore timer unconditionally

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* docs(client): correct WithTimeout autopipeliner note

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

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

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

This reverts commit 45f0159217.

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

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

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

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

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

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

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

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

From the review of #4002.

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

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

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

From the review of #4002.

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

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

From the review of #4002.

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

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

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

From the review of #4002.

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

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

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

From the review of #4002.

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

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

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

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

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

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

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

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

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

From the review of #4002.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Refs #4002.

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

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

Refs #4002.

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

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

Refs #4002

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

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

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

Tests: TestFDSetErrSafeRecoversPanic, TestClusterFullDuplexChildCloseSelfHeals.

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

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

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

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

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

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

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

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

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

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

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

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

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

Four fixes from PR #4002 review comments:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: sadd amr3e <saddamr3e@gmail.com>
Co-authored-by: Albert Huynh <huynhalb@gmail.com>
Co-authored-by: Vladislav Kotsev <vladygk8@gmail.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-11 17:54:03 +03:00
f6ffe09a91 feat(csc): Refresh and miss coalescing (#3989)
* feat(csc): refresh-on-invalidate and reader-miss coalescing

Two additions over the shared-tracking client-side cache, both inert
unless CSC is enabled.

Refresh-on-invalidate re-fetches recently-read keys as soon as their
invalidation push arrives, instead of leaving them to be reloaded by the
next reader that misses. Invalidated cache keys that were valid and read
within the recency horizon are collected under the shard lock as they are
deleted, batched over a fixed window from the first collect, and flushed
when the window expires, when a size cap is hit, or on demand the moment a
reader misses a key still sitting in the window. Refetches reserve each
cache key before touching the network and release any reservation they do
not publish, so a reader never blocks on an orphaned in-progress entry.
Reads run on a background pipelined connection. Enabled via
Options.ClientSideCacheRefreshOnInvalidate; window and demand behaviour
tunable through GOREDIS_CSC_REFRESH_WINDOW_MS and GOREDIS_CSC_REFRESH_DEMAND.

Reader-miss coalescing pipelines concurrent cache-miss reads onto a single
CSC-tracked main-pool connection. Each miss is sent as the caller's own
per-key command (not rewritten to MGET, so it is cluster-safe); every reply
is applied to its caller's command and published to the cache under the
connection's captured tracking generation, so entries stay invalidatable.
initConn issues CLIENT TRACKING ON for every pooled connection while CSC is
active, so the main pool is tracked; using it explicitly (rather than the
pipeline pool) keeps the path correct even if a later change makes a
dedicated pipeline pool deliberately untracked, since publishing on an
untracked connection would serve stale until TTL. Enabled via
GOREDIS_CSC_COALESCE_MISSES; worker count via GOREDIS_CSC_COALESCE_WORKERS
(default 8).

Adds LocalCache.deleteByRedisKeyCollectingHot / LRUClock / InvalidationStats
and per-cache invalidation counters to support the above, with unit coverage
of the recency filter and an integration test asserting a refreshed entry is
itself invalidatable.

* feat(csc): full-duplex reader-miss coalescing + invalidation batching (#3965)

* docs: point security reports at Redis VDP (#3949)

* fix(pool): log buffered push data at debug level (#3948)

The buffered-push-data notice in isHealthyConn fired unconditionally.
  With client-side caching every tracked write parks an invalidate frame
  on idle pooled conns, so at default log level the line floods the log
  on nearly every pool Get under write-heavy workloads. It documents the
  normal healthy path, not a problem - gate it behind LogLevelDebug like
  the equivalent notices in maintnotifications.

* feat(csc): reader-miss coalescing modes (workers + full-duplex) and invalidation batching

Builds on the CSC refresh-on-invalidate + base miss-coalescing PR. Adds:

- Coalescing MODES for the reader-miss path, selected by config:
  * "workers" (default): a small pinned worker pool fetches reserved misses.
  * "fullduplex": one held connection with a writer + reader goroutine pair
    pipelines the reserved misses (concurrent, replies streamed back). Ordering
    is preserved: the miss-coalescer's reservation dedups concurrent misses of
    the same key to a single fetch, and each caller blocks on its own request's
    completion — the caching client is blocking per goroutine, so no goroutine
    ever sees its own reads reordered; full-duplex only overlaps independent
    goroutines' fetches on the wire.
- Invalidation batching: coalesce invalidation-driven cache deletes within a
  configurable window instead of one delete per push.

Config (no environment variables): new AutoPipeline-free Options fields —
ClientSideCacheCoalesceMisses (enable), ClientSideCacheCoalesceMode
("workers"/"fullduplex"), ClientSideCacheCoalesceWorkers, and
ClientSideCacheInvalidationBatchWindow. Prototype-only read-path telemetry
(READPATH_LOG + LocalCache stat counters) removed; no stats surface added.

Measured on a 50ms-RTT WAN proxy under invalidation churn, the coalescing fixes
turn published v9.22.0's miss-stampede collapse (p99 up to ~1.1s, throughput
floored) into ~5x throughput at ~1 RTT p99, matching rueidis; the full-duplex
mode gives the tighter mid-range tail at fewer connections. See
AP_CSC_TWOCLIENT_VS_RUEIDIS.md.

Depends on: feature/csc-refresh-and-miss-coalescing.

* fix(csc): address #3965 review and lint in coalescing modes

- lint (the only red CI check): drop the redundant `chan struct{}` type from the
  recycle declaration (ST1023) and //nolint:unused the deliberately-off, measured
  cscRefreshCooldown knob.
- Reject the "pinned" PROTOTYPE engine from the public ClientSideCacheCoalesceMode
  option: it holds a connection with no idle invalidation drain and can serve
  stale values (cursor HIGH). It now falls back to "workers" and is reachable
  only via an internal benchmark hook (cscForcePinned).
- Pass the new CSC miss-coalescing / invalidation-batching knobs through
  UniversalOptions.Simple() (ClientSideCacheRefreshOnInvalidate, CoalesceMisses,
  CoalesceMode, CoalesceWorkers, InvalidationBatchWindow) so UniversalClient
  users can enable them (codex P2).
- Give the invalidation batcher a stop path: it is stopped and cleared when the
  last user releases the binding (releaseLocked), and ensureBatcher refuses to
  start one for an already-released binding, so its goroutine no longer lives
  past the binding re-arming its timer forever; a later re-acquire starts fresh.
- Full-duplex session: acquire the pool connection only after the first miss
  arrives, and release it if CSC serving was disabled meanwhile, so an idle
  session no longer starves a small pool (PoolSize:1) until PoolTimeout.

Adds unit tests for the pinned-mode rejection and the UniversalOptions passthrough.

* fix(csc): FD apply-and-gate on id/gen mismatch; drain buffered pushes; doc

- Full-duplex readOne now applies the read reply and lets fulfillCached gate
  only the cache publish on the captured conn id/generation -- matching the
  workers/pinned engines -- instead of failing the caller with ErrClosed and
  losing a good reply on a mid-flight id/gen change.
- peekAndProcessPushNotifications also drains when the reader has buffered bytes
  (HasBufferedData), not only when the socket is readable (MaybeHasData), so a
  buffered invalidation is processed on the idle tick.
- Workers-mode doc corrected: a tracked connection is acquired/released per
  batch, not held across batches.

* 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(csc): retry uncached on mid-miss disable; guard cancelled fetches

- A coalesced miss re-runs uncached when CSC serving is disabled after the miss
  was reserved (RESP3 downgrade / CLIENT TRACKING loss during a conn re-init),
  via an internal retry-uncached sentinel that processCached catches, instead of
  surfacing a spurious pool.ErrClosed for a valid cacheable read.
- A caller whose context cancels mid-fetch no longer races the coalescer: a CAS
  interlock (claimAbandon/claimApply) hands the Cmder to exactly one of the caller
  or the applying worker, so the worker never writes a Cmder the caller has taken
  back. The reply is still classified and published to the shared cache, and a
  cancelled Get returns the context error deterministically (matching the
  non-coalesced path).
- A fetch after coalescer shutdown returns pool.ErrClosed instead of hanging on a
  post-drain enqueue race.
Adds TestClassifyCachedReply, TestCSCMissReqClaimInterlock,
TestCSCMissCoalesceAbandonedFetchNoRace, TestFullDuplexDisabledMidMissRetriesUncached.

* fix(csc): drop batched deletes on flush; rebuild batcher on window change

- A full cache flush (FLUSHDB/FLUSHALL) cleared the cache but left the
  invalidation batcher's queued per-key deletes, which then fired and evicted
  entries repopulated after the flush (an extra miss within the window). Drop the
  batcher's pending queue on flush.
- A running batcher's window is fixed at creation, so a second client binding to
  the same shared handler with a stricter window kept the old cadence and its
  staleness bound did not hold. setInvalBatchWindow now drops the running batcher
  on a window change so the next invalidation starts a fresh one with the new
  window.

* 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(csc): preserve queued deletes on rebuild; honor configured timeouts

- The invalidation batcher's stop path drains keys still buffered in its channel
  into the final flush, so a window-change rebuild loses no queued deletes
  (they would otherwise serve pre-invalidation values until TTL/MaxStaleness).
  Adds TestInvalBatchStopAppliesQueuedDeletes.
- ensureBatcher reads the window under the handler lock (not the caller's
  pre-lock snapshot) and returns nil at window 0, so a concurrent tighten or
  disable cannot be undone by a batcher rebuilt with the stale cadence.
- setInvalBatchWindow folds windows strictest-wins across attached clients
  (explicit 0/inline strictest, then smaller nonzero): a later, looser attach
  can no longer lengthen batching past an earlier client's staleness bound.
  The effective window resets when the last user releases.
- The miss-coalescer flush budget derives from the configured WriteTimeout +
  ReadTimeout (plus a 5s pool-Get floor) instead of a fixed 5s, so clients with
  deliberately long timeouts do not see only coalesced misses clipped early.

* fix(csc): batcher stop/release safety; FD idle release; flush budget

- enqueue/stop interlock: enqueue sends under stopMu.RLock and stop flips a
  stopped flag under stopMu.Lock before closing stopCh, so a key sent by a
  handler holding a stale batcher pointer lands before the close (applied by the
  stop-drain) and a post-stop enqueue applies inline — no delete is parked in a
  channel nothing drains. Adds TestInvalBatchEnqueueAfterStopAppliesInline.
- The batcher snapshots cache/refresh at creation and apply uses the snapshot,
  so the release-time stop-drain still lands queued deletes on a shared cache
  after releaseLocked has cleared the binding (a successor reusing that cache
  would otherwise serve pre-invalidation values until TTL/MaxStaleness).
- Full-duplex coalescer sessions release their held connection after an idle
  grace (cscFullDuplexSessionIdle, 1s) instead of holding it toward the 30s
  recycle age: at PoolSize:1 a non-cacheable command otherwise blocks until
  PoolTimeout. The next miss re-acquires; an idle coalescer holds zero
  connections. Adds TestFullDuplexSessionReleasesIdleConn.
- batchBudget includes the configured PoolTimeout alongside Write/ReadTimeout
  (5s floor for scheduling only), so a coalesced flush's pool Get waits the same
  saturation budget as the normal path, including ReadTimeout: -1 setups.

* fix(csc): close coalescer lifecycle races; honor pool budget on acquire

- cscMissCoalescer is an atomic.Pointer: processCached loads it once (a cache
  miss racing Client.Close could observe non-nil then call fetch on nil — a
  panic and a data race); stop Swaps to nil so exactly one closer wins.
- Connection acquisition in all three engines goes through acquireCtx:
  max(5s, PoolTimeout) so a configured pool budget above 5s is honored under
  temporary saturation (matching the normal command path), and cancelled when
  the coalescer stops so Close is not stalled behind a blocked pool Get. The
  workers engine's per-batch Get is split out of the batch write/read budget
  (batchBudget covers write+read only again).
- The runtime cleanup for a client dropped without Close also stops the
  coalescer workers (idempotent stopWorkers), so a forgotten client no longer
  leaks the worker goroutines and everything they retain (cache, pools).
- Document that ClientSideCacheCoalesceMisses requires the built-in LocalCache;
  with a custom Cache implementation the option is ignored.

* fix(csc): coalescer latency, shutdown and clone fixes

- fullDuplexLoop backs off only after an error end; a clean idle/recycle end
  starts the next session immediately instead of adding a flat 5ms to a miss
  already queued.
- flushBatch no longer routes the engine's bounded batch ctx through
  c.context(): with the default ContextTimeoutEnabled=false that stripped the
  deadline, and with ReadTimeout disabled a non-replying server wedged the
  worker in ReadRawReply and Close in wg.Wait.
- baseClient.clone carries the miss coalescer (atomic.Pointer Store of the
  owner's Load), so WithTimeout clones keep coalescing instead of silently
  falling back to per-caller fetches; lifecycle stays owner-only.
- Document that ClientSideCacheInvalidationBatchWindow requires the built-in
  LocalCache (matching the miss-coalescing option).

* fix(csc): make FD session I/O interruptible; refresh rebuild; stat CAS

- The full-duplex session writer/reader pass the engine's cancellable session
  ctx directly instead of through c.context(), which stripped it under the
  default ContextTimeoutEnabled=false; and since no context can interrupt a
  deadline-less socket read, the supervisor's stop path now bounds the graceful
  drain — after mc.stop it waits up to the batch budget and then closes the held
  connection. Close always unblocks: promptly on a healthy drain, within the
  budget against a server that never replies.
- setRefreshQueue drops a running batcher when the refresh binding changes
  (like setInvalBatchWindow), so a client attaching refresh-on-invalidate later
  does not leave batched deletes permanently feeding a nil refresher.
- countBatch's max-batch statistic uses a CAS loop, so concurrent workers cannot
  commit a smaller maximum over a larger one.

* fix(csc): join FD supervisor before release; drain queue in GC cleanup

- The FD session joins its supervisor goroutine (supDead) after closing
  superDone and BEFORE releasing the connection: the deferred scancel makes
  sctx.Done() ready at return, and a still-parked supervisor could pick it over
  the equally-ready superDone and close a healthy connection already returned to
  the pool — possibly under an unrelated command.
- The GC cleanup for a client dropped without Close drain-cancels the miss queue
  after stopping the workers (non-blocking), so a ctx-cancelled caller's queued
  request cannot strand an IN_PROGRESS reservation that would block another
  client sharing the injected cache until StaleTimeout.

* fix(csc): clear the push-probe deadline for negative ReadTimeout

A negative ReadTimeout means every WithReader skips SetReadDeadline, so the 1ms
deadline peekAndProcessPushNotifications installs for its probe stayed on the
socket and poisoned the next reply read (a spurious i/o timeout on a held
full-duplex connection, or on a pooled conn reused right after a drain). Clear
the deadline after the probe for negative-ReadTimeout clients; timeout>=0
callers re-arm their own on the next read. Fixed at the source, covering the
FD coalescer tick, the autopipeline paths and the CSC hit-drain.

* refactor(csc): full-duplex is the only miss-coalescer engine

Misses are caller-blocking — a real request waits on every fetch — so the
latency-first engine is the right one, always: a lone miss is written
immediately, batching stays opportunistic (packs only what is already queued),
and new misses stream out while earlier replies are in flight (~1 RTT per
miss, no batch phase-lock, no pool Get on the hot path).

Remove the alternatives and their configuration surface:
- the "workers" engine (N pooled conns, half-duplex batches): its per-batch
  round trip added a full RTT of tail latency to misses arriving mid-flight,
  and its batching advantage is preserved by the FD writer's opportunistic
  packing;
- the "pinned" benchmark prototype (no idle invalidation drain; could serve
  stale after an invalidation while idle; never publicly selectable);
- Options.ClientSideCacheCoalesceMode and ClientSideCacheCoalesceWorkers (both
  unreleased experimental fields) and their UniversalOptions mirrors:
  ClientSideCacheCoalesceMisses now selects the one engine, no tuning needed.

CSCMissCoalesceStats.Mode becomes Active. Background invalidation traffic is
unaffected: it keeps the batching-first windowed batcher and refresh queue.

* fix(csc): held-conn probe safety and lifetime bounds

- checkForData no longer resets the connection deadline: the probe is a raw
  MSG_PEEK|MSG_DONTWAIT syscall that never touches net.Conn deadlines, and on a
  held full-duplex connection the reader's idle tick ran it CONCURRENTLY with
  the writer inside WithWriter — stripping the armed write deadline and letting
  a blocked write hang past its timeout. connCheck's own reset stays (it runs
  on idle pool-owned conns).
- peekAndProcessPushNotifications falls back to a throttled timed drain
  (TakeCscPeriodicReadPending, the CSC drainer's opaque-transport discipline)
  when socket readiness cannot be inspected (Windows; dialer wrappers exposing
  neither syscall.Conn nor NetConn), so a push addressed to the held connection
  is consumed within the fallback interval instead of sitting until the next
  reply.
- The full-duplex session's hold is bounded by the connection's REMAINING
  absolute lifetime via a new pool.Conn.ExpiresAt() accessor (jitter included),
  floored at 10ms — a near-expiry pooled connection is no longer held for a
  fresh full recycle age past the expiry the pool's reaper enforces.

* fix(csc): scope idle-drain fallback to held conns; drainer deadline hygiene

- The opaque-transport timed-drain fallback moves out of the shared
  peekAndProcessPushNotifications and into the full-duplex session tick, the
  one place a connection has no alternative drain coverage. The blanket
  fallback made every push path run throttled timed drains on uninspectable
  transports (including mock connections), perturbing sequenced push handling
  — the maintnotifications downgrade test caught it in CI. Pooled push paths
  keep their drainer/health-check coverage. The timed drain itself is factored
  into timedPushDrain, shared by both callers.
- drainPushNotifications explicitly clears the socket deadline after its
  hard-deadline probe/drain for clients with a negative ReadTimeout (which
  never re-arm): the SetDeadline reset removed from checkForData — because it
  clobbered a concurrent writer's deadline on held full-duplex connections —
  had been clearing this residue incidentally on the next drainer tick. This
  is the explicit, correctly-scoped replacement.

* refactor(csc): drop the public coalescer stats API

Observability rides the client's normal telemetry — coalesced misses fire the
otel operation-duration and error callbacks like any other command path — so
CSCMissCoalesceStats (an unreleased experimental type) duplicated that surface
for no consumer. The engine's internal counters stay for the in-package tests,
which read them directly off the coalescer.

* refactor(csc): drop the public coalescer stats API

Observability rides the client's normal telemetry — coalesced misses fire the
otel operation-duration and error callbacks like any other command path — so
CSCMissCoalesceStats (an unreleased experimental type) duplicated that surface
for no consumer. The engine's internal counters stay for the in-package tests,
which read them directly off the coalescer.

Also: acquireCtx folds in the pool's full dial budget (DialerRetries x
(DialTimeout + DialerRetryTimeout) + DialTimeout) so a slow endpoint gets its
configured dial-retry sequence instead of being cancelled mid-sequence; and the
ClientSideCacheCoalesceMisses GoDoc documents pool sizing for the held
connection (PoolSize 1 + sustained misses starves non-cacheable commands).

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

* fix(csc): bound the recycle-path drain like the stop path

Reaching the recycle age signaled the recycle and exited the supervisor while a
reader could stay blocked in a deadline-less read on a non-replying server —
with the supervisor gone nothing would ever close the conn, and a later Close
hung in wg.Wait. The recycle path now waits up to the batch budget for the
session to drain and then closes the connection, mirroring the stop path. Also
documents that a custom DialerRetryBackoff exceeding DialerRetryTimeout should
raise PoolTimeout (an opaque callback's total delay is not observable from
options).

* fix(csc): progress-based drain backstop; departial test flake

- The stop/recycle drain backstop force-closed the conn after one fixed
  batch budget, which could cut a HEALTHY drain of a deep in-flight
  pipeline (up to cscFullDuplexDepth replies, ~in-flight x RTT,
  possibly under maintenance-relaxed timeouts). The supervisor now
  samples the in-flight queue per budget interval: any consumed reply
  extends the wait; only a zero-progress interval closes the conn.
- TestReleaseConnRemovesConnectionAfterPartialPushRead skips the benign
  case where the drain probe times out before consuming any byte: the
  frame is intact and re-pooling is safe; only a Put after bytes moved
  into the reader is the desync bug the test pins.

* fix(pool): clear residual read deadline in checkForData

Removing the SetDeadline reset left a prior WithReader's read deadline
armed; once expired, rawConn.Read fails fast with i/o timeout before
the non-blocking peek runs, the CSC drainer treats the error as fatal
and removes an idle-but-healthy conn, evicting its cache coverage. The
full-duplex coalescer concentrates coverage on one held conn, so one
spurious removal wiped the whole cache (WAN: hit rate 25%, throughput
cut to a third). Clear only the READ deadline: the write deadline stays
untouched because checkForData runs concurrently with command writes on
a held full-duplex connection - which is what the old full SetDeadline
reset clobbered. Regression test pins the expired-deadline case.

* fix(csc): claim FD writes; progress-aware drain backstop

- The abandon interlock gated only the reply side, so the session
  writer could serialize an abandoned caller's cmd args - a
  use-after-return on mutable args (e.g. a []byte key), with the reply
  publishable under the original cache key. The writer now claims each
  request (PENDING->WRITING) around arg serialization, releases before
  the flush, and drops abandoned requests with their reservation
  cancelled; abandonOrWait yields through a WRITING claim, which spans
  one in-memory batch encode.
- The drain backstop measures progress as completed reads (readsDone)
  instead of len(inflight), which was blind to the reader's active
  read, and its interval honors the connection's effective
  (maintenance-relaxed) read timeout via pool.Conn.EffectiveReadTimeout
  so a legitimately deep read is not cut short.
- The partial-push-read test completes the frame and requires a whole-
  frame parse before skipping: an empty reader buffer alone does not
  prove the probe consumed nothing.

* fix(csc): snapshot miss wire form at enqueue

Replace the WRITING ownership claim with a wire snapshot taken in
fetch, while the caller still owns cmd: the session writer writes only
engine-owned bytes and never reads cmd, so no ownership window spans
arg serialization (which could implicitly flush and block on the
socket), abandoning callers return immediately, and a post-abandon arg
mutation can neither reach the wire nor publish under the original
cache key. Abandoned fetches complete again, so their reservations
settle instead of stranding IN_PROGRESS behind the miss backlog.

Also: fetch's Close-race branch drains the queue after cancelling (a
request landing after the shutdown drain is not retained via a live
WithTimeout clone), and the recycle backstop honors explicitly
disabled read deadlines - it waits for the session instead of force-
closing on a zero-progress interval, escalating only once stop fires
so Close still terminates.

* fix(csc): harden FD session recycle and probe paths

- timedPushDrain reads under a hard deadline: WithReader let an active
  maintenance-relaxed timeout replace the 1ms probe cap, parking the FD
  session reader for the relaxed duration on a no-data probe while it
  held the connection. WithReaderHardDeadline bypasses relaxation and
  clears the deadline on exit (the negative-ReadTimeout cleanup block
  is now redundant and removed).
- The supervisor also watches the recycle channel, so a
  reader-triggered recycle (handoff/close-on-put) gets the same bounded
  progress-based drain backstop as the age-triggered path - a stalled
  in-flight reply can no longer postpone a requested handoff until
  Close.
- The deadline-less recycle carve-out tests rt <= 0: Options.init
  normalizes ReadTimeout -1 (indefinite) to 0, so the indefinite mode
  was still force-closed after one budget interval.

* fix(csc): epoch inval drop; refresh ownership; close order

- Queued invalidations carry the batcher epoch they were enqueued
  under; drop() bumps the epoch instead of draining, and apply skips
  stale-epoch items (queue and in-progress batch alike, so the dropCh
  signal is gone). The flush handler bumps before cache.Flush(), so a
  per-key invalidation racing in from another tracked connection after
  the flush keeps its delete - the drain-based drop could discard it
  and leave a repopulated entry stale. Dedup is per key+epoch so a
  post-flush re-arrival is not swallowed by the duplicate check.
- stopCSCRefresher clears the shared handler's refresh binding only
  while it still points at the closing client's own queue: a sibling
  client sharing the cache/processor keeps its refresher fed.
- Teardown deactivates cscActive before stopping the coalescer, and
  fetch's stop paths settle with errCSCRetryUncached so a clone's miss
  racing the teardown window re-runs uncached instead of failing with
  ErrClosed on an open pool; the mid-apply branch returns the settle
  result itself rather than discarding a successfully applied reply.

* fix(csc): serialize inval apply with drop; FD handler adapter

- apply/drop are serialized by a batcher mutex: apply snapshotting the
  epoch once let a concurrent FLUSH land mid-batch and stale-epoch
  deletes still run post-flush, evicting repopulations - the case the
  epoch exists to prevent. drop() holding the mutex means an in-flight
  batch finishes before the flush (harmless: the flush wipes it) and
  every later apply sees the new epoch.
- Both FD-session push paths (the reader's per-reply drain and
  timedPushDrain) hand handlers the nonblocking cscHandlerClient
  adapter like the background drainer does: a custom push handler
  calling Close() signaled-and-deferred instead of deadlocking on
  mc.wg.Wait while the session reader is parked inside that handler.

* fix(csc): retry-uncached drains; refresh stack; weak close

- The shutdown drain and the GC cleanup settle queued misses with
  errCSCRetryUncached, matching fetch's stop paths: a caller woken
  during the teardown window re-runs its read on the still-open pool
  instead of surfacing ErrClosed.
- The shared handler tracks refresh bindings in a stack: clearing the
  ACTIVE binding restores the next-newest live sibling instead of
  nil, so closing the newest owner no longer severs an older client's
  still-running refresher (the identity check alone only protected the
  reverse order).
- The push-handler adapter closes through the canonical *Client via a
  weak back-pointer: Client.Close also stops the cached autopipeliners,
  which baseClient.Close bypassed, leaving flush goroutines running
  against closed pools. Weak so the wrapper stays collectible and the
  drop-without-Close cleanup still fires.

* fix(csc): write-side backstop; probe gate; clone bypass

- The drain backstop honors the write side too: the interval covers the
  connection's effective write timeout (pool.Conn.EffectiveWriteTimeout)
  and the deadline-less carve-out fires when either read or write
  deadlines are disabled - a deadline-free write blocked flushing a
  large request shows no read progress by construction and must not be
  cut by an age/handoff recycle.
- The opaque-transport speculative probe runs only with the built-in
  push processor: a custom processor may surface the empty-probe
  timeout per its contract, which would remove and redial a healthy
  session on every idle probe.
- withTimeout clones whose timeouts diverge from the owner's bypass
  miss coalescing: the shared engine reads the owner's options, so a
  coalesced miss would honor the owner's deadlines, defeating
  WithTimeout. Hits and uncached fetch caching are unchanged.
- releaseLocked clears refresh/refreshStack with the binding: a client
  dropped without Close must not leave a dead queue for a successor to
  inherit or restore.
- The drop-without-Close cleanup drain settles with errCSCRetryUncached
  like every other stop path (the round-22 hunk for this block was a
  silent patch no-op; applied for real).

* fix(csc): honor lifetime jitter in the FD recycle age

Capping the recycle age at the raw ConnMaxLifetime before consulting
ExpiresAt collapsed every positive-jitter connection back to the
unjittered lifetime, re-synchronizing session recycles across clients
started together - the herd ConnMaxLifetimeJitter exists to prevent.
Bound the age by the connection's actual absolute expiry only (jitter
included), subject to the independent 30s session cap.

* fix(csc): fail the FD session on push-processor errors

Log-and-continue past a surfaced push-processor error let the reader
proceed into ReadRawReply on a possibly desynchronized stream: a
processor that consumed part of a push before failing leaves the next
bytes mid-frame, and a fragment could be applied as a caller's reply
and published to the cache. Treat the error as session-fatal (close and
remove the connection), matching drainPushNotifications - the built-in
processor surfaces only mid-frame failures, and a custom processor's
contract cannot prove no bytes were consumed.

* feat(csc): native OTel attribution for coalesced misses

A coalesced miss returned from processCached without touching
processState, so the native recorder saw zero attempts and a nil
connection (empty server.address), and engine-failed requests bypassed
the error callback that processWithRetry emits. fetch now returns the
serving session connection (recorded by the reader before the done
settle, which is the happens-before edge) and processCached stamps it
into processState as one attempt; applyAndSettle and settleErr emit the
native error callback with parity to processWithRetry and the FD
autopipeline engine's failReqs - the retry-uncached sentinel is
excluded, since its command re-runs on the fully instrumented path.

Refs the deferred-observability thread on #3965.

* fix(csc): FD miss-coalescer review fixes

- Attribute write-side failures to the held session connection: the miss
  coalescer sets req.servedBy for the whole batch before the write, so a
  WithWriter error (or ctx cancel during the inflight handoff) settles with the
  connection attribution the reply path records, not a nil conn / zero attempts.

- Tolerate fragmented push frames on held sessions: once readiness is
  established, the drain uses the longer cscDrainHardReadCap budget (as the
  background drainer does) instead of the 1ms probe cap, which could time out
  mid-frame over TLS and fatally close a healthy session. The 1ms cap is
  reserved for speculative no-data probes (opaque transports).

- Route a WithTimeout clone's CSC push-handler Close through the owner wrapper:
  the clone's cscClientWeak now points at the canonical owner, so closeCanonical
  calls owner.Close() (full CSC + autopipeliner teardown) instead of
  baseClient.Close closing shared pools while the owner's drainer runs.

* fix(csc): harden refresh-on-invalidate push drain

Two fixes to the refresh-on-invalidate reply reader, matching the
miss-coalescer / background-drainer policy:

- Propagate push-processor errors instead of logging and continuing. A
  surfaced error means bytes may have been consumed mid-frame; reading the next
  reply on a desynchronized stream could publish a push fragment under the
  wrong cache key. Returning the error aborts the batch so withConn retires the
  connection.

- Use the nonblocking cscHandlerClient adapter for the drain. This reader is
  part of the refresher's waitgroup, so a custom push handler calling Close()
  on the raw client would deadlock shutdown (Close waits on the refresher
  goroutine parked in the handler).

* 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(csc): record cancel metric on coalesced miss

A caller that cancels its context while waiting on a coalesced miss returned
early without recording an error metric: the serving session still completes
the background fetch (applyAndSettle success or settleErr), so neither records
THIS caller's cancellation, undercounting the cancellation rate whenever miss
coalescing is enabled. Report it here, attributed to the caller's ctx, matching
processWithRetry.

The conn is passed as nil deliberately: req.servedBy is written by the session
and only safe to read after the req.done receive (the happens-before edge),
which has not happened on the cancellation branch, and is nil anyway when the
cancel beats the serve. The metric recorder treats a nil conn as "no peer
attributes".

* fix(csc): retry-uncached on coalescer stop-acquire

When getConn fails because acquireCtx was cancelled by mc.stop during coalescer
teardown, the session settled the pulled miss and drained the queue with the raw
context.Canceled. processCached only retries uncached on the errCSCRetryUncached
sentinel, so in-flight coalesced reads surfaced a spurious cancellation even
though the caller's context was still valid, instead of re-running on the
still-open pool like the other stop paths.

acquireCtx derives from context.Background() and is cancelled only by mc.stop
(its sole non-deadline cancel source), so a context.Canceled here uniquely means
teardown: settle first and the queue drain with errCSCRetryUncached and report
stopped so the loop exits without backing off. Genuine acquire failures (dial
error, deadline) keep the raw error and the backoff path.

* fix(csc): detect coalescer stop by signal, not error

The full-duplex miss coalescer mapped a getConn cancellation to
errCSCRetryUncached by testing errors.Is(err, context.Canceled). But getConn runs
the dialer, credentials callbacks, and init hooks, any of which can return
context.Canceled from their OWN context while the coalescer is not stopping.
Treating the error value as proof that mc.stop fired permanently exited the sole
full-duplex worker; the current miss retried uncached, but later misses still
enqueued through the still-live coalescer with no worker to settle them, hanging
deadline-free callers and leaking IN_PROGRESS reservations.

Detect teardown by checking mc.stop directly instead. mc.stop is closed only by
stopWorkers, and close(mc.stop) is ordered before acquireCtx cancels its context
(before getConn returns), so a real teardown is always visible. On stop, settle
retry-uncached and exit; on any other acquire failure — including an unrelated
context.Canceled — fail the batch and back off so the worker stays alive. This
supersedes the errors.Is check added in the previous round.

* docs(csc): note per-session Limiter for miss coalescing

ClientSideCacheCoalesceMisses holds one connection per coalescer session and
serves many misses on it, so Options.Limiter is admitted (Allow/ReportResult)
once per session — per held connection — not per coalesced miss, unlike the
plain per-command path. This is inherent to the held-connection model; a
per-miss Allow would defeat the coalescing and re-admit a connection already
held. Document it on the option so callers that need strict per-command
admission or circuit-breaking know not to enable miss coalescing.

* 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(csc): propagate drain errors on fragmented attributes

The held-session push drain (pushDrainWithin) dispatched the built-in processor
through ProcessPendingNotifications, which swallows an error that occurs AFTER
partially consuming a frame — e.g. a RESP3 attribute whose remainder arrives
slower than the hard read cap. A swallowed mid-frame error left the connection
desynchronized, so later fragments could be misread as command replies and cached
under the wrong key, or fail unrelated misses.

Use ProcessPendingNotificationsBuffered for the built-in processor, exactly as
drainPushNotifications already does, so a post-consumption error is propagated and
the caller retires the desynced session. Custom processors keep the unbuffered
call (their contract is "invoked only when notifications are known present").

* fix(csc): limit bare-socket push drain to built-in processor

Round-8 review fix (codex r3809020181).

peekAndProcessPushNotifications fired on MaybeHasData (raw socket
readiness). Over TLS, maybeHasData unwraps the transport. It then
returns true for a post-handshake control record that has zero RESP
bytes. (conn_check.go's connCheck refuses to unwrap TLS for this reason.
maybeHasData does unwrap it.) A custom NotificationProcessor then ran
ProcessPendingNotifications under the 50ms hard cap. The empty read
timed out. The FD session reader treats that timeout as fatal. It closed
a healthy connection, and its in-flight misses failed. The built-in
buffered processor accepts the empty read.

Now the bare-socket-readiness path runs only for the built-in processor.
A custom processor drains only when the reader already holds decoded
bytes (HasBufferedData), which means any TLS is already unwrapped.
Tradeoff: a push notification that arrives as bare socket readiness on a
custom and idle session waits for the next reply read, the session
recycle, or the MaxStaleness backstop. The connection stays open. This
mirrors the built-in-only guard on the opaque-transport probe in the FD
session tick.

* fix(csc): peek frame type before a custom push drain

Round-9 review fix (cursor r3811858390).

The round-8 D fix let a custom push processor drain on HasBufferedData. But
the buffered bytes can be a coalesced reply, not a push. A custom processor
invoked then could consume the reply, cache it under the wrong key, and
desync the stream. The built-in processor peeks the frame type and consumes
only push frames, but the NotificationProcessor interface does not promise
that. pushDrainWithin now peeks the frame type for a custom processor and
returns without a read when the frame is not a push. The peek is bounded by
the closure's hard read deadline.

* fix(csc): propagate custom push-drain peek errors

Round-10 review fix (codex r3812520847).

The round-9 fix peeked the frame type before it handed a buffered frame to a
custom push processor, but it returned nil on a peek error. PeekReplyType can
partially consume a fragmented RESP3 attribute (DiscardNext) before it errors,
which desyncs the stream. Swallowing the error kept the desynced connection,
so later fragments could be read as a command reply and cached under the wrong
key. The peek error now propagates, so the session fails and drops the
connection, as the built-in buffered path does. A cleanly-peeked non-push
frame still returns nil (a real reply left for the caller's read).

* fix(csc): drain miss-reader pushes through the shared safe helper

Round-11 review fix (cursor r3813795126).

The full-duplex miss reader (readOne) drained pushes with the unbuffered
ProcessPendingNotifications for both processor kinds -- a third drain site the
earlier desync fix did not reach. The built-in processor there swallowed a
mid-frame DiscardNext error on a fragmented attribute, and a custom processor
got no frame-type peek, so either could leave the stream misaligned; readOne
then read the shifted bytes as the reply and cached them under the wrong key.
Both drain paths now share drainPushFrames: the built-in uses the Buffered
variant (mid-frame errors propagate) and a custom processor is handed only a
confirmed push frame (peek first, propagate the peek error). The helper sets no
read deadline, so readOne's following ReadRawReply is unaffected.

* fix(csc): retire conn on pre-command drain desync; refresh drain helper

Two review fixes.

Pre-command push-drain desync (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. A fragmented RESP3 frame straddling
the drain's short hard cap left the reader mid-frame; the error was logged and
ignored, so the reply read consumed the residue and could cache it under the
wrong key. _process now closes the conn and retries on a fresh one instead of
ignoring the error (peekAndProcessPushNotifications already propagated
mid-frame errors via drainPushFrames).

Refresh reader drain (cursor r3813795126 / r3815669446): the refresh reply
reader still drained pushes with the unbuffered ProcessPendingNotifications,
a fourth drain site that could swallow a mid-frame desync before ReadRawReply
and publish a push fragment under the wrong cache key. It now routes through
the shared drainPushFrames helper, like the idle-tick and miss-reader paths.

* fix(csc): stop the refresh worker on GC cleanup

Round-14 review fix (codex r3817196217).

When refresh-on-invalidate is enabled and the caller drops the client without
Close, the runtime cleanup stopped the miss coalescer and the invalidation
drainer but never signaled the refresh worker. runCSCRefresher then stayed
parked on its ticker/queue, holding the baseClient, cache, and pools -- a
goroutine and memory leak that defeated the drop-without-Close safety net.
cscRegisterCleanups now captures the refresh handle and signals it, idempotent
and non-blocking (a new cscRevalidateHandle.signalStop guarded by sync.Once, so
Close and the cleanup cannot double-close). stopCSCRefresher uses the same
signal.

* feat(csc): add refresh-failure metric

Adds RefreshFailed to CSCRefreshStats -- the refresh-failure counter the HLD
lists (go-redis section 4.1). A refresh round trip that errors increments it;
those keys stay evicted and a later read repopulates them, so a rising count is
the signal that refresh-on-invalidate is degrading to plain eviction. Counted
per errored batch, not per key.

* fix(csc): drain pending pushes before reading the reply

The CSC miss and refresh readers drained push notifications with the
Buffered variant, which stops the instant the reader buffer empties. If a
second invalidation was still on the socket ahead of the command reply,
ReadRawReply read that push as the reply and cached it under the wrong
key -- a one-frame shift that then cascaded to later replies. The readers
now drain in BLOCKING mode: they block on the socket and skip push frames
until a non-push frame (the reply) is next, the same non-buffered
discipline the full-duplex reader already uses. PeekReplyType is
attribute-aware, so a fragmented RESP3 attribute needs no separate
buffered scan. A swallowed boundary-peek TIMEOUT is caught by the reader's
shared read deadline (ReadRawReply hits the same expired deadline and
fails the session); a swallowed non-timeout peek error would need a
malformed attribute mid-push (a server protocol bug), so this path is no
weaker than the full-duplex reader. Probe and idle paths keep the Buffered
variant so they never block. A socket-pair regression test pins the frame
order.

Also in this change:

- GC cleanup unbinds the dropped client's refresh queue from a shared
  invalidate handler (clearRefreshQueue), so a surviving sibling's
  refresh-on-invalidate keeps working instead of feeding a stopped queue.
  A refresh queue is only created inside attachSharedTrackingCSC, which
  also builds the drain handle, so the handle is always present when the
  queue is; a Conn() clone bails before the refresher starts.

- Record usedAt on deadline-free (negative-timeout) reads and writes, so
  a long full-duplex session under ReadTimeout/WriteTimeout=-2 is not
  misjudged as idle-expired by the pool and needlessly reconnected.

* fix(csc): harden miss-coalescing per review

- spill invalidation overflow off the full-duplex reply reader; the worker
  drains it through the same seen/pending dedup, so a burst collapses to one
  delete per key and no cache work runs on the reader
- drop the drain deadline cleanup that re-armed a relaxed timeout
  (WithReaderHardDeadline already restores the deadline)
- cap a coalesced write batch by bytes, not just count
- warn when ClientSideCacheInvalidationBatchWindow exceeds MaxStaleness

Refs #3965

---------

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>

* fix(csc): address review findings on refresh, batcher, and spill

Refresh-on-invalidate:
- pass the refresher's internal 5s ctx directly to WithReader/WithWriter instead
  of c.context(ctx); c.context strips it to Background when ContextTimeoutEnabled
  is false, so with read/write timeouts disabled a stalled Redis hung the
  refresher and wedged Close (cursor/codex).
- recover() the flush batch and defer its cancel(), so a panic can't kill the
  refresher (silent permanent degradation) or leak the 5s timer.
- skip Reserve's (0,true) 'fetch uncached' targets: they wasted an MGET slot and
  registered server-side tracking for a never-cached key.

Invalidation batcher:
- hold the handler lock across drop()+Flush() and read the batcher/cache fresh
  under it, so a concurrent batcher rebuild can't leave stale-epoch deletes
  applying after the flush and evicting post-flush repopulations (cursor).
- hard-cap the overflow spill and schedule one full Flush past the cap: the
  invalidatable keyset is NOT bounded by cache size (the server tracks keys past
  local LRU eviction), so an unbounded spill could OOM (codex). Adds a test.

Other:
- FTHybridCmd: populate rawVal on the RESP2 path for RawResult parity (Copilot).
- docs: mark ClientSideCacheRefreshOnInvalidate experimental + LocalCache-only
  (Copilot); correct the HasBufferedData 'complete frame' comment; note the
  Effective*Timeout post-expiry side effect; reconcile the refresh header's
  stale-read claim; drop a stale symbol reference.

* fix(csc): follow-up review fixes (spill race, stats, refresh, acquire)

- inval batcher: set flushReq BEFORE releasing stopMu at the spill cap, so a
  concurrent stop() cannot close stopCh and let the worker exit without seeing
  the request (lost flush, stale entries).
- inval batcher: count a dedup-dropped duplicate invalidation as invalidation +
  noop, so InvalidationStats reflects incoming pushes (the duplicate ratio) with
  the window batcher on, matching the inline path.
- csc integration: unbind the refresh queue in the drainer's exit defer, so a
  SELF-DISABLE exit (custom-processor damping / RESP3 downgrade) restores a
  sibling's binding instead of silently breaking the survivor's refresh.
- miss coalescer: derive the connection-acquire budget from a custom
  DialerRetryBackoff (sum the per-attempt delays), so a backoff longer than
  DialerRetryTimeout no longer cancels Get mid-retry-sequence.

Addresses codex re-review findings on #3989.

* fix(csc): address codex re-review (relaxed-timeout race, acquire, spill stats)

- pool relaxed timeout: the counter is a relax/unrelax NOTIFICATION balance, not a
  per-I/O count. getEffective{Read,Write}Timeout no longer decrements it per call;
  a passed deadline triggers a single CAS-coordinated reset (expireRelaxedTimeout),
  so a full-duplex connection's concurrent reader/writer/drain-backstop can't drive
  it negative and wedge later relaxations, and a lost unrelax notification is
  recovered at the deadline. ClearRelaxedTimeout is clamped at zero. Adds a
  concurrent-expiry regression test.
- miss coalescer acquire budget: revert the DialerRetryBackoff summing — calling a
  randomized/stateful backoff out-of-band consumes different jitter samples than
  the pool's actual retries, so the estimate can still be wrong. Use the
  deterministic flat estimate; mc.stop + PoolTimeout bound it for custom backoffs.
- inval batcher: count invalidations superseded by the spill-cap full-Flush
  (enqueue discard + worker pending reset) so CSCRefreshStats doesn't undercount an
  invalidation flood.

Addresses the codex re-review on #3989.

* fix(pool): retire one relaxed holder on expiry

The relaxed-timeout deadline safety net full-cleared the whole window on
expiry. On a full-duplex conn the reader, writer, and drain backstop all
observe the expired deadline concurrently; a CAS on the shared deadline
already gates that to one action, but a full clear also wiped a DIFFERENT
relaxation sharing the conn -- a notification SetRelaxedTimeout outstanding
alongside an expired handoff deadline lost its window (the :566 race).

Retire exactly one holder instead: expireRelaxedTimeout decrements the
counter by one (clamped, clears the timeout values only when the last
holder leaves). To keep overlapping handoffs from leaking a permanently
relaxed conn, SetRelaxedTimeoutWithDeadline now takes a holder slot only
for the FIRST deadline-scoped window and re-arms by replacing the deadline
in place (Swap), so the eventual expiry retires the single holder and the
window clears.

Add deterministic regression tests: a notification window survives an
expired handoff deadline, and overlapping handoffs clear without a leak.

Addresses the codex conn.go:566 finding on #3989.

* fix(csc): split invalidation and deletion stats

InvalidationStats conflated two quantities and undercounted whenever the
window batcher deduped, spilled, or fell back to a full Flush, which drove
a recurring "another uncounted path" review cycle. Split them:

- Invalidations counts keys named in INCOMING pushes, tallied once at the
  handler choke point before dedup/batching.
- Deletions / DeletionsNoop count APPLIED deletes at the two cache choke
  points (DeleteByRedisKey and deleteByRedisKeyCollectingHot), so the count
  is accurate whether refresh-on-invalidate is on or off.

Invalidations - Deletions now measures dedup plus the flood full-Flush
fallback directly, so the batcher no longer needs ad-hoc per-drop counting
(removed addInvalidationStats and its three call sites). Both are exposed
on CSCRefreshStats.

Also snapshot the refresh recency horizon at ENQUEUE into
cscInvalItem.sinceToken and use it in apply, so a batch-window delay cannot
advance the live horizon and chill a key that was hot when the invalidation
arrived (it would degrade to plain eviction instead of a proactive
refresh). Value-passed, no added allocation.

Addresses the cursor csc_inval_batch.go batch-chill finding and the codex
csc_inval_batch.go stats-undercount finding on #3989.

* fix(pool): serialize relaxed-timeout mutations

SetRelaxedTimeoutWithDeadline stored its new timeout values before swapping the
deadline, so an expiry observed concurrently (a full-duplex reader/writer/drain
backstop) could CAS the old deadline to zero and clearRelaxedTimeout the fresh
values between the store and the swap -- leaving a window with a future deadline
but zeroed timeouts (active but ineffective; commands fall back to the shorter
normal timeout during a migration and can fail spuriously). Reordering alone only
moves the race.

Add relaxedMu to serialize all four mutators (Set, SetWithDeadline, Clear,
expire) so the timeout/deadline/counter atomics are always updated or cleared as
one unit. expireRelaxedTimeout re-validates the deadline under the lock (a
generation check): an expiry the caller observed lock-free is dropped if a newer
window replaced it. The read path (getEffective*/HasRelaxedTimeout) stays
lock-free and takes the lock only via expire on the rare observed expiry, so the
per-I/O hot path is unchanged. The former CAS retry loops collapse to plain
load/store under the lock.

Addresses the codex relaxed-timeout rearming-vs-expiry finding on #3989.

* fix(csc): repoint batcher to survivor before stop

The invalidation batcher captured its refresh binding at creation. When the
active refresh owner closed, clearRefreshQueue restored the surviving sibling on
h.refresh and stopped the batcher, but the stop-drain still offered evicted-hot
keys through the closing owner's queue -- whose drainer had already gone, so the
offers were dropped and the survivor never refetched them (they stayed evicted
until a reader missed). setRefreshQueue had the same staleness on a rebind.

Make cscInvalBatcher.refresh an atomic pointer and repoint it at the current
binding (survivor / new owner, nil if none) BEFORE stop() in both
set/clearRefreshQueue, so the stop-drain feeds the live refresher. An in-flight
apply already holding the old pointer feeds its current batch to the old queue --
a bounded, benign residual (offers to a stopped queue are dropped, never panic:
the queue channel is never closed).

Addresses the cursor "batcher drain feeds closed refresher" finding on #3989.

* fix(pool): relaxed timeout as one atomic snapshot

Ofek's review asked for the relaxed-timeout window to be published as one value
so a reader never sees it half-updated. Replace the mutex plus four atomics with
a single atomic.Pointer to an immutable relaxedState snapshot. Set,
SetWithDeadline, Clear, and expire install a new snapshot with a
compare-and-swap; the read path (getEffective* and HasRelaxedTimeout) does one
lock-free Load. This removes the writer-only mutex and the torn read a reader
could see between the counter bump and the timeout store.

It also fixes getEffective* returning the normal timeout on the call that
observes an expired deadline while a notification holder is still active. The
method now re-reads the snapshot after it retires the deadline holder and returns
the surviving relaxed timeout.

Addresses the codex relaxed-timeout thread and Ofek's snapshot request on #3989.

* fix(csc): bound coalesced writes, harden miss retry

Reader-miss coalescing and refresh both write a whole batch before they read the
replies, and the reader waits until the write finishes. A batch larger than the
connection write buffer (default 32 KiB) makes bufio flush in the middle of the
batch, which can deadlock the write and the read on large payloads. The batch
byte cap was 1 MiB. Bound each batch to the write buffer:

- The miss coalescer caps a batch at min(write buffer, 1 MiB). An over-budget
  request is carried in the writer's own state and sent as the first request of
  the next batch, not put back on the shared queue. So a shutdown cannot strand
  it, and it also does not widen a pre-existing reservation-strand window on the
  drop-without-Close path. The first request always goes.
- The refresh chunker applies the same byte bound next to its count cap.

Also route a coalescer session or transport failure through the normal retry
path. settleErr tags such errors as cscSessionError; processCached re-runs the
read with MaxRetries and backoff instead of a raw io.EOF to the caller. A
reply-level result (redis.Nil, WRONGTYPE, LOADING) stays untagged and is returned
as-is.

Addresses the Ofek review threads on #3989: write-before-read batches, the
refresh batch, the byte-cap overshoot, and the coalesced-miss retry path.

* fix(csc): drop ineffectual pending clear

The grabInto carry change made the pending = nil at the top of the writer loop a
dead store: grabInto now reassigns pending (with the next carry) before it is read
again. Drop the clear. No behavior change. Fixes the golangci-lint ineffassign
that CI flagged.

* fix(csc): read coalesced replies while writing the batch

The miss-coalescer wrote the whole batch and only then handed the requests to the
reader, so the reader was idle for the entire write. A batch larger than the
transport send capacity could deadlock: the server blocks writing early replies
that nobody reads while the writer blocks flushing later requests. Bounding the
batch to the write buffer did not fix this — the write buffer does not bound the
socket send buffer (a custom Dialer or a large WriteBufferSize breaks the
assumption), and with per-op timeouts disabled it can wedge.

Hand the batch to the reader BEFORE writing it (as the autopipeline full-duplex
engine already does), so the reader drains reply k while request k+1 is still
being written and relieves the server's send backpressure. The send cannot
deadlock the writer: a batch is at most cscMissBatchMax (128) and inflight is
cscFullDuplexDepth (4096) deep, the writer is sequential so every prior batch was
already flushed, and every reader-exit path either cancels the session context or
follows the writer's own exit. The byte cap remains only as a batch-size bound,
not as the deadlock fix.

Addresses the Ofek and codex/cursor write-before-read findings on #3989.

* fix(csc): stats without refresh, bound refresh write

Two refresh-path fixes from the review:

CSCRefreshStats returned an all-zero struct when the refresh queue was absent, so
with ClientSideCacheRefreshOnInvalidate off (the default) but invalidation
batching on it hid the cache-level Invalidations/Deletions/DeletionsNoop the
handler still records. Read those cache counters independently of the queue; gate
only the queue-specific counters on the queue.

The refresh writes a whole chunk before it reads the replies, so a chunk that the
server cannot drain (transport backpressure) blocks the flush. With per-op write
timeouts disabled the flush is deadline-less and the 5s batch context does NOT
interrupt it, so the refresher goroutine wedges — worse than the review reported.
Bound the write with a positive deadline (shared with the batch context via one
constant) so a stalled flush fails the refresh and the entries degrade to plain
eviction (self-healing). Full read/write concurrency in the refresher is a
possible follow-up, not done here.

Addresses the codex refresh-stats and refresh write-before-read findings on #3989.

* fix(csc): honor recycle/stop on the carry path

grabInto can defer one over-budget request as a carry, held in the writer's
pending slot and written as the first request of the next batch. While pending is
non-nil the writer took the carry branch and skipped the select that watches
recycle and mc.stop, so a producer whose commands are persistently over the byte
cap could keep the held connection busy and starve a maintenance handoff or a
lifetime recycle indefinitely (its OnPut hooks would never run).

Check recycle/stop/session-cancel (non-blocking) between batches even when a carry
is present, and settle the carry on bail — it lives only in the writer's state, so
the teardown drain, which covers inflight, would otherwise leak it and hang its
caller. Settle with the retry-uncached sentinel on recycle/stop so the caller
re-runs on the pool.

Addresses the codex carry-vs-recycle finding on #3989 (a follow-on from the carry
introduced last round).

* fix(csc): bound the refresh read too

Last round bounded the refresh WRITE so a deadline-less flush could not wedge the
refresher. The following WithReader still used the raw ReadTimeout: with per-op
timeouts disabled (options.go maps -2 to -1, which WithReader treats as no
deadline) a stalled reply or push drain parks refreshInvalidatedBatch forever, and
stopCSCRefresher waits on that goroutine, so Client.Close never returns.

Bound the read with a positive deadline (still capped by the 5s batch ctx, shared
via cscRefreshBatchTimeout), symmetric with the write, so a stall fails the refresh
and the entries degrade to plain eviction instead of hanging Close.

Addresses the cursor refresh-read-hangs-Close finding on #3989.

* fix(csc): per-chunk deadline for refresh round trips

The refresh split a window into chunks (by count and by write-buffer bytes) but
ran them all under one cscRefreshBatchTimeout context. When a window held more
than one chunk and their AGGREGATE latency exceeded the budget, later chunks
failed immediately even though each round trip was within the intended per-round-
trip timeout — four 2s chunks under a 5s budget would refresh only the first two
and leave the rest evicted.

Create the bounded context PER refreshInvalidatedBatch call so the documented
per-round-trip timeout applies to each chunk independently. The recover guard
stays at the loop level.

Addresses the codex per-chunk-deadline finding on #3989.

* fix(csc): flush snapshot cache on FLUSH invalidation

The FLUSHDB/FLUSHALL invalidation branch re-read the live h.cache under a second
RLock and flushed that, while per-key deletes use the cache pointer snapshotted at
handler entry. A last-user releaseLocked (drainer teardown / GC cleanup) can nil
h.cache between the entry RUnlock and the flush RLock, so the guarded
'if h.cache != nil' skipped the wipe entirely. On an injected shared cache
(cscOwnsCache == false) the teardown flush is gated off, so post-FLUSH entries
kept serving until TTL / MaxStaleness.

Flush the entry snapshot instead, matching the per-key branches. The second RLock
stays — it guards the batcher epoch against a concurrent rebuild — but only the
batcher is read fresh under it now; the cache uses the snapshot. The nil guard is
dropped: the entry guard already returns on a nil snapshot.

Addresses the cursor 'FLUSH can skip shared cache wipe' finding on #3989.

* fix(csc): drain all pushes before a coalesced/refresh reply

drainPushFrames in blocking mode, for a CUSTOM PushNotificationProcessor, peeked and
processed once. A second invalidation still on the socket ahead of the reply was
then read by the caller's ReadRawReply as the command value — published to the
shared cache under the wrong key and shifting every later reply. Loop peek-then-
process until a non-push (the reply) is next, matching the built-in blocking
discipline; non-blocking probes keep the single-pass behavior. Covers the coalescer
and refresh reply readers.

* fix(csc): bound in-flight coalesced wire by bytes

Miss coalescing serializes each caller's command into req.wire before it can enter
the bounded queue; arbitrarily many callers can block on the send holding a full
wire copy, and large cacheable commands (a big MGET) have no small encoded size, so
a burst could exhaust memory — the queue's item cap does not bound bytes. Track
total in-flight serialized bytes; before serializing, reserve this command's
approximate size and shed over cscMissWireBudgetBytes (8 MiB) to the pooled path
(errCSCRetryUncached), which is bounded by pool turns. Reserving before serialization
means over-budget callers never allocate the wire; a single command larger than the
budget sheds too (the pooled path runs it). TestCSCMissCoalescerWireBudget pins it.

* fix(push): consume a push whose name can't be peeked

The built-in blocking drain, on a frame already confirmed as a push, broke out of
the loop when PeekPushNotificationName failed for a non-'too-long' reason (a
non-string / malformed name). Breaking LEFT the push at the buffer head, so a
reply-expected reader (CSC refresh / miss-coalescer / the single-command pre-read
drain) then read the push as the command value — the intermittent 'redis: nil' and
cross-command reply shift under load. Fall through to ReadReply and CONSUME the
push instead (matching the name-too-long path): a genuine mid-frame read error still
surfaces at ReadReply and retires the connection, and a non-string name is
gracefully ignored (the downstream type assertion fails, so no handler runs).
Buffered probes still stop cleanly on a boundary timeout. Affects every RESP3
client's pre-read drain, not only CSC. TestProcessor_ConsumesUnpeekableNamePush
asserts the following reply is readable (the push was consumed, not left).

* fix(csc): release miss wire budget when the wire is consumed

The miss coalescer reserved wire-budget bytes before serializing a command, then
released them in a defer that fired on every fetch exit — so a caller that abandoned
after enqueue freed the budget while its wire snapshot was still retained in the
session channel / inflight deque. A burst of large abandoned misses could thus drive
the retained wire bytes far past cscMissWireBudgetBytes.

Track the WIRE's lifetime instead of the caller's: store the reservation on the
request (reserved) and release it exactly once, through a new settle helper, when
the request is consumed (applyAndSettle, settleErr, shutdown drain). fetch releases
in its defer only when the request never reached the queue (serialize error, or
Close/ctx before enqueue), guarded by an enqueued flag.

Also honor ContextTimeoutEnabled for the coalescer's enqueue and reply waits: when
it is off the ordinary command path drives socket I/O on context.Background()
(bounded by ReadTimeout, not the caller deadline), so a coalesced miss must not
surface context.DeadlineExceeded on the caller's deadline. Wait on c.context(ctx)
(caller ctx when the policy is on, Background when off); mc.stop stays the
unconditional shutdown signal and returns still report ctx.Err().

Addresses the csc wire-budget and context-timeout review findings on #3989.

* fix(csc): repopulate cache on coalescer-bail retry, order close

Two fixes on the CSC integration path.

Fallback populate: when the miss coalescer bailed (errCSCRetryUncached or a session
error) processCached retried through a nil-capture processWithRetry — it returned
the value but stored nothing, so a connection blip or a wire-budget shed left the
key uncached and every later reader missed it. Re-Reserve and fall through to the
capture path instead, so a successful retry repopulates the cache (Reserve may hand
fetching to another reader, which the capture path already handles).

Close ordering: stopBackgroundDrainer deactivated serving (cscActive=false) before
joining the refresher, but the refresher's stop-path flush re-fetches the in-window
invalidated keys and refreshInvalidatedBatch skips that work once cscActive is false
— so the last collection window was dropped, and on a SHARED (unowned) cache those
hot keys stayed evicted for siblings until a later miss. Stop the refresher FIRST
(serving still on, pool still open), then deactivate before stopping the coalescer,
which still shields a racing clone miss from an ErrClosed on the just-stopped
coalescer.

Addresses the csc fallback-populate and close-refresh-window review findings on #3989.

* fix(csc): cap per-target key bytes in the refresh queue

The refresh queue's depth cap (cscRefreshQueueDepth) bounds it by ITEM count, not
memory: with large key encodings, up to that many queued targets could pin far more
than the cache's own limit. Drop any target whose key bytes (cache key + Redis keys)
exceed cscRefreshTargetMaxBytes before enqueue — like a full-queue drop, it just
means a later reader does a normal miss fetch — bounding retained key memory to
roughly cscRefreshQueueDepth * the cap.

Addresses the csc refresh-queue-bytes review finding on #3989.

* fix(csc): drain queued refresh targets on refresher stop

The refresher's stop branch flushed only the collected window (pending); targets
still buffered in q.ch — offered but not yet collected — were abandoned. Because
stopCSCRefresher rebinds the invalidate handler away from this queue BEFORE
signalling stop, nothing else drains q.ch, so on a shared cache up to
cscRefreshQueueDepth hot entries stayed evicted for the surviving sibling until a
later reader missed them.

Extract drainQueue (the existing non-blocking window-fill, now reused by the
q.ch branch) and, on stop, loop drain+flush until the channel is empty. The
channel is quiescent at that point (no producer), so the loop is finite, and the
pool is still open (closeResources runs after the join), so the final refetch can
complete.

Addresses the codex drain-queued-refresh-targets finding on #3989.

* fix(csc): use live horizon for no-binding invalidations

An invalidation enqueued while the batcher had no refresh binding stamped
sinceToken 0. apply re-reads the refresh binding at apply time, so a refresh
client attaching in between (setRefreshQueue repoints the batcher, then
stop-drains it) applied those items against horizon 0 — treating every valid
entry as hot and offering it for refetch. That is the cold-key refresh loop the
recency horizon exists to prevent, and it re-registers cold keys with the server,
pinning its tracking table.

Stamp cscInvalNoHorizon (-1) when no binding exists at enqueue; apply falls back
to the binding's LIVE horizon (seeded at the attaching client's start), a real
recency test that can only under-refresh, never chase cold keys. LRUClock is a
monotonic sequence >= 0, so -1 cannot collide with a real horizon.
TestInvalBatcherRefreshAttachUsesLiveHorizon pins it (a cold pre-attach entry is
deleted but not offered; a hot one is).

Addresses the cursor refresh-attach-bypasses-recency-gate finding on #3989.

* fix(options): normalize nonpositive DialerRetries

Options.init normalized DialerRetries only when == 0, but the pool treats any
value <= 0 as the default (ConnPool.dialConn). A negative DialerRetries therefore
left the miss-coalescer's acquire deadline (which derives its budget from
opt.DialerRetries) computing a smaller retry count than the pool actually uses.
Normalize <= 0 so both see the same count; document the field accordingly.

Addresses the codex normalize-nonpositive-retry-counts finding on #3989.

* fix(csc): raise push-drain budget under relaxed timeouts

Every push drain ran under a hard 50ms read cap (WithReaderHardDeadline) that
deliberately bypasses maintenance relaxation. Right for a speculative probe on a
healthy conn; wrong mid-failover: relaxation is active exactly because the server
is slow, a push frame can fragment past 50ms, and a mid-frame timeout is a desync
— so the pre-command path retired a HEALTHY tracked connection (evicting its CSC
coverage) and, with MaxRetries=0, failed the command outright. The old pre-PR
drain honored relaxation via WithReader.

Add pushDrainBudget: the hard cap, raised to the connection's relaxed timeout
while relaxation is active (EffectiveReadTimeout; one-directional — a smaller
relaxed window never lowers the cap). Paid only when bytes are actually mid-frame:
an empty or completed drain returns without blocking regardless of the budget.
Pinned by TestPushDrainBudgetHonorsRelaxation.

Addresses the pre-command-drain finding in the #3989 deep review.

* fix(csc): join inval batcher on stop, wait on re-reserve loss

Two review fixes on the invalidation/miss integration paths.

Batcher join: stop() only signalled; the batcher's stop-drain and final flush ran
asynchronously after Close (or a rebuild) returned — a straggler goroutine past
Close, and on a shared injected cache a late apply could evict an entry a
successor client just repopulated. Give the batcher a done channel (closed when
run() exits) and a join(); every stop site (releaseLocked, setInvalBatchWindow,
set/clearRefreshQueue) now stops AND joins, making teardown and rebuilds
synchronous. join is safe under h.mu: run() takes no handler locks. Pinned by
TestInvalBatcherStopJoinIsSynchronous. Also resolves the join-before-tighten
review thread.

Bail-loser wait: after a coalescer session failure, waiters raced the re-Reserve
and every LOSER fell through to an independent pooled processWithRetry — one hot
key turned into N pooled requests exactly mid-recovery. A loser now waits on the
new owner (csc.Get parks on the in-progress entry, same shape as the
first-Reserve loser path) and only falls back to the pooled path after a second
Reserve loss.

Addresses the codex drain-after-close and keep-same-key-waiters findings on #3989.

* fix(csc): consume window demand nudge on refresh flush

A demand signal buffered while a window was being flushed survived the flush:
signalDemand fires only for keys in pendingSet, so the nudge referred to a key
this flush was already refetching, yet it immediately flushed the NEXT window on
its first collected key — defeating the coalescing window and inflating
DemandFlushes. flush() now consumes one buffered nudge after clearing the window
(collect runs only on the refresher goroutine, so no future-window demand can
exist yet).

Addresses the codex discard-stale-demand-signals finding on #3989.

* fix(csc): emit coalesced reply-error metric exactly once

applyAndSettle emitted the native error metric for every non-nil parsed reply
while a caller whose context expired late emitted its cancellation independently
— the same operation recorded twice with conflicting error types whenever the
cancellation raced the reply.

Centralize emission at the caller's req.done receive (emitReplyErr): reply errors
(redis.Nil, WRONGTYPE, ...) are recorded once, where the caller consumes them;
session errors and shed retries stay excluded (their processWithRetry re-run
emits its own outcome); a caller returning ctx.Err() emits only its cancellation.
req.servedBy is safe at the receive (the done receive is the field's
happens-before edge), so the metric now also carries the serving conn on the
paths that previously reported from the reader side.

Addresses the codex double reply+cancel metric finding on #3989.

* fix(csc): staleness warning covers injected LocalCache

The ClientSideCacheInvalidationBatchWindow > MaxStaleness misconfiguration
warning was gated on ClientSideCacheConfig != nil, so a pre-built cache injected
via ClientSideCache was never checked — a 10ms-staleness cache with a 500ms batch
window served pre-invalidation values 50x past its own contract, silently. When
the config is nil, read the injected *LocalCache's effective bound
(effectiveMaxStaleness) and run the same comparison.

Addresses the injected-cache warning gap in the #3989 deep review.

* fix(csc): budget refresh round trips by expected reply bytes

The refresh chunker bounded only the REQUEST side (key bytes vs the write
buffer); the reply side was unbounded. Within one chunk the server streams
replies for the early keys while the client is still writing the late ones, and
against a flow-controlling middlebox (a proxy or tunnel that stops reading when
its buffer toward the client fills — vanilla Redis instead buffers replies in
RAM) enough in-flight reply bytes stall the connection both ways: the classic
pipeline write-read deadlock, surfacing as the per-chunk deadline failing the
whole chunk and refresh silently degrading to eviction for large-value
workloads.

Budget each round trip's EXPECTED reply volume: collectHotAndDelete captures the
dying entry's value size (free, under the same shard lock) on the refetch
target, and the chunk boundary — extracted pure as cscRefreshChunkEnd and
unit-tested — now caps count, request bytes, and expected reply bytes (32 KiB,
comfortably inside typical kernel socket buffering). An estimate, not a
guarantee (a value may have grown since eviction); the per-chunk deadline stays
the backstop, and full read/write concurrency in the refresher remains the
follow-up if the deployment path is confirmed to apply per-connection
backpressure.

Refs the refresh write-before-read review threads on #3989.

* docs(csc): freshness contract, push spec, metrics TODO

Outcomes of the push-consumption review discussion, no behavior change.

- Document the invalidation-freshness contract on ClientSideCacheConfig: applied
  at arrival on full-duplex conns, at the next command on active conns, at the
  next drainer tick on idle conns; MaxStaleness is the hard bound everywhere.
- Add .claude/specs/push.md: the checkpoint consumption model and its physics
  (frame completion, TLS peek opacity, custom-processor gating), the freshness
  guarantee, and the rules that keep the model closed — no new speculative drain
  sites; features needing fresher pushes own a reader (FD-session pattern). Also
  records the planned NotificationDrainer capability follow-up.
- TODO(convergence) at the coalescer-bail re-run: start retry metrics at attempt
  1 via the explicit-start plumbing once the full-duplex branch lands here.

* fix(csc): non-blocking GC teardown, classify attributed refresh replies

The drop-without-Close GC finalizer set cscActive false BEFORE signalling the
refresher, so refreshInvalidatedBatch bailed and the stop-drain flush was a no-op
— in-window hot keys stayed evicted for a surviving sibling. And clearRefreshQueue
/ setRefreshQueue / releaseLocked called the batcher join() while holding h.mu,
blocking the GC finalizer (documented non-blocking) and stalling a sibling in
ensureBatcher on the hot path (miss-coalescer reader).

Detach the batcher under h.mu (detachBatcherLocked, stop-only) and join OUTSIDE
the lock — on the Close / self-disable / attach paths, and NOT AT ALL on the GC
finalizer, which is reordered (clearRefreshQueue -> refresher signalStop ->
deactivate) so its async stop-drain is runnable while serving is still on. The
guaranteed final-flush window stays the Close-path join; the finalizer stays
non-blocking. setInvalBatchWindow gets the same treatment.

Also: the refresher's cacheability check inspected raw[0], but a RESP3
attribute-prefixed error (|... frame) has raw[0]==RespAttr, so an error reply was
published as a false refresh success (next reader must evict + refetch).
Classify the FULL reply (cscRefreshReplyCacheable via classifyCachedReply), like
the miss-coalescer.

Addresses the GC-cleanup teardown findings and the attributed-error refresh
finding on #3989.

* fix(csc): cancel stranded miss on shutdown, charge wire, bound spill

Three bounds/lifecycle fixes on the coalescer and invalidation batcher.

Stranded miss: fetch's mc.stop wait branch cancelled the reservation and
re-drained mc.ch, but the wctx.Done (caller-deadline) branch only claimAbandoned,
leaving a post-drain request IN_PROGRESS so a later Get waited out StaleTimeout.
cancelIfStopping now cancels + re-drains on that branch too — gated on mc.stop
being observed, so a healthy late-cancel does not no-op a live reader's
fulfillCached (token-guarded cache populate).

Wire budget: cmdApproxBytes charges ~8 bytes for variable-size non-key args
(BinaryMarshaler, *string), so the 8 MiB in-flight wire budget under-counted.
reconcileWireBytes charges the delta between the estimate and the actual
len(req.wire) after serialization, keeping req.reserved in lockstep (settle
releases exactly once) so the counter reflects reality and the next reserve sheds.

Invalidation spill: the spill was bounded by item count only; a burst of distinct
large namespaced keys retained GBs below the item cap. Track retained key bytes
(cscInvalSpillMaxBytes = 8 MiB) and take the same full-Flush fallback when either
bound trips (a single oversized key trips it alone).

Addresses the shutdown-strand, wire-size-budget, and invalidation-backlog-bytes
findings on #3989.

* fix(csc): shed a coalesced miss when its real wire exceeds budget

The wire budget is reserved from cmdApproxBytes before serialization, then
reconciled to the actual size after. Round 5 only corrected the counter and kept
an over-budget wire, so N concurrent misses with undercounted variable-size args
(BinaryMarshaler, *string, net.IP) each pass the pre-serialize gate and then
charge their true size, drifting the in-flight total well past
cscMissWireBudgetBytes while every snapshot stays queued.

reconcileWireBytes now returns keep/shed: when charging the actual size would
cross the cap it rolls the delta back, leaves req.reserved at the estimate (fetch's
!enqueued defer releases exactly that), and fetch sheds to the pooled path — so the
over-budget wire is dropped, not retained in mc.ch/inflight. A shrinking delta
always keeps. Pinned by TestCSCMissCoalescerShedsWhenActualOverBudget.

Addresses the recheck-wire-budget-after-serialization finding on #3989.

* fix(csc): bound invalidation channel bytes, not just spill

Round 5 bounded the overflow spill by retained key bytes, but the fast-path
channel admits by item COUNT only: a burst of large keys — or one key larger than
the whole byte cap — retains unbounded bytes in ch before the spill path ever
engages.

Track ch retained key bytes (chBytes): enqueue adds on a successful ch send and
the worker subtracts on consume. A pre-admission gate diverts to the full-Flush
fallback when this key alone exceeds the byte cap, or when the ch bytes plus this
key would cross it, instead of admitting to ch. Pinned by
TestInvalBatcherChByteGateTripsFlush and TestInvalBatcherOversizedKeyTripsFlush.

Addresses the invalidation-byte-cap-before-channel-admission finding on #3989.

* docs(csc): note refresh-stats scope on a shared cache

CSCRefreshStats mixes cache-global counters (Invalidations/Deletions/DeletionsNoop,
read from the shared LocalCache) with this-client's-queue counters
(Enqueued/Refreshed/...). On a shared cache+processor the active refresh binding is
the last-attached client's queue, so an earlier client reports every invalidation
but ~zero refresh work — correct (it is not the refreshing client), but a naive
refresh/invalidation ratio then reads as broken. Document that the ratio is
meaningful only single-client or for the client holding the active binding. No
behavior change (scoping the counters per-client would change what Invalidations
means for existing readers).

Addresses the keep-shared-cache-refresh-statistics-in-one-scope finding on #3989.

* fix(csc): reserve invalidation channel bytes atomically

The ch byte gate was a non-atomic Load then Add-after-send: concurrent producers
(invalidations on multiple tracked conns) could each read a below-cap chBytes, all
send a large key, then all Add — overshooting cscInvalSpillMaxBytes while the
counter briefly read near zero. Reserve atomically before the send (add, and roll
back if it crosses the cap or the ch is full, mirroring reserveWireBytes); an
over-cap reservation diverts to the full-Flush fallback. Pinned by
TestInvalBatcherChByteReservationIsAtomic.

Addresses the reserve-invalidation-channel-bytes-atomically finding on #3989.

* fix(csc): generation-tag refresh demand notifications

A reader that passed signalDemand's pendingSet check just before flush cleared the
window could send its nudge AFTER the one-time drain, and that stale nudge then
flushed the NEXT (unrelated) window early — inflating DemandFlushes and defeating
the coalescing window. Stamp each nudge with the window generation (read before the
membership check); flush bumps the generation on clear; the refresher ignores a
nudge whose generation is retired (demandIsCurrent). Pinned by
TestCSCRefreshDemandGenerationIgnoresStaleSignal.

Addresses the generation-tag-demand-notifications finding on #3989.

* fix(csc): honor caller cancellation while enqueuing a miss

fetch used wctx (Background when ContextTimeoutEnabled is false) in the enqueue
select, so a caller that cancelled could not abort while merely WAITING FOR A QUEUE
SLOT — pre-I/O backpressure — even though the ordinary path passes the caller ctx to
ConnPool.Get. The enqueue-admission select now watches the original ctx; wctx stays
only on the reply wait, which maps to socket I/O and follows the timeout policy.
Pinned by TestCSCMissCoalescerEnqueueHonorsCallerCancel.

Addresses the keep-caller-cancellation-while-waiting-to-enqueue finding on #3989.

* fix(csc): don't re-run a limiter-denied coalesced miss

When the coalescer's session acquisition failed because Options.Limiter.Allow
REJECTED admission, the rejection was tagged cscSessionError like a transport
failure, so processCached re-ran the command through processWithRetry -> getConn ->
Limiter.Allow a SECOND time. A stateful or token-based limiter can permit that
second call, executing the very miss whose original admission was denied.

Split the admission from the dial (getConnLimited: getConn wraps it, byte-identical
for existing callers) so the coalescer can tell an Allow rejection apart from a
dial/transport failure. On a rejection, settle the caller with the error UNTAGGED
(settlePlain/drainQueuePlain) so processCached returns it as-is — one Allow total,
matching the ordinary path (shouldRetry is false for a generic limiter error). A
genuine transport failure keeps its cscSessionError retry behavior. Pinned by
TestGetConnLimitedSurfacesRejection.

Addresses the preserve-limiter-rejections-during-session-acquisition finding on #3989.

* fix(csc): unify teardown ordering across close and self-disable

Three interrelated CSC teardown findings, resolved by one canonical order.

New once-guarded stopCSCRefresherAndCoalescer runs: stop the coalescer (releasing
its held pool connection) -> run the refresher's final flush while cscActive is
still true -> deactivate. Called from both the clean Close path and the drainer
goroutine's self-disable defer (neither is the refresher/coalescer goroutine, so
the joins never self-block).

- Self-disable (disableCSCServing / custom-processor damping / drainer seeing
  cscActive go false) previously exited without stopping the refresher or
  coalescer — the goroutines and a held pool conn leaked for the client's life.
  It now runs the same teardown. Start guards on startCSCRefresher/
  startCSCMissCoalescer close a construction-time disable window.
- Handler-initiated close (a push handler calling Client.Close) no longer
  preemptively deactivates + stops the drainer before the canonical close; it lets
  the async close drive the refresher-first-while-active ordering (mirrors the GC
  path).
- Releasing the coalescer connection before the refresher's flush avoids a Close
  stall (up to ~160s on a small pool: refresh chunks waiting their deadline while
  the coalescer held the only conn). Reversing the old deactivate-first step is
  safe: every coalescer stop path returns errCSCRetryUncached/cscSessionError, so
  processCached re-runs on the still-open pool — no raw ErrClosed reaches callers.

Tests + red-checks in csc_test.go. Deferred: quiescing invalidation producers that
snapshotted the queue before unbind (needs a hot-path lock; the residual is a
bounded teardown-only refetch, not a stale serve) — see the review reply.

Addresses the self-disable-leak, handler-close-ordering, and
release-coalescer-before-refresh findings on #3989.

* fix(csc): preserve invalidation dedup across a size-cap flush

run()'s size-cap flush (len(pending) >= the batch cap) cleared the epoch-scoped
seen map without a full-cache Flush, so a duplicate key straddling the cap was
applied twice; with refresh-on-invalidate the first apply refetched and published
and the second evicted that fresh entry. Split flush into applyPending + clearSeen;
the size-cap path applies WITHOUT clearing seen, so per-key dedup spans the whole
window (timer/stop/full-flush still clear it). Epoch-bump was rejected: epoch is
captured at enqueue and only valid paired with a full Flush, so bumping here would
skip in-flight ch/spill items and lose their deletes. Pinned by
TestInvalBatcherSizeCapPreservesDedup.

Addresses the size-cap-flush-drops-dedup finding on #3989.

* fix(csc): bound the shutdown refresh drain on first failure

On Close with a large backlog against a dead/stalled server, the stop-drain gave
each chunk a fresh 5s deadline and continued after errors — a full queue could
delay Close ~160s (hours if large replies shrink chunks). The stop-drain now bails
after the first failed chunk (those entries are evicted with this client's coverage
on Close anyway). The normal (non-shutdown) refresh path is unchanged. Pinned by
TestCSCRefresherStopDrainBailsOnFirstFailure.

Addresses the cap-the-entire-shutdown-refresh-drain finding on #3989.

* fix(csc): bound concurrent coalesced wire serialization

cmdApproxBytes underestimates variable-size args, so many concurrent misses all
passed the approx reservation and each allocated a full wire snapshot BEFORE
reconcileWireBytes could shed — a concurrent burst transiently coexisted many
multi-MB buffers and could exhaust memory even though none stayed queued. Add a
serialize semaphore (cap = budget/batch) acquired before writeCmd and released
after the reconcile decision, always before the enqueue select, so no slot is held
across a channel send or reply wait (deadlock-free with the single worker); a
caller that can't get a slot sheds to the pooled path. Pinned by
TestCSCMissCoalescerBoundsConcurrentSerialize and the fetch-level shed test.

Addresses the bound-concurrent-serialization finding on #3989.

* fix(csc): share refresh queue into clones for demand flush

clone() (WithTimeout/WithContext) copied the miss coalescer but not
cscRefreshQueue, so a derived client's queue was nil and signalDemand no-opped —
the clone never early-flushed the owner's in-window batch, so a demand-worthy miss
waited the full cscRefreshWindow. Share the queue pointer into the clone (like the
pool hook); lifecycle stays safe — the refresher/drainer handles are owner-only
(nil in clones) so a clone only signals (nil-safe, non-blocking), never stops.
Pinned by TestCloneSharesRefreshQueueForDemand.

Addresses the clones-skip-refresh-demand-flush finding on #3989.

* fix(csc): guard invalidation deletes by fetch-issue order

Round-10 kept the batcher's per-window dedup (seen) across a size-cap flush so a
key straddling the cap could not be applied twice — the second apply would evict
the entry the first apply's refresh had just refetched. That traded a benign extra
miss for two defects: seen grew unbounded under sustained load (the timer that
clears it is deferred by every size-cap t.Reset), and a genuine re-invalidation of
a refreshed key was deduped away and served stale. Worse, keep-seen could not fix
the case where ONE cached command depends on SEVERAL Redis keys (e.g. MGET k1 k2):
a refresh triggered by k1's invalidation republishes the entry, and k2's
invalidation — a different seen entry — then re-evicts it. seen is keyed by Redis
key, so it structurally cannot reason about whether THIS entry was refetched.

Move the correctness out of dedup and into the delete. Stamp each entry with the
global cscFetchSeq value at the moment its fetch is ISSUED (Reserve), carried
through fulfill; snapshot cscFetchSeq when an invalidation is OBSERVED (enqueue).
collectHotAndDelete now keeps a Valid entry whose fetchSeq exceeds the
invalidation's snapshot: that fetch was issued after the write, so its reply
already reflects it. In-progress placeholders are still removed unconditionally.

Stamp at fetch ISSUE, not fulfill COMPLETION: invalidations and replies travel on
different connections with no ordering, so a stale reply can fulfill AFTER the
invalidate is observed (see deleteByRedisKey). A completion-order stamp would then
retain that stale value; an issue-order stamp is sound because a fetch issued after
the observe necessarily reached a server that had applied the write.

With correctness in the guard, dedup lifetime no longer matters: the size-cap path
goes back to flush() (apply + clearSeen), so seen is bounded and a genuine
re-invalidation is re-applied. A straddling stale duplicate is now a no-op via the
guard instead of via dedup.

Tests: TestCollectHotMultiKeyRefreshSurvives (the MGET case dedup can't fix),
TestCollectHotFetchOrderGuard (stale kept, genuine evicts — the case a fulfill-time
stamp fails), and TestInvalBatcherSizeCapGuardKeepsRefreshed (end-to-end, with a
DeletionStats noop delta proving the duplicate was applied and guard-skipped, not
deduped). Existing collectHotAndDelete callers pass a max snapshot to isolate their
recency/stats assertions from the guard.

Addresses the size-cap-flush-dedup regression and the multi-key re-eviction on #3989.

* fix(csc): extend fetch-order guard to in-progress fetches

A delayed invalidation applied while a replacement fetch was still in progress
removed the reservation even when entry.fetchSeq > fetchSnap — a fetch reserved
AFTER the invalidation was observed, which cannot hold the pre-write value.
Canceling it failed its racing Fulfill, woke every waiter as a miss, and could
duplicate requests / defeat miss coalescing. The guard was Valid-only; drop that
restriction so it keeps any entry (Valid or in-progress) issued after the observe.
An entry reserved at or before the observe (fetchSeq <= fetchSnap) may predate the
write and is still removed. Pinned by TestCollectHotGuardKeepsInProgress.

Addresses the preserve-newer-in-progress-fetches finding on #3989.

* fix(push): align idle CSC drain with the push-drain spec

drainPushNotifications diverged from peekAndProcessPushNotifications /
pushDrainWithin and .claude/specs/push.md on two points.

1. It drained under the raw cscDrainHardReadCap. During maintenance relaxation a
push frame can fragment past that small cap, and a mid-frame timeout is a fatal
desync that removes the connection and evicts its CSC coverage in the very
failover/migration window relaxation is meant to tolerate. Route the drain through
pushDrainBudget so the cap is raised to the relaxed timeout while relaxation is
active (unchanged otherwise).

2. It invoked a custom push processor whenever the socket was readable
(CheckForData), including kernel-only readability that over TLS can be a control
record with zero RESP bytes; the custom blocking loop then hit the hard cap and its
error — always fatal for a custom processor — retired a healthy connection. Gate the
custom processor on the buffered state captured BEFORE the probe: a byte the probe
just fetched from the socket/wrapper is not "already buffered", and handing it to a
blocking loop is the exact spurious retire push.md warns of. So a custom processor
drains only when RESP bytes were already in the reader buffer; kernel-only AND
probed bytes both defer to the next pass. Mirrors peekAndProcessPushNotifications
(buffered captured before any drain). The built-in buffered processor is unchanged —
it still proceeds on kernel-only readability (a boundary timeout is benign). Custom
skipping is bounded by the periodic fallback probe and the MaxStaleness backstop.

Pinned by TestDrainPushNotifications_RelaxedBudgetToleratesFragmentedFrame and
TestDrainPushNotifications_CustomProcessorSkipsKernelOnlyData.

Addresses the apply-relaxed-budgets-to-idle-drainer and enforce-custom-processor-gate
findings on #3989.

* fix(csc): carry the latest fetch snapshot when deduplicating invalidations

When two writes to the same key land in one batching window with a cache refetch
between them (an entry near MaxStaleness expiring and being re-fetched), the second
write's invalidation was deduped and DROPPED, leaving the retained item with the first
write's older fetchSnap. At apply the fetch-order guard then preserved the entry
refetched BETWEEN the writes (its fetchSeq exceeded the stale snapshot), serving it
stale until the next invalidation or MaxStaleness.

The dedup now records each key's index in pending and, on a same-epoch duplicate,
carries the LATEST (max) fetchSnap onto the retained item, so the guard evicts an
entry only refetched before the most recent write. seen and pending are always reset
together, so a stored index never outlives its entry. Pinned by
TestInvalBatcherDedupKeepsLatestFetchSnap. Addresses the retain-newest-invalidation-
snapshot finding (e4xHA).

* fix(csc): cancel partial refresh reservations on a CacheSizer panic

refreshInvalidatedBatch armed its reservation-cancelling defer AFTER the Reserve loop.
A user CacheSizer panics inside Reserve; the outer refresher recovery keeps the worker
alive, but the targets reserved so far were left IN_PROGRESS with no defer installed,
so every reader of those keys blocked until StaleTimeout. Arm the defer BEFORE the
loop so a mid-loop panic cancels the partial set during unwind. Pinned by
TestRefreshInvalidatedBatchCancelsReservationsOnSizerPanic. Addresses the cancel-
partial-refresh-reservations finding (e8hzZ).

* fix(push): keep the short first-byte boundary for speculative drains

pushDrainWithin applied the relaxation-aware budget (pushDrainBudget) to the ENTIRE
drain, including the first-byte wait. Under active maintenance relaxation a TLS control
record — socket-readable with zero RESP bytes — made the speculative/pre-command drain
block for the full relaxed timeout (seconds), stalling a ready command and the idle
drainer. Bound the first-byte wait to the short cap: under relaxation, probe one byte
under the short cap first (a timeout there means no frame began, benign) and grant the
relaxed budget only to finish a frame already in flight — mirroring the idle drainer's
probe-then-drain. Pinned by TestPushDrainWithinShortBoundaryUnderRelaxation. Addresses
the keep-short-boundary-timeout finding (e8hzh).

* fix(csc): pair the batcher with the flushed cache on full invalidation

On a FLUSHDB/FLUSHALL (nil-payload) invalidation, HandlePushNotification snapshotted
the cache at entry but read h.batcher FRESH under the flush lock. A last-user release +
rebind to a different cache (A->B) running under h.mu.Lock between the snapshot and the
flush left h.batcher = B's while cache = A: dropping B's batcher bumped B's epoch and
skipped B's already-queued deletes while only A was flushed, so B served stale entries
until MaxStaleness (zero by default).

Extract fullFlush and drop the batcher ONLY when it still belongs to the cache being
flushed (h.cache == cache). An unchanged binding still drops the fresh batcher — a
same-cache batcher rebuild's queued deletes must be superseded — but a rebind no longer
drops the new binding's batcher. Pinned by
TestInvalidateHandlerFullFlushPairsBatcherWithSnapshotCache.

Addresses the P1 keep-batcher-paired-with-the-snapshotted-cache finding (fCCqu).

* fix(csc): attribute a cancelled coalesced miss to its sent connection

When a caller's context expired after the coalescer session had sent the command but
before the reply was claimed, fetch reported the cancellation with a nil connection and
returned nil, so the error/operation-duration metrics dropped the peer attributes for a
command Redis actually processed. servedBy is unsafe to read on that branch (it races
the session's write; the happens-before edge is the req.done receive, which has not
happened yet).

Add sentConn (atomic.Pointer[pool.Conn]), stored by the session when the batch is
assigned to a connection; the cancel branch reads it to attribute the metric and the
returned conn, falling back to nil only for a true pre-send cancellation. Metrics-only,
no behavior change; the exact send-vs-cancel race is not deterministically unit-pinned.

Addresses the record-sent-coalesced-misses-before-cancellation finding (fCCqy).

* 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(csc): recover coalescer callback panics; honor pre-I/O cancel; bound reply deadline

Three coalescer robustness fixes from the #3989 review.

Recover a panicking user CacheSizer (via fulfillCached) or custom Cmder reply
parse on the full-duplex reader goroutine: applyAndSettle now defers a recover
that calls settleErr, so the panic neither crashes the process nor leaves the
caller blocked on req.done (the req is already off inflight, so the teardown
drain cannot reach it). fetch now releases the serialize slot and cancels the
cache reservation on a panic in the writeCmd window (folded into the !enqueued
defer + a serializeHeld flag), instead of leaking the slot and pinning the key
IN_PROGRESS until StaleTimeout. These are guardrails for the documented
'callbacks must not panic' contract; they mirror the refresher/invalidation-
batcher recover pattern and are not separately unit-pinned.

Honor caller cancellation while a coalesced miss waits for a session but has not
yet reached a connection (req.sentConn == nil), even when ContextTimeoutEnabled
is false. The ordinary path passes ctx to ConnPool.Get and only drops to the
policy ctx for socket I/O; the reply wait now matches that, then follows the
policy once I/O has begun.

Arm the reply read under batchBudget (write + read), not bare ReadTimeout: the
writer hands a batch to the reader before flushing it (deadlock avoidance), so a
reply read can arm while its request is still being written, and a slow/large
write within WriteTimeout would spuriously time out and tear down the session.
The drain backstop's zero-progress interval is kept strictly above batchBudget so
a legitimately slow single reply is not mistaken for a stall. This changes a
user-visible reply-timeout bound; no test drives a reply slower than ReadTimeout.

Addresses the coalescer-reader-panic (fCCni-689), fetch-panic-leak (532),
post-enqueue-cancellation (492) and reply-deadline-during-write (392) findings.

* fix(csc): safe cache identity, batcher rebind pairing, per-notification fetch order

Three invalidation-handler correctness fixes from the #3989 review.

Compare caches with sameCache() instead of ==: h.cache == cache in fullFlush
panics on every FLUSHDB/FLUSHALL when the cache's dynamic type is non-comparable
(a supported case — sameCache already exists for it), crashing the push
goroutine. Pinned by TestInvalidateHandlerFullFlushNonComparableCacheNoPanic.

Pair the windowed enqueue batcher with the snapshot cache: after a last-user
release + rebind to a different cache (A->B) between the notification snapshot and
ensureBatcher, the returned batcher belonged to B and deleted B's entries for a
push meant for A. Guard with sameCache(b.cache, cache), else fall through to the
inline delete on the snapshot; fullFlush already guarded the same pairing.

Snapshot the fetch-order sequence once per notification, not per key: a live
per-key cscFetchSeq.Load() included a fetch reserved after the push was observed,
so collectHotAndDelete wrongly evicted the fresh value / cancelled its
reservation. Mirrors cscInvalItem.fetchSnap (taken at enqueue).

The rebind and fetch-order fixes are race-dependent and not separately unit-
pinned; the CSC -race suite covers them. Known follow-up: the batcher enqueue
path (csc_inval_batch.go) still snapshots cscFetchSeq per enqueue call rather than
per notification — a deliberate, larger change left for a focused commit.

Addresses the safe-cache-identity (468), enqueue-cache-rebind (409) and
per-notification-fetch-order (434) findings.

* fix(csc): boundary-probe idle drain; document built-in-processor and hook contract

Route the background idle-drainer read through a relaxation-aware boundary probe,
mirroring pushDrainWithin: under active maintenance relaxation, confirm a RESP
byte under the short hard cap before granting the relaxed budget, so a TLS control
record (socket-readable, zero RESP bytes) cannot stall the single-goroutine
drainer for the full relaxed timeout (3068).

Document the contract instead of chasing every corner case: a custom
PushNotificationProcessor is not supported with client-side caching (CSC relies on
the built-in processor consuming kernel-only data and tolerating boundary
timeouts); the client logs a warning at init in that case. Hooks and user
callbacks must call next, must not call Close or other client control methods
(deadlock), must not panic, and must not mutate client/connection state. Behavior
is undefined otherwise. Documented on Options.ClientSideCacheConfig, AddHook,
.claude/specs/push.md, and AGENTS.md.

Addresses the custom-processor-staleness (3057) and custom-processor-gate
(push.md:35) findings by contract, and the relaxed-idle-drain-probe (3068) finding
by fix.

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

This reverts commit e62712061e.

* fix(csc): publish client weak ref before the drainer starts

cscClientWeak was assigned in cscRegisterCleanups, which runs AFTER attachCSC has
already launched the background drainer. The push-handler adapter's canonical close
reads cscClientWeak.Value() on the drainer goroutine, so a push arriving in that
window both raced the assignment (a data race) and could miss the close. Publish the
weak back-reference in NewClient BEFORE attachCSC, and drop the post-attach set in
cscRegisterCleanups (writing it while the drainer is live would re-introduce the
race). Weak so a dropped *Client stays collectible and its GC cleanup still fires.

From the Copilot review of #3989.

* fix(csc): bound the invalidation batcher's pending batch by bytes

The windowed invalidation batcher size-capped its PENDING batch by item count only
(cscInvalBatchMax=4096). ch and spill bound the bytes WAITING to be consumed, but the
worker then holds each key in pending until the window or the item cap flushes, so a
stream of large keys (each under the admission limit) could retain GBs across one
window while the item count stayed far below the cap. Track retained key bytes and
flush the batch early once they cross cscInvalBatchMaxBytes (8 MiB, mirroring the
spill/refresh key-byte caps) — a normal apply, not a full-cache Flush. Pinned by
TestInvalBatcherPendingByteCapFlushes.

From the Copilot review of #3989.

* fix(csc): stop refreshes from renewing a key's read recency

LocalCache.fulfill stamps lastAccessNs unconditionally, and the refresh hot-key guard
keeps entries whose lastAccessNs is past the refresh horizon. So a background refresh
republish looked like a fresh reader access: a key invalidated faster than the horizon
tick kept refreshing itself even after every reader stopped — a self-sustaining
refetch loop contrary to the cold-key guard.

collectHotAndDelete now captures the evicted entry's reader-access token on the
refresh target, and the refresh republish restores it via LocalCache.restoreAccessToken
after fulfill, so only a real reader read (get, which re-stamps) keeps a key eligible
for refresh. A reader racing the restore only UNDER-refreshes that key (the next reader
refetches), never serves stale. Refresh runs only with the built-in *LocalCache, so the
restore is a *LocalCache method reached via a type assertion; the public Cache surface
is unchanged. Pinned by TestRefreshRepublishDoesNotRenewDemand.

From the Copilot review of #3989.

* test(csc): fix disable-mid-miss flake; correct wire-budget note

TestFullDuplexDisabledMidMissRetriesUncached read cscMissCoalescer.Load().fetch after
disableCSCServing, whose drainer teardown asynchronously swaps the coalescer to nil —
a scheduler delay made the Load() nil and the test nil-deref. Capture the coalescer
BEFORE disabling and call the captured pointer (it still honors the serving-off flag).

Correct TestCSCMissCoalescerBoundsConcurrentSerialize's comment: the serialize
semaphore bounds the NUMBER of concurrent snapshots, not their bytes — peak memory is
cap x per-snapshot size, not the wire budget, because a single undercounted argument
still allocates its full snapshot before reconcileWireBytes sheds it. The per-request
byte ceiling is a deferred follow-up; the comment no longer overclaims the bound.

From the Copilot review of #3989.

* fix(csc): snapshot invalidation fetch order once per push

The windowed invalidation batcher snapshotted cscFetchSeq inside enqueue, i.e. once
PER KEY. A push that invalidates several keys therefore stamped a different fetch-order
snapshot on each cscInvalItem, so a fetch reserved after the push was observed but
before the loop reached a later key was not treated as newer for that key: the
fetch-order guard (cscInvalItem.fetchSnap, checked at apply in collectHotAndDelete)
could then evict a fresh value or cancel an in-progress reservation on a straddling
duplicate. The inline (window==0) delete path already snapshotted once per push.

The push handler now takes fetchSnap := cscFetchSeq.Load() once, before the enqueue
loop, and passes it to enqueueAt, which stamps that shared snapshot on every key in
the push. enqueue keeps the per-call snapshot for single-key/test callers. Pinned by
TestInvalBatcherEnqueueAtStampsSuppliedFetchSnap.

From the review of #3989.

* docs(csc): correct the miss-coalescer serialize-bound comment

The cscMissMaxConcurrentSerialize comment claimed the concurrent set keeps peak memory
at O(budget) with at most a few batch-sized snapshots. That overclaims: reserveWireBytes
gates on cmdApproxBytes, which undercounts a variable-size argument, so a single command
whose encoded form far exceeds the estimate (e.g. an encoding.BinaryMarshaler returning
hundreds of MB) is fully serialized before reconcileWireBytes sheds it. The semaphore
bounds the COUNT of concurrent snapshots, not their bytes; the true worst-case transient
peak is cap x (largest single serialized command). A hard per-request byte ceiling
during writeCmd is a follow-up. Comment only.

From the review of #3989.

* fix(pool): keep relaxed timeout active while a holder survives

HasRelaxedTimeout did a single snapshot Load and returned false the moment a deadline
passed, even when a non-deadline (notification) holder was still active — contradicting
the surviving-holder semantics that getEffectiveReadTimeout already implements. Until an
Effective* call happened to run expireRelaxedTimeout, HasRelaxedTimeout reported no
relaxation while the connection was in fact still relaxed.

Retire the expired deadline holder once (a single CAS, never on the hot unexpired path)
and re-read the snapshot before deciding no relaxation remains, mirroring
getEffectiveReadTimeout. Only tests call HasRelaxedTimeout; there is no production
hot-path caller. Pinned by TestHasRelaxedTimeoutExpiresDeadlineAndReadsSurvivor.

This fixes the boolean; the timeout VALUE still comes from the single shared
(readNs, writeNs) pair (last-writer-wins), whose per-holder preservation is a separate
tracked follow-up.

From the review of #3989.

* fix(csc): apply the batcher fetch-order guard without refresh

When invalidation batching was enabled for a *LocalCache but refresh-on-invalidate was
off, the batcher's apply path discarded it.fetchSnap and did an unconditional
DeleteByRedisKey. A delayed invalidation whose fetch snapshot predated a refetch during
the batch window then deleted the newer value (or cancelled its in-progress fetch),
serving a spurious miss and possibly waking waiters into duplicate fetches.

Route the *LocalCache delete through the sequence-aware deleteByRedisKeyCollectingHot
with it.fetchSnap (discarding collected refresh targets, since refresh is off), so
fetchSeq > fetchSnap is honored exactly as on the inline and refresh-on paths. A
non-*LocalCache cache keeps the plain delete (no per-entry fetch sequence). Pinned by
TestInvalBatcherNoRefreshGuardKeepsRefetched.

From the review of #3989.

* fix(csc): abort an in-progress refresh flush when Close begins

A normal (timer/demand/window-full) refresh flush already running when Close arrived kept
its stopping=false snapshot for the whole run, so it ignored the now-closed h.stop and gave
each remaining chunk a fresh cscRefreshBatchTimeout. With reply budgeting producing
one-target chunks over a 512-key window, a stalled server could hold Close for tens of
minutes. The earlier F2 fix only bounded a flush ENTERED from the stop case.

Check h.stop between chunks on the non-stopping path and bail; the stopping==true stop-drain
already bounds itself by bailing after the first failed chunk. Bailed targets stay evicted
(a reader repopulates them; coverage is revoked on Close). Pinned by
TestCSCRefresherNormalFlushAbortsOnClose.

From the review of #3989.

* fix(csc): bound coalescer Close drain, guard inline invalidation

Two findings from the re-review that reached the miss-coalescer files, plus a
documented dispute:

- Close-time drain (csc_miss_coalesce_modes.go): drainBackstop renewed the wait on
  ANY per-interval progress, so a server trickling one reply per interval kept a deep
  in-flight pipeline (up to cscFullDuplexDepth) draining and blocked Close in wg.Wait
  for hours. Arm a total stopping-drain deadline once — cscMissStopDrainIntervals x
  interval — and close+return once spent, even while readsDone advances. Stated
  tradeoff: past the budget, already-accepted in-flight misses may be cancelled rather
  than completed (Close-never-wedges outranks completing the remainder). Non-stopping
  (age-out/handoff) recycle keeps the unbounded progress rule. drainBackstop is
  extracted to drainBackstopRun so the cap is unit-testable (pure move; the only
  in-body change is c.opt -> mc.c.opt).

- Inline invalidation (csc_integration.go): with refresh-on-invalidate OFF the inline
  delete ignored the fetch-order guard, so a delayed invalidation could cancel a miss
  reserved after this push was observed and wake coalesced waiters as duplicate misses.
  Route it through deleteByRedisKeyCollectingHot (cscInvalNoHorizon, fetchSnap), the
  mirror of the batcher's !canRefresh branch.

- batchBudget 5s floor: documented that the floor also anchors the drain stall interval
  (batchBudget + 1s), so it cannot be removed to honor short read timeouts without
  force-closing healthy conns; the read-deadline/stall-interval decouple is a follow-up.

Pinned by TestCSCMissStopDrainCapsTotalOnTrickle and
TestInvalidateHandlerInlineNoRefreshKeepsRefetched (both red-checked).

From the review of #3989.

* fix(csc): arm coalescer drain cap on a recycle-then-Close race

The round-6 stopping-drain cap only armed when drainBackstopRun was entered with
stopping==true. Age-out and handoff recycle enter with stopping==false, and the
normal-timeout main progress-wait did not watch mc.stop (only the deadline-less branch
did). So if Close arrived AFTER a recycle drain had already started, the supervisor
stayed on the unbounded progress path and a server trickling one reply per interval
could hang Close in wg.Wait for hours — the exact hang the cap was meant to prevent.

The main timer select now watches mc.stop on the non-stopping path (a nil channel once
stopping, so a closed mc.stop cannot busy-spin the select): a Close arriving mid-recycle
flips the drain to the stopping path and the next loop arms the total cap.

Pinned by TestCSCMissStopDrainCapsAfterCloseRaceInRecycle (ReadTimeout/WriteTimeout > 0
so the deadline-less branch is skipped and the drain sits in the main select, then
mc.stop is closed mid-drain; red-checked).

From the review of #3989.

* fix(csc): move single-key enqueue helper to tests

cscInvalBatcher.enqueue had no production caller: the push handler uses
enqueueAt with a fetch snapshot taken once per push, and enqueue was only a
single-key convenience for tests. golangci-lint's unused analyzer skips test
files, so it read the method as dead code and failed the lint job. Move it
verbatim into csc_inval_batch_test.go (same package); no behavior change.

* feat(csc): add ClientSideCacheRefreshRecencyWindow, default refresh-all

Options.ClientSideCacheRefreshRecencyWindow bounds
ClientSideCacheRefreshOnInvalidate to keys read within a configurable window
before their invalidation arrived. Default (0) changes to refreshing every
invalidated Valid entry regardless of recency; a positive value restores a
bounded lookback, rounded up to a guaranteed-covered multiple of the existing
200ms cscRefreshRecencyTick via a small ring of per-tick LRUClock()
snapshots (cscRecencyRing).

Tradeoff, documented on the option: refreshing re-registers a key with the
server tracking table, which manufactures the next invalidation - so the
new default turns a write-heavy, rarely-read key into a self-sustaining
refresh loop driven by write traffic until capacity eviction reaps it. The
window option is how to bound that.

cscInvalNoHorizon (-1) is reused as the "refresh everything" sentinel: it
already sits below any real lastAccessNs token, so the unchanged
deleteByRedisKeyCollectingHot comparison marks every entry hot with no
change to that function. Wired through UniversalOptions.Simple() alongside
the sibling CSC knobs.

Tests: TestCollectHotAndDeleteNoHorizonRefreshesEverything,
TestCscRecencyRingOldest, TestCscRefreshWindowTicks,
TestStartCSCRefresherRecencyWindowWiring, extended
TestUniversalOptionsSimpleCopiesCSCCoalesce.

* fix(csc): skip stop-drain refetch, guard worker teardown race

Two codex findings on #3989.

P1 (csc_refresh_on_invalidate.go:655) — Close's refresher stop-drain
used to refetch every buffered invalidated key over the network,
bailing only after the first FAILED chunk (#3965 F2). Against a
healthy server nothing fails, so that bound never triggered: a full
backlog could cost thousands of round trips, one per chunk once the
reply-byte budget shrinks each chunk to a single target. Those round
trips were always wasted — stopCSCRefresherAndCoalescer's teardown
already documented (csc_integration.go:1305-1316, pre-existing) that
invalidateAllCoverage evicts every entry this connection would have
published immediately after this flush returns, on every teardown path
(Close and both self-disable exits share the one deferred cleanup that
runs stopCSCRefresherAndCoalescer before invalidateAllCoverage). This
finishes that conclusion: flush() now skips the refetch entirely when
stopping, abandoning the buffered targets (they stay evicted; a reader
repopulates them, same outcome an errored refetch already produced).
The now-dead per-chunk stopping branches, the flush return value
(never a live signal to any caller once the skip is unconditional),
and the single-pass drain's window-cap-only shortfall are all folded
in as part of the same change: the h.stop case now loops until q.ch is
fully drained rather than leaving up to ~3584 buffered targets pinned
in the channel, since c.cscRefreshQueue is never nilled on stop.
RefreshFailed is intentionally left untouched by the skip: its doc
scopes it to errored round trips, and an abandoned-by-choice target
never attempted one.

Test: TestCSCRefresherStopDrainSkipsRefetchEntirely, replacing
TestCSCRefresherStopDrainBailsOnFirstFailure. Verified against the true
pre-fix baseline (not just the changed line): reverting to the
pre-existing code makes the erroring pooler's Get run once before the
old bail-on-first-failure logic kicks in, which the test catches.

P2 (csc_integration.go:709) — attachSharedTrackingCSC starts the
drainer before startCSCRefresher/startCSCMissCoalescer publish their
queue/handle. In principle the drainer's self-disable tick (reacting
to an async conn init's disableCSCServing) could observe cscActive
false and run stopCSCRefresherAndCoalescer, consuming workersStopOnce,
before either start function publishes — leaving that worker with no
remaining path to be stopped. This interleaving is not reachable
today: cscMinDrainInterval floors the first tick at 1ms against
synchronous, microsecond-scale construction, and nothing external
holds a reference to call Close mid-construction. That is a
timing-dependent invariant, not a structural one, so replace it with
an explicit mechanism instead of only documenting it: cscDrainHandle
gains workersMu/workersTornDown. stopCSCRefresherAndCoalescer's
teardown body sets workersTornDown=true as its first action under the
lock; each start function checks the same flag under the same lock
immediately before publishing and declines to start (launching
nothing) if teardown already ran. startBackgroundDrainer needs no
matching guard: it is what allocates cscDrainHandle, so no teardown
path can observe a non-nil handle to race against until after it
returns.

Rejected fixing this by reordering attachSharedTrackingCSC to start
the workers before the drainer, per codex's literal suggestion: that
would leave the workers unstoppable by Close if anything panicked
between the reordered start calls and the drainer's construction,
since stopBackgroundDrainer no-ops on a nil cscDrainHandle. The
current order's panic-safety property is worth keeping; the lock-based
guard closes the race without touching it.

Tests: TestCSCConstructionRaceDeclinesToStartAfterTeardown (drives
workersTornDown directly, independent of cscActive; verified to fail
if either start function's guard is reverted),
TestCSCConstructionWinsRaceStartsNormally (control),
TestCSCStopCSCRefresherAndCoalescerSetsWorkersTornDown (plumbing).

* fix(csc): widen worker-publish lock, scope limiter denial to the waker

Two cursor Bugbot findings on e2a103e5.

High: "Teardown joins unstarted CSC workers" — the workersTornDown
check in startCSCRefresher/startCSCMissCoalescer only covered the
initial field write (cscRefreshHandle / cscMissCoalescer), not the
rest of publish. startCSCRefresher still had setRefreshQueue's
predecessor-batcher join after the write, and startCSCMissCoalescer
still had its wg.Add loop after the Store. A concurrent
stopCSCRefresherAndCoalescer (the drainer's own self-disable tick,
reacting to an async conn init's disableCSCServing) could read the
just-published handle/coalescer and act on it before either had
actually finished starting: stopCSCRefresher would block on <-h.done
for a runCSCRefresher goroutine not yet launched (bounded by however
long the join took, not a permanent deadlock, but pointless to allow),
and stopCSCMissCoalescer's wg.Wait() could run concurrently with an
in-progress wg.Add — the sync.WaitGroup misuse case, which can panic.

Fix: both start functions now run their entire publish step — the
field write, the queue/batcher attach (refresher) or the wg.Add/go loop
(coalescer), and the goroutine launch — inside the SAME workersMu
critical section that stopCSCRefresherAndCoalescer's teardown body
acquires as its first action. Teardown cannot read a published
handle/coalescer until publish has fully finished (goroutines running),
because it cannot even acquire the lock until then. Holding the lock
across setRefreshQueue's batcher join is safe: that path is
internal/panic-free (lookupInvalidateHandler resolves to the built-in
push processor's plain registry lookup, never user code, since CSC
requires the built-in processor) and never re-enters workersMu.

Test: TestCSCStartRefresherHoldsLockAcrossBatcherJoin installs a
controlled predecessor batcher (no run() goroutine backing it, so its
join blocks until released) and verifies workersMu stays held —
observed via TryLock, which never blocks, so the check can't deadlock
on the lock it's inspecting — for as long as startCSCRefresher is
parked in that join, even with a concurrent teardown racing it.
Verified against e2a103e5 directly: the test correctly fails there,
since that version's lock only spans the field write. Covers the
refresher path only; the coalescer's wg.Add window gets the same fix
but has no separate test — there is no equivalent controllable
blocking hook in its publish sequence.

Medium: "Limiter denial fails queued coalesced misses" — when
Limiter.Allow rejected the miss-coalescer session's getConn, every
request already queued behind the one that triggered the check
(first) was failed with that same raw, non-retryable error
(drainQueuePlain). Those queued requests never called Allow
themselves; only first did. The existing rationale (avoid a second
Allow call that could admit the very operation just denied) is
correct for first but does not extend to requests the limiter was
never asked about.

Fix: first still gets the raw, untagged error (settlePlain, unchanged).
Every other already-queued request now gets errCSCRetryUncached
(drainQueueErr) instead, so processCached re-runs each on the ordinary
per-command path and each calls Allow independently — the same
handling this engine already uses for a session torn down mid-fetch.
drainQueuePlain is now unused and removed.

Test: TestCSCMissLimiterDenialOnlyFailsWakingRequestPlainly drives
runFullDuplexSession directly with a rejecting Limiter and one queued
follow-up request, asserting first gets the raw sentinel and the
queued request gets errCSCRetryUncached, with Allow called exactly
once. Verified to fail against the prior drainQueuePlain behavior.

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: sadd amr3e <saddamr3e@gmail.com>
Co-authored-by: Albert Huynh <huynhalb@gmail.com>
Co-authored-by: Vladislav Kotsev <vladygk8@gmail.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-11 11:36:13 +03:00
Nedyalko Dyakov 8010edc761 fix(client): don't leak pools on NewClient panic (#4003)
* fix(client): don't leak pools on NewClient panic

NewClient creates the connection pools, then runs later steps that can panic:
maintnotifications in ModeEnabled failing, or a custom push processor rejecting
handler registration. The panic propagates to the caller (some, e.g. MultiDB
AddDatabase, recover it), but the client is never returned, so the pools and
their pooled connections leak with no reference left to close them.

Add a deferred cleanup that closes the partially-built client if construction
panics before NewClient returns. closeResources is nil-safe for a partial
client, and the defer does not recover, so the panic still propagates. On the
normal path the guard is set before returning and the defer is a no-op.

* test(client): deflake NewClient panic-cleanup test

The test asserted the pool dialed at least once before checking every
dialed conn was closed. The min-idle dial runs in a pool goroutine, and
the deferred Close in NewClient can win the race: dialConn then sees the
closed pool and never calls the dialer, so opened=0 and the assertion
fails even though nothing leaked. Failed on 6/8 CI matrix jobs and 5/30
local -race runs.

Make the panic trigger (RegisterHandler) wait for the dialer's first call
before panicking, so the leak scenario is what actually runs. Keep a 5s
fallback so a missing dial still fails loudly instead of hanging.

* fix(client): skip typed-nil pool in panic cleanup

The panic-cleanup defer added for the partial-client leak called c.Close() even
when newConnPool/newPubSubPool returned a nil pool plus an error: assigning
that straight to the pool.Pooler field left a typed-nil interface, so
closeResources treated it as present (its != nil check passes) and
ConnPool.Close nil-dereferenced — replacing the original construction panic
with a nil-pointer panic, visible to callers that recover from NewClient (e.g.
MultiDB AddDatabase).

Assign the pool fields only after the error check, via a local var, so a failed
constructor leaves the field nil (untyped) and cleanup skips it. The pipeline
pool already followed this pattern.
2026-09-03 15:38:05 +03:00
Nedyalko Dyakov 32c371dffc fix(autopipeline): add WaitClosed; keep concurrent Close non-blocking (#3998)
AutoPipeliner.Close claims the shutdown with a CAS and drains the already-
accepted batches. A concurrent Close that loses the CAS returns nil immediately,
which by itself lets a wrapping client (e.g. MultiDBClient.Close) observe
"closed" and tear down shared connection pools while the winning Close is still
flushing accepted commands.

Making the loser BLOCK on the drain would instead self-wait: a re-entrant Close
from an in-flight dispatch (a batch's hook calling Close on its own executor
goroutine) runs on a goroutine the winner's drain is waiting for, so it would
deadlock until the ~30s backstop.

So keep Close's loser returning immediately (no self-wait, no regression) and
add WaitClosed(): it blocks until the winning Close's drain completes and
returns that drain's result. A caller that must not act on "closed" until
accepted commands are flushed calls Close and then WaitClosed.

This change is additive (new closeDone/closeErr fields, the winner signaling
them, and WaitClosed). The wrapping-client wiring — MultiDBClient.Close calling
p.Close(); p.WaitClosed() before core.close() — is a follow-up once this lands
on master and the MultiDB stack rebases onto it; until then this PR provides the
mechanism but MultiDBClient does not yet call it.

Test: a losing Close returns immediately; WaitClosed blocks until the drain
signals closeDone and returns its result.
2026-09-02 17:23:47 +03:00
Nedyalko Dyakov 2b238395c4 fix(pool): run conn teardown even when already CLOSED (#3985)
* fix(pool): run conn teardown even when already CLOSED

initConn marks a rejected connection CLOSED to report an init/auth
failure before any teardown runs. Conn.Close early-returned on CLOSED,
so the pool's subsequent close leaked the socket and skipped the
streaming-credentials unsubscribe and CSC close callbacks. Repeated
failures accumulated open file descriptors until GC.

Fall through to cleanup regardless of state: callbacks are cleared with
an atomic swap (exactly once) and net.Conn.Close is double-close safe.

Closes #3982

* fix(pool): close conn transport exactly once

Review follow-up for #3985. Falling through to net.Conn.Close on an
already-CLOSED connection meant a second or concurrent Close re-closed
the socket and, on real transports, returned 'use of closed network
connection' — a spurious error the pool's close paths (e.g.
closeConnsIf) would propagate, where the pre-fix code returned nil.

Claim the transport teardown with an atomic CAS so the socket is closed
exactly once and repeat/concurrent closes return nil. The claim can
never be stale: the socket is only replaced by SetNetConnAndInitConn,
which gates on non-CLOSED states, so no replacement can follow a Close.

Tests assert the transport closes exactly once and that repeat Close
returns nil (recording net.Conn now errors on the second close, like a
real socket).

* fix(pool): close transport per socket generation

The previous fix guarded net.Conn.Close with a per-Conn transportClosed
flag. That flag is claimed once for the lifetime of the Conn, but the
socket can be replaced afterwards: a Close racing SetNetConnAndInitConn
in the INITIALIZING window closes the original socket and sets the flag,
then init stores a replacement socket and unconditionally transitions
the conn back to IDLE (Transition is a plain Store). Every later Close
then fails the claim and never closes the replacement socket, leaking an
FD under the same race TestConnOnCloseRaceWithInitConn exercises.

Bind teardown to the transport generation instead: Close atomically
swaps the current socket out of netConnAtomic and closes exactly what it
took. Each stored socket is closed by the one caller that swaps it out;
a socket a concurrent handoff installs afterwards is a fresh generation
closed by the next Close. Repeat/concurrent closes swap out a sentinel
and return nil, preserving the no-double-close-error contract.

Adds a deterministic regression test for the replaced-socket case.

The resurrection race itself (a Close during INITIALIZING overwritten by
Transition(StateIdle)) is pre-existing and out of scope here; this change
makes it not leak, not correct.

* fix(pool): keep closed transport retrievable after Close

The previous revision swapped a nil-conn sentinel into netConnAtomic on
Close to bind teardown to the socket generation. That made getNetConn
return nil after Close, changing a pre-existing contract: Conn.RemoteAddr
and the connCheck health path then dereferenced nil. Sentinel's Filter
closes an in-use conn while leaving it in the pool and later calls
cn.RemoteAddr().String(); ConnPool.isHealthyConn calls
connCheck(cn.getNetConn()). Both could panic.

Bind teardown to the transport generation without clearing it: add a
per-wrapper closed flag (atomicNetConn) that Close CAS-claims before
calling conn.Close. The wrapper (and its closed conn) stays in place, so
getNetConn keeps returning it and RemoteAddr/LocalAddr/connCheck behave
as before. Each socket is still closed exactly once, a handoff installs a
fresh wrapper so a replacement socket is a new generation closed by the
next Close, and repeat/concurrent closes lose the CAS and return nil (no
spurious double-close error).

Adds TestConn_GetNetConnSurvivesClose for the restored contract; the
existing generation and double-close tests still pass.
2026-09-02 17:23:26 +03:00
Nedyalko Dyakov c5cad058c7 test(tests): TCP-probe before Ping in skip gates (#4001)
Tests that skip when Redis is absent gated on a client Ping to a
hardcoded/helper address. A Ping to an addr with nothing listening does
not fail fast: it pays the dial-retry (DialerRetries, default 5) plus
command-retry budget first, ~1.7s per call. A recent RE integration
nightly spent ~1.6 min across 68 such skips.

Probe the address with a short-timeout TCP dial first, which fails in
microseconds on connection-refused, then keep the existing Ping so a
reachable-but-unusable server still skips rather than proceeds.
2026-08-31 14:18:10 +03:00
Nedyalko Dyakov 911fad2994 fix(pipeline): remove duplicate getPipelinePool (#3987)
* fix(pipeline): remove duplicate getPipelinePool

PR #3959 branched before sibling #3962 merged; the merge kept both
copies of getPipelinePool, and #3962's copy returns *pipelinePoolRef,
which no longer satisfies pool.Pooler after #3959's field refactor.

Drop the stale duplicate and adapt #3962's getter test: compare
against pipelinePool.pool, and reach the nil path via the new
always-on opt-out (PipelinePoolSize: -1).

* test(command): warm pipeline pool before slowlog count

The always-on pipeline pool (PR #3959) dials lazily on the first
autopipelined flush, so its HELLO + CLIENT SETINFO handshake is
recorded as slow queries once slowlog-log-slower-than is 0, inflating
the counted total from 2 to 5 on the autopipeline-subjects run. Warm
the pipeline-pool connection before lowering the threshold. The
failure was only reachable after the master compile break was fixed.
2026-08-24 16:21:20 +03:00
Nedyalko Dyakovandofekshenawa bd0cea4ea8 chore(pipeline): Create pipeline pool even without specific buffer passed (#3959)
* fix(pipeline): PipelinePoolSize alone should create the pipeline pool

The dedicated pipeline connection pool was created only when
PipelineReadBufferSize or PipelineWriteBufferSize was set. Setting just
PipelinePoolSize therefore produced no pipeline pool at all, with no error and
no log line, and every pipelined operation silently kept using the main
connection pool — competing with ordinary commands for the same connections,
which is the opposite of what the option asks for.

The gating was inverted. The pool size is the primary intent ("give pipelines
their own N connections"); the buffer sizes are tuning. And the buffer sizes
already supplied a default for the size (10), so the dependency only ran one
way.

Any of the three options now creates the pool. Enabling it by size inherits the
regular ReadBufferSize/WriteBufferSize, so a caller who only wants to bound
pipeline connections no longer has to know that a buffer size is secretly
required.

Found while measuring client-side caching over a high-latency link: the CSC
background batched reads go through withPipelineConn, which falls back to the
regular pool when none exists, so they were contending with the very reader
misses they exist to prevent.

Tests cover all four option combinations and were verified to fail with the
gate reverted.

* feat(pipeline): give autopipelined clients a dedicated pipeline pool by default

Autopipelining is pipeline-heavy by definition, yet the dedicated pipeline
pool still had to be enabled separately; without it every batch competes
with regular commands for main-pool connections. Two paths now default it in:

Config time: Options.init defaults PipelinePoolSize to DefaultPipelinePoolSize
(new exported const, 10) when AutoPipelineOptions is set. Cluster node clients
do not inherit AutoPipelineOptions (the cluster autopipeliner lives at cluster
level and dispatches into the node clients' pipeline hooks), so clientOptions
applies the same default there. Sentinel and failover node clients inherit
AutoPipelineOptions and are covered by Options.init; Ring does not support
autopipelining. A negative PipelinePoolSize opts out explicitly.

Runtime: the common pattern configures the autopipeliner after NewClient
(AutoPipelineWithOptions on a plain client), where pool creation has already
happened. ensurePipelinePool now builds the pool lazily when an autopipeliner
is created. baseClient.pipelinePool becomes a pointer to an atomic slot
(pool + pool-name ref) so the lazy publication is race-free against
concurrent Pipelined callers on the hot path, and WithTimeout clones share
the slot — two sharers cannot install two different pools (CAS; the loser
closes its candidate). Streaming-credentials and maintnotifications hooks are
wired before publication; otel registration happens for the winner. Conn, Tx
and SentinelClient never allocate the slot and keep using the main pool.

The extraction into buildPipelinePool also surfaced that NewFailoverClient
kept a duplicated creation block with the pre-fix gate (buffer sizes only),
so PipelinePoolSize alone silently created no pipeline pool on failover
clients; the shared helper fixes that, with a regression test. Stale
RingOptions/FailoverOptions doc lines still describing the old gate are
corrected.

* feat(pipeline): 64KiB default buffers and no pre-dial for the pipeline pool

The options the dedicated pipeline pool is built with are now resolved by
pipelinePoolOptions, a pure function with pinned tests:

Buffers default to DefaultPipelineBufferSize (new exported const, 64 KiB)
rather than inheriting the regular 32 KiB. Pipeline connections move whole
batches per round trip and earn bigger buffers than per-command traffic:
measured on the autopipeline engine, throughput plateaus around 64 KiB,
gains nothing past ~128 KiB, and very large buffers can regress it. An
explicitly larger regular buffer is kept rather than shrunk, and explicit
pipeline buffer sizes always win. The RESP3 minimum clamp applies as on the
main pool.

MinIdleConns is forced to 0 on the pipeline pool. It previously inherited
the main pool's value through the options clone, so a client with
MinIdleConns set pre-dialed that many pipeline connections at pool creation
— a silent doubling of the idle footprint. The pipeline pool is burst
capacity: its connections dial on demand, and the pool size
(PipelinePoolSize or DefaultPipelinePoolSize) is only a cap, so an unused
pipeline pool holds zero connections.

* test: make pipeline-conn specs aware of the dedicated pipeline pool

The autopipeline-subjects job builds its suite subject by calling
AutoPipelineWithOptions on the shared rawClient, which now (correctly)
creates the dedicated pipeline pool lazily - so client.Pipelined and
client.Pipeline run on pipeline-pool connections, not the main pool's.
Two specs assumed the old sharing:

should Auth asserted main-pool stats (Hits=2/Misses=1/TotalConns=1) after
two Pipelined calls. It now branches on PoolStats.PipelineStats: when the
dedicated pool exists, the two pipelines account there (first dials,
second reuses) and the main pool holds only the BeforeEach FlushDB dial;
in the default subject mode PipelineStats is nil and the original
assertions run unchanged.

should ClientSetInfo issued CLIENT SETINFO through a pipeline and then read
CLIENT INFO through rawClient - with a pipeline pool those are different
connections, and SETINFO is per-connection state. It now reads CLIENT INFO
through the same pipeline that issued the SETINFOs, which is equivalent on
a single-pool client and correct on a split-pool one.

Verified against redis:8 in all three GOREDIS_TEST_SUBJECT modes
(default, ap-blocking, ap-async): Ran 2 of 1543 Specs, 2 Passed.

* test: warm the pipeline-pool conn in AP tests that instrument connections

Two autopipeline unit tests assumed batches execute on the main pool's
already-initialized connection; with the dedicated pipeline pool now
created lazily at autopipeliner creation, a batch's first dispatch dials
and initializes a fresh pipeline-pool connection instead. Both tests now
warm that connection with a real two-command batch first (a lone command
takes the solo fast path through Process on the main pool and would not
touch the pipeline pool):

TestAutoPipelineRetriesOnNetworkError counted the pipeline pool's first
dial as if it were a retry redial (3 dials observed, 2 expected). The
warm-up runs before the write failure is armed, so the count again
measures exactly the one redial the retry performs.

TestAutoPipelineHookPostNextErrorPartialBatch installed a hook that
unconditionally injects a post-next error before the pipeline-pool
connection existed. Connection init runs its handshake pipeline through
the client's hook chain (newConn shares hooksMixin), so the hook failed
the pipeline conn's init itself — retries exhausted, the injected error
was stamped on every command, and the test measured init poisoning
rather than the post-next outcome rule it pins. The warm-up initializes
the connection before the hook is installed.

* docs: TODO(v10) — ClientTracking* and ClientMaintNotifications are stateful

CLIENT TRACKING and CLIENT MAINT_NOTIFICATIONS mutate per-connection
state, exactly like CLIENT SETNAME/SETINFO, but live on cmdable: on a
pooled client they land on an arbitrary pool connection that is
immediately returned to the pool, so the flag applies to a connection
the caller cannot address again. They belong on statefulCmdable, next
to ClientSetName — but the move removes the methods from
Client/UniversalClient/Cmdable (and defining them on both cmdable and
statefulCmdable makes every Pipeline selector ambiguous), so it is a
breaking change deferred to v10. Documented now; the move happens in a
follow-up PR. No behavior change.

* docs: drop the TODO(v10) notes on the per-connection commands

The ClientTracking*/ClientMaintNotifications move to statefulCmdable ships
as its own PR against master (feature/stateful-conn-commands) rather than
waiting for v10, so the TODO notes here are obsolete. Docs only.

* feat(pipeline): always create the pipeline pool; spill on exhaustion

The dedicated pipeline pool is now created unconditionally at
NewClient/NewFailoverClient — like the pubsub pool — instead of behind a
matrix of triggers (pipeline options set, AutoPipelineOptions declared,
or lazily when an autopipeliner was built). It is pure burst capacity:
no pre-dialing (MinIdleConns forced to 0), a small cap
(DefaultPipelinePoolSize) and 64 KiB buffers, so an unused pipeline pool
holds zero connections and costs nothing, while pipelines stop competing
with regular commands for main-pool connections. PipelinePoolSize < 0
opts out and restores the old pipelines-on-the-main-pool behavior.

To keep this from capping heavy concurrent Pipelined callers — who
previously shared the much larger main pool — withPipelineConn spills to
the main pool when the pipeline pool is exhausted (ErrPoolTimeout):
bursts wider than the cap keep their old capacity, spilled pipelines
simply run with regular buffers, and total connections stay bounded by
PoolSize + PipelinePoolSize.

Pipeline-pool connections are excluded from CLIENT TRACKING during init.
Pipelined commands never consult or populate the built-in client-side
cache (only the single-command cached path on main-pool connections
does), so tracking pipeline reads only grew the server's tracking table
and produced invalidation pushes for keys the cache does not hold.

With creation unconditional, the lazy machinery is deleted: the atomic
slot and CAS publication, ensurePipelinePool, the autopipeliner-factory
hooks, the Options.init AutoPipelineOptions defaulting and its cluster
clientOptions mirror. pipelinePool is a plain field set before the
client is visible to any goroutine; WithTimeout clones share it. Net
diff of this commit is negative.

Tests: the creation-rule table becomes always-on plus opt-out; new e2e
coverage for the spill (a pipeline pool of one, held busy by a pipelined
BLPOP, must not queue a second pipeline behind PoolTimeout) and for the
tracking exclusion (CLIENT LIST shows the main-pool connection tracked
and the pipeline-pool connection untracked).

* test: keep HImport mock choreography on the main pool; doc the pool default

The HImport mock tests choreograph exact per-connection sequences — a
pool of one, booms armed to fire on the Nth replayed PREPARE, session
counts pinned per connection. The always-on pipeline pool adds a second
connection whose init replays fieldsets (correct production behavior:
HIMPORT pipelines work on pipeline-pool connections precisely because of
that replay), which fired the armed booms during the pipeline conn's
init instead of the reissue under test and shifted the session counts.
Opt every client in the file out with PipelinePoolSize: -1: the tests
pin HIMPORT reissue/session semantics, not pool routing.

Also states the PipelinePoolSize default plainly in the option docs:
DefaultPipelinePoolSize (10) connections (review feedback).

* test: skip conn-init handshakes in the cluster shard pipeline hooks

The shard-level pipeline hooks in the cluster hook-order specs asserted
Expect(cmds).To(HaveLen(...)) unconditionally. With the always-on
pipeline pool, a node's first pipeline dials a pipeline-pool connection
whose init runs its handshake pipeline through the node client's hook
chain, so the assertion fired on the handshake and the resulting Ginkgo
panic aborted the whole suite. Guard both hooks with the idiom
ring_test.go already uses for the same situation: defer GinkgoRecover()
and pass connection-initialization pipelines (hello/client) through.
The cluster-level hooks are untouched — node connection init never runs
through the cluster client's chain.

* test: adapt plain-pipeline tests to the always-on pipeline pool

TestNoPipelinePoolStats pinned the stats shape of a client without a
pipeline pool by creating a client without pipeline options — a premise
the always-on pool inverts. Having no pool now requires the explicit
opt-out, so the test sets PipelinePoolSize: -1, which is exactly the
configuration whose stats shape it verifies.

TestPipelineRetriesOnNetworkError is the plain-pipeline twin of the AP
dial-count test adapted earlier: pipelines now run on the dedicated
pipeline pool, so the first Exec dialed a fresh pipeline-pool connection
and the test counted it as a phantom retry redial. Warm the pipeline
connection with one Pipelined round trip before arming the write
failure, so the count again measures exactly the one redial the retry
performs.

* fix(pipeline): address #3959 review (spill, caps, UniversalOptions)

- UniversalOptions now exposes PipelineReadBufferSize / PipelineWriteBufferSize /
  PipelinePoolSize and maps them through Cluster()/Failover()/Simple(), so
  UniversalClient users can size or opt out (PipelinePoolSize < 0) of the now
  always-created pipeline pool (ofek's CHANGES_REQUESTED blocker).
- withPipelineConn no longer double-counts the Limiter on spill: it acquires the
  main-pool conn directly (not via withConn) and records which pool owns the
  conn, so the single deferred ReportResult+release runs once, in order (cursor
  HIGH).
- The pipeline pool no longer inherits MaxActiveConns (reset to 0, so the ceiling
  is MaxActiveConns + PipelinePoolSize, not ~2x) and uses a short
  DefaultPipelinePoolTimeout so a saturated pipeline pool spills promptly instead
  of queueing; spill also triggers on ErrPoolExhausted (defensive) and the doc is
  corrected. Stale "lazy ensurePipelinePool" comments reworded (eager, set-once).

Adds tests: spill accounts the Limiter exactly once, the clone's cap/timeout
overrides, and the UniversalOptions passthrough.

* fix(pipeline): review follow-ups (docs, de-flake, init-spill, stats)

- Options/FailoverOptions/RingOptions docs: pipeline pool is created by default
  (opt out PipelinePoolSize < 0); buffers default to max(regular, 64 KiB).
- pipeline_pool_gate_test.go: poll PoolStats instead of a fixed Sleep (de-flake).
- withPipelineConn now spills to the main pool on a pipeline-conn INIT failure
  too (e.g. a fresh dial refused with maxclients), not just Get saturation;
  non-saturation Get errors (ctx cancelled / pool closed) still do not spill.
- ClusterClient.PoolStats / Ring.PoolStats aggregate each node/shard pipeline
  pool into PoolStats.PipelineStats.

* fix(pipeline): review follow-ups (docs, de-flake, init-spill, stats)

- Options/FailoverOptions/RingOptions docs: pipeline pool is created by default
  (opt out PipelinePoolSize < 0); buffers default to max(regular, 64 KiB).
- pipeline_pool_gate_test.go: poll PoolStats instead of a fixed Sleep (de-flake).
- withPipelineConn now spills to the main pool on a pipeline-conn INIT failure
  too (e.g. a fresh dial refused with maxclients), not just Get saturation;
  non-saturation Get errors (ctx cancelled / pool closed) still do not spill.
- ClusterClient.PoolStats / Ring.PoolStats aggregate each node/shard pipeline
  pool into PoolStats.PipelineStats.
- Ring.PoolStats now folds WaitCount/WaitDurationNs/StaleConns into both the
  main-pool and pipeline-pool accumulators, matching the cluster fold (were
  dropped before).
- pipelinePoolOptions caps the pipeline PoolTimeout at DefaultPipelinePoolTimeout
  but honors a caller's SHORTER PoolTimeout (min), so a client tuned to spill
  faster is not forced to wait the full default before falling back.

* fix(pipeline): accept pipeline pool settings in URL options

ParseURL/ParseClusterURL/ParseFailoverURL now consume pipeline_pool_size,
pipeline_read_buffer_size and pipeline_write_buffer_size, so a URL such as
redis://host:6379?pipeline_pool_size=-1 applies the documented opt-out instead of
being rejected as an unexpected option. Adds TestParseURLPipelinePoolOptions.

* ci(govulncheck): use stable Go to pick up security patches

setup-go's "1.26.x" resolved to go1.26.5, which govulncheck flags for two
standard-library vulnerabilities fixed in go1.26.6: GO-2026-6090 (crypto/tls)
and GO-2026-5972 (encoding/asn1). Track the latest stable toolchain so future
security patches are picked up automatically instead of pinning a patch.

* fix(pool): size maintnotif defaults for the pipeline pool too

Handoff worker and queue defaults were derived from PoolSize alone, but
the manager hooks the dedicated pipeline pool as well, so its
connections competed for capacity sized as if only the main pool
existed. Options.init now derives the defaults from the combined
ceiling (PoolSize + effective PipelinePoolSize) and widens the
MaxActiveConns bound the same way, since the pipeline pool sits outside
it.

---------

Co-authored-by: ofekshenawa <104765379+ofekshenawa@users.noreply.github.com>
2026-08-24 14:42:40 +03:00
Nedyalko Dyakovandofekshenawa 3f417e77fe perf(autopipeline): better pool utilization on stranglers - *Client (#3962)
* perf(autopipeline): bound the straggler-hold when the pipeline pool has a free connection

The async autopipeline held 1-3 queued "straggler" commands until the
in-flight batch's reply landed (~1 RTT) whenever any batch was executing,
so on a slow link an uncached command that enqueued behind an in-flight
batch paid ~2x RTT. A phase trace on a deterministic 50ms link pinned it:
service (round trip) was clean at 1 RTT and the concurrency permits were
never exhausted, but the straggler-hold re-armed for a full RTT (uncached
p95 stuck at 107ms / 2x RTT at low-to-mid concurrency).

Bound that hold to a few silence gaps (stragglerHoldGaps * gap, which
tracks the round trip) ONLY when the pipeline pool has a connection to
spare (an idle conn, or room to dial — the pipeline pool runs
MinIdleConns=0). When the pool is saturated, keep the original long hold:
flushing tiny batches into a full pool thrashes it because they cannot
coalesce into the deep pipelines the scarce connections need (an
unconditional few-ms bound cut throughput ~7x at a squeezed pool). The
30s autoPipelinePermitBackstop remains the flush-path safety ceiling.

The pipeline pool is captured once at construction via an in-package
assertion and is nil for clients without one (e.g. *ClusterClient), which
then keep the prior long hold.

Measured (50ms proxy, churn, inflight 1): default pool uncached p95/p99
111->65ms; squeezed pool (pipeline-pool 2, uncached-heavy) no throughput
regression; real WAN uncached p99 314->177ms (residual is link jitter,
present on the untouched cached path too).

* perf(autopipeline): add getPipelinePool accessor so the straggler gate is live

The straggler-hold pool gate reaches the pipeline pool via an in-package
interface assertion (getPipelinePool() pool.Pooler) in newAutoPipeliner. On
master that accessor does not exist, so the assertion fails silently:
ap.pipelinePool stays nil, pipelineHasFreeConn() always returns false, and the
gate degrades to the conservative long hold — the fix compiles and passes CI
while doing nothing. Add the accessor (returns the baseClient pipeline pool) so
the assertion succeeds and the gate actually engages.

Tests assert the AutoPipeliner captures a non-nil pool (gate live) and the safe
nil fallback when no pipeline pool is configured.

Note: PR #3959 introduces its own getPipelinePool over a pipelinePoolRef
refactor; when both land keep one definition (the #3959 ref-based form) and drop
this plain accessor.

* fix(autopipeline): honor MaxActiveConns in straggler-hold gate

pipelineHasFreeConn() used `IdleLen()>0 || Len()<Size()`, which ignores
MaxActiveConns: a pipeline pool with MaxActiveConns < PoolSize and no idle
connection reported free even though the flush's Get would hit
ErrPoolExhausted, so the straggler-hold shortened its hold into a pool it could
not actually acquire from (ofek's named change-request blocker; codex #3962).

Add ConnPool.HasFreeCapacity(): it keeps the existing idle / dial-able terms and
adds the one the heuristic missed -- MaxActiveConns hard-caps dials on poolSize
(newConn returns ErrPoolExhausted at poolSize >= MaxActiveConns). The gate
prefers this probe (every *ConnPool implements it) and keeps the old heuristic
as a fallback for any other Pooler, so with MaxActiveConns == 0 the result is
unchanged.

Adds internal/pool tests for the max-active-saturated and no-max-active branches.

* fix(autopipeline): tighten straggler gate; route solo via pipeline pool

- HasFreeCapacity now counts only USABLE idle conns (usableIdleLen) and requires
  a free pool turn (new FastSemaphore.Available(), which accounts for in-flight
  dials) before declaring capacity. Adds an unusable-idle test.
- The solo-straggler flush now dispatches via processPipeline (one-command
  pipeline) on the pipeline pool, so it uses the same pool the gate probes;
  processPipeline falls back to the main pool when none exists (no-op for default
  clients). The solo goroutine dispatch (the 2xRTT phase-lock fix) is unchanged.

* fix(autopipeline): tighten free-capacity gate; keep CSC on solo flush

- HasFreeCapacity: when a dial is required, also require a free dial slot
  (MaxConcurrentDials below PoolSize can leave all slots busy while a turn is
  free); and usableIdleLen excludes idle conns marked ShouldHandoff (an OnGet
  hook would divert them). Adds TestHasFreeCapacityExcludesHandoffIdle.
- Solo autopipeline flush routes a cacheable command through the single-command
  Process path so client-side caching is honored (processPipeline bypassed the
  processCached branch, dropping CSC hits for one-command flushes); non-cacheable
  solos still dispatch on the pipeline pool.

* fix(autopipeline): keep non-CSC cacheable solos on the pipeline pool

A cacheable solo straggler was routed through Process (main pool) whenever the
command type was cacheable, even with client-side caching off (the default) —
so it ignored the dedicated pipeline pool the straggler gate probed and could
contend on a saturated main pool while pipeline capacity sat free. Gate that
routing on cscEnabled (captured at construction): only a CSC client sends a
cacheable solo through the cache-honoring path; otherwise it dispatches on the
pipeline pool.

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

* chore(autopipeline): compact review-round comments

Comment-only: HasFreeCapacity/usableIdleLen docs reduced to their check
invariants; the cacheable-solo CSC gate comments deduplicated into the
cscEnabled field doc; test headers condensed.

* fix(autopipeline): breaker-aware capacity; live CSC solo gate

- HasFreeCapacity reports no capacity while the dial circuit breaker is open
  (dialErrorsNum >= PoolSize, dialConn's fail-fast condition): room under
  PoolSize is not dialable capacity when every dial errors immediately, so the
  straggler gate keeps the conservative hold instead of flushing into a
  failing dial path.
- The cacheable-solo CSC gate is live (the client's autopipelineCSCActive func,
  consulted per dispatch) instead of a construction-time bool: when CSC
  disables itself mid-life, cacheable solos return to the pipeline pool. The
  routing test pins the mid-life flip.

* fix(pool): check semaphore turns before idle in HasFreeCapacity

Get acquires a turn before popIdle, so with the semaphore exhausted
even a usable idle conn is not immediately servable — and
putConnWithoutTurn can re-pool a conn while its turn stays held, so a
positive idle count does not imply a free turn. The turn check now runs
first; the idle short-circuit only applies when a turn is available.

---------

Co-authored-by: ofekshenawa <104765379+ofekshenawa@users.noreply.github.com>
2026-08-24 14:42:14 +03:00
Nedyalko Dyakov 216593cc01 test(command): skip CLIENT INFO tracking-flag assert on RE (#3981)
Against a Redis Enterprise database the proxy does not surface the client
tracking flags in CLIENT INFO, so 'info.Flags & ClientTracking' (and BCAST) read
back zero even though CLIENT TRACKING ON returned OK and client-side caching
works. The two CLIENT TRACKING specs failed with 'ClientFlags: 0 not to be
zero-valued'.

Assert the CLIENT INFO tracking flags only against a direct server (guard with
!RECluster). The rest of each spec — the command returning OK, and the flag
clearing after TRACKING OFF — still runs everywhere.
2026-08-21 16:00:00 +03:00
Nedyalko Dyakov 96554bd5a4 test(autopipeline): scale timing assertions to measured RTT (#3978)
On a remote endpoint (e.g. the Redis Enterprise integration run) the plain
autopipeline tests that assert a fixed local latency bound fail on network
latency alone — e.g. TestAutoPipelineSequentialSingleThread: '10 sequential
commands took too long: 144ms (should be < 100ms)'.

Add apBaselineRTT() (min of a few warmed PINGs) and apLatencyBudget() (scales a
per-round-trip budget to the measured RTT, with a floor so localhost stays
tight), and use them in the latency-bound checks: the single-command, sequential
and MaxFlushDelay plain tests and the ginkgo blocking-command spec. The bounds
now hold on both localhost and a remote database while still catching a real
per-command coalescing regression.

Also guard TestDoBypassesPipeline (autopipeline_internal_test.go) with the same
'no redis' skip its siblings use — it was the last internal test that hard-failed
on an unreachable :6379.
2026-08-21 14:51:25 +03:00
Nedyalko Dyakov ceba4fbb9b fix(command): tolerate unknown CLIENT INFO flags (#3977)
parseClientInfo (shared by CLIENT INFO and CLIENT LIST) failed the whole reply
on any client-flag character it did not recognize:

  redis: unexpected client info flags(...)

New flag characters can appear over time, so a version-safe client must handle
them gracefully. Skip unrecognized flag characters instead of erroring, matching
the 'skip unknown fields' behaviour already used for unknown fields in the same
parser. Known flags still set their bits.
2026-08-20 11:30:13 +03:00
Nedyalko Dyakov c37c3485d8 test(autopipeline): reach RE database in plain tests (#3976)
Follow-up to #3975, which fixed only the ginkgo Describe blocks. On the Redis
Enterprise integration run (RE_CLUSTER=true) the plain TestXxx funcs still built
clients from apTestAddr() (hard-wired to :6379) and ran before BeforeSuite, so
those without a skip guard hard-failed with
'dial tcp :6379: connect: connection refused'.

  - new apTestOptions() helper lazily loads reConn (RE_CLUSTER +
    REDIS_ENDPOINTS_CONFIG_PATH) without depending on BeforeSuite, so the plain
    redis_test AutoPipeline tests reach the RE database (addr, creds, TLS);
  - four guardless white-box timing tests in autopipeline_internal_test.go
    (package redis, which cannot see the redis_test helpers) get the same
    'no redis' skip guard as their siblings.

DB-15, custom-dialer, ring/cluster and pure-config tests are intentionally left
on apTestAddr()/their existing skip guards.
2026-08-19 17:10:17 +03:00
Nedyalko Dyakov 2a037fd2c9 test(autopipeline): use suite DB in Ginkgo specs (#3975)
The three AutoPipeline Ginkgo Describe blocks built their client from
&redis.Options{Addr: apTestAddr()}, which is hard-wired to :6379 (or
REDIS_PORT). Against a Redis Enterprise endpoint (RE_CLUSTER=true) the
address, credentials and TLS come from redisOptions()/BeforeSuite, so
those specs dialed :6379 and failed with 'connection refused'.

Base the Ginkgo clients on redisOptions() like the rest of the suite so
they reach the proper database (RE endpoint + creds + TLS via
applyREConnection). apTestAddr() stays for the plain go tests, which run
without BeforeSuite and self-skip when Redis is unreachable.
2026-08-19 15:22:19 +03:00
Nedyalko Dyakov 36d97525cd fix(pool): log buffered push data at debug level (#3948)
The buffered-push-data notice in isHealthyConn fired unconditionally.
  With client-side caching every tracked write parks an invalidate frame
  on idle pooled conns, so at default log level the line floods the log
  on nearly every pool Get under write-heavy workloads. It documents the
  normal healthy path, not a problem - gate it behind LogLevelDebug like
  the equivalent notices in maintnotifications.
2026-08-05 14:30:28 +03:00
Nedyalko Dyakov 1acd19a61b docs: point security reports at Redis VDP (#3949) 2026-08-04 12:14:23 +03:00
Nedyalko Dyakov c7f59a2a95 chore(release): prepare 9.22.0 (#3947)
- add 9.22.0 release notes (consolidates 9.22.0-beta.1)
  - bump version to 9.22.0 in version.go and all extra/example go.mod files
2026-08-03 20:39:49 +03:00
Nedyalko Dyakov c994cfc85f feat(autopipeline): automatic command pipelining (#3942)
* ci: run PR workflows for feature/** base branches

PRs stacked against feature/autopipeline/integration (and any feature/**
integration branch) didn't trigger build, e2e, govulncheck or codeql because
their pull_request.branches filters only matched master/v9/ndyakov/**/etc.
A pull_request event filter matches the BASE branch, so PRs targeting a
feature branch were skipped. Add 'feature/**' to those filters.

* feat(autopipeline): #1 base types and client wiring (#3864)

* perf(proto): read payload+CRLF in one shot in ReadStringInto

When the caller-supplied buffer has room for the payload plus the trailing
CRLF, read both in a single io.ReadFull instead of a separate line read.
This is the zero-copy fast path used by GetToBuffer.

* feat(autopipeline): add AutoPipeliner with deferred typed results

Introduce AutoPipeliner, a background batcher that coalesces concurrent
commands from many goroutines into Redis pipelines for high throughput.

Engine (autopipeline.go):
  - Sharded command queue (per-shard mutex + flusher + FIFO semaphore),
    round-robin enqueue to cut contention under concurrency.
  - Batch completion signalled by a single close(batch.done) instead of
    per-command channel sends.
  - AutoPipelineConfig{MaxBatchSize, MaxConcurrentBatches, Unordered,
    MaxFlushDelay, AdaptiveDelay}. Default is ordered (MaxConcurrentBatches=1).
    Validate() rejects MaxConcurrentBatches>1 without Unordered:true so callers
    can't silently lose ordering; NewAutoPipeliner panics on an invalid config.

Deferred results (command.go):
  - baseCmd gains a ready channel + setReady/await/rawErr. Every exported
    value-accessor (Val/Result/Text/Int/Bytes/Bool/Float/Time/Scan/First/Item/
    ...) awaits the result before reading cmd.val/cmd.err; delegating accessors
    inherit the await through the method they call. Execution-path reads use
    rawErr() to avoid awaiting the very batch they produce (self-deadlock).
  - Cmder interface extended with setReady/await/rawErr.

Pooling (pipeline.go) reuses Pipeline/cmd-slice objects across batches.
osscluster.go and redis.go use rawErr() on the pipeline execution path.

* feat(autopipeline): return an error from constructors instead of panicking

newAutoPipeliner / NewAutoPipeliner validated the config and panicked on a
bad one. Since these run from the post-init AutoPipeline / AsyncAutoPipeline
calls (not client construction), a panic is the wrong failure mode — a
deterministic config mistake should surface as a returned error the caller
can handle. Return (*AutoPipeliner, error); the config is rejected before any
flusher goroutine starts, so the error path leaks nothing.

* fix(autopipeline): don't cap the shutdown flush with a hardcoded 2s timeout

flushBatchSliceShutdown wrapped the final drain in context.WithTimeout(2s).
Because the effective wire deadline is min(ctx deadline, conn timeout), that 2s
capped everything — including a relaxed read/write timeout set by
maintnotifications during a failover/migration. In-flight commands on shutdown
would then time out at 2s instead of honoring the relaxed window the relaxation
was designed to protect.

ap.ctx is already cancelled at this point, so use a plain context.Background()
with no artificial deadline, matching the normal flush path and a plain client
Exec: the connection's ReadTimeout/WriteTimeout (and any relaxed override)
governs the wire deadline. A user who wants shutdown bounded sets those, as for
any command.

* fix(autopipeline): correct permit-wait timeout and error in flushBatchSlice

The flush waited a hardcoded 5s for a concurrency permit and, on any non-nil
result, errored every command in the batch with ErrClosed. Two problems:

  - ErrClosed is wrong when the wait simply timed out — the client is not
    closed. Callers saw a misleading closed error for a deadline.
  - 5s is below a maintnotifications relaxed window, so a legitimately slow
    in-flight batch (e.g. during a failover) holding the permits could make
    waiters fail spuriously.

Distinguish a real shutdown (ap.ctx cancelled -> ErrClosed) from the backstop
firing (-> the timeout error as-is), and raise the backstop to 30s — well above
the default ReadTimeout and a relaxed window. It remains only a safety net: every
permit holder releases via defer and each batch Exec is bounded by the conn
timeout, and the wait still ends immediately when Close cancels ap.ctx.

* docs(autopipeline): fix usage example to the error-returning API

The AutoPipeliner type doc showed 'ap := client.AutoPipeline()', the old
single-return form. AutoPipeline / AsyncAutoPipeline now return
(*AutoPipeliner, error); update the example to assign and check err so the
base reflects the final API surface.

* refactor(autopipeline): drop orphan AutoPipeline() accessor stubs

(*Pipeline).AutoPipeline() returned a silent nil and (*AutoPipeliner).
AutoPipeline() returned itself, with a comment claiming they satisfy the
Cmdable interface. They don't — neither Cmdable nor Pipeliner declares
AutoPipeline(), so these methods satisfy nothing and the nil-returning
Pipeline stub is a latent nil-deref footgun. The real entry points are
Client/ClusterClient.AutoPipeline(config...) (*AutoPipeliner, error). Remove
both stubs; the var _ Cmdable assertion still holds.

* fix(autopipeline): Process honors mode; unexport constructor; doc fixes

- Process() now routes through ap.cmdable like Do and the typed methods, so on
  a blocking autopipeliner it blocks instead of always running async. Previously
  it hardcoded processAsync, diverging from every other entry point.
- Unexport NewAutoPipeliner -> newAutoPipeliner. The exported form took an
  unexported cmdableClient param and skipped cluster slot-sharding, silently
  degrading a *ClusterClient to cross-node batches. The public entry points
  (Client/ClusterClient.AutoPipeline/AsyncAutoPipeline) wire sharding correctly.
- Fix stale docs: Do no longer claims it always 'returns immediately' (it blocks
  in blocking mode); TxPipeline/TxPipelined drop the false 'will panic for other
  clients' note (cmdableClient embeds Cmdable, so TxPipeline always exists).

* ci: run PR workflows for feature/** base branches

PRs stacked against a feature/** integration branch (e.g.
feature/autopipeline/integration) didn't trigger build, e2e, govulncheck or
codeql: a pull_request event filter matches the BASE branch, and those filters
only listed master/v9/ndyakov/** etc. Add 'feature/**' so stacked PRs run CI.

* fix(json): avoid autopipeline deadlock and stale reads in JSON commands

JSONCmd/JSONSliceCmd readReply called baseCmd.Err(), which now awaits the
autopipeline batch's done channel. readReply runs inside that batch's Exec,
before done is closed, so a deferred JSON command would deadlock. Use rawErr()
there instead. Also add the missing await() to JSONCmd.Expanded and
JSONSliceCmd.Val/Result so deferred callers block for the result instead of
reading empty/stale data. Reported by Cursor Bugbot on PR #3864.

* ci: exempt autopipeline engine files from the unused linter

The autopipeline stack defines its engine on lower branches and wires the
callers in on top, so those symbols read as unused until the stack is
assembled, failing golangci-lint on the base/pool PRs. Scope the unused
exemption to autopipeline.go and pipeline.go only; unused stays enforced
everywhere else.

* docs(autopipeline): MaxBatchSize default is 200, not 100

DefaultAutoPipelineConfig and the zero-value fill both use 200; the field doc
said 100. Reported by Copilot on PR #3865.

* fix(search): await deferred FTHybrid cursor/raw accessors; clarify MaxBatchSize

- FTHybridCmd.CursorResult/CursorVal/RawVal/RawResult read their fields without
  await(), unlike Val/Result. On a deferred autopipeline that returns empty/stale
  data before the command executes. Add await() to match. (readReply sets the
  fields directly, so this can't re-introduce the batch-Exec deadlock.)
- Clarify the MaxBatchSize doc: it is a soft accumulation threshold, not a hard
  cap; under burst the batch can exceed it and execute as one larger (safe)
  pipeline. Reported by Cursor Bugbot on PR #3864.

* fix(autopipeline): drain shards in Close after flushers exit

A command could pass enqueue's under-lock closed-recheck moments before Close's
CompareAndSwap, then append to a shard AFTER that shard's flusher had already
drained and exited — leaving its batch.done unclosed, so the caller's blocking
accessor hung forever (lost command).

Close now drains every shard once more after wg.Wait() (flushers provably gone).
The drain loop checks queue emptiness UNDER s.mu instead of via the unlocked
s.Len(), so it cannot miss a command appended concurrently. This pairs with the
closed-recheck in enqueue: s.mu serializes them, so either the late enqueue
appends first and the sweep flushes it, or the sweep empties first and the
enqueue then sees closed==true and rejects with ErrClosed. Fixes the hang in
TestAutoPipelineCloseRace.

* address pr comments

* search needs to wait on raw result

* feat(autopipeline): #2 add pipeline pool (#3865)

* perf(autopipeline): flush once the queue stops growing on the default window

accumulateBatch waited the full defaultAccumulateWindow (200µs) whenever the
queue was below MaxBatchSize, even for a single blocking caller with nothing
left to coalesce, so every low-concurrency command paid the whole window. The
doc described a "stops growing" early exit that was never implemented.

Implement it for the implicit default window only: a burst timer fires when no
new command arrives for defaultAccumulateGap (20µs); each enqueue resets it, so
concurrent load keeps coalescing up to the window cap while a lone caller
flushes ~one gap after enqueuing. An explicit MaxFlushDelay / AdaptiveDelay is
an intentional accumulation window and is still waited in full.
defaultAccumulateWindow becomes a var so tests can enlarge it deterministically.

* feat(autopipeline): default the blocking face to a single ordered batch stream

DefaultBlockingAutoPipelineConfig used MaxConcurrentBatches:50 "for throughput",
but that is pessimal. With many flush permits, each command finds a free permit
and flushes on its own before others accumulate, collapsing batch size toward one
command per round-trip — i.e. non-pipelined throughput at high latency.
Backpressure from a single in-flight batch (MaxConcurrentBatches:1) instead lets
callers whose commands return re-enqueue and flush together as the next batch, so
batches stay deep (a near-continuous, double-buffered pipeline).

Measured (get=80/set=20, 64B, localhost, 128 blocking workers):
  before (b=50): ~59k ops/s, p50 2.9ms, avg batch 1.6
  after  (b=1):  ~420k ops/s, p50 0.30ms, avg batch ~94
Higher throughput AND ~10x lower latency, and it is naturally ordered.

The async default (DefaultAutoPipelineConfig) is already MaxConcurrentBatches:1;
for maximum throughput use AsyncAutoPipeline with a window of in-flight commands
(inflight>1).

* feat(pool): add optional dedicated pipeline connection pool

Add an opt-in separate connection pool for pipeline/autopipeline execution
(PipelinePoolSize), so heavy pipelining doesn't starve the regular command
pool. withPipelineConn draws from it when configured and falls back to the
main pool otherwise, draining push notifications before returning a connection.

Attach the existing per-pool listeners to the dedicated pool as well, so its
connections get the same lifecycle handling as the main pool:
  - streaming credentials re-auth hook
  - maintnotifications handoff hook (MOVING/MIGRATING). Because that hook's
    failed-handoff removal target is its own single pool field, the pipeline
    pool gets an independent hook (Manager.InitPoolHookForPool) bound to it,
    rather than sharing the main pool's hook — otherwise a pipeline-pool
    connection that failed handoff would be removed from the wrong pool. The
    extra hook shares the Manager, so MOVING/MIGRATING tracking stays central,
    and is shut down and detached on Manager.Close.

* docs(options): correct pipeline buffer recommendation to 64-128 KiB

The recommended 512 KiB overstated the benefit. Benchmarks show pipeline
throughput climbs from the 32 KiB default to ~64 KiB and then plateaus; beyond
~128 KiB there is no further gain and very large buffers (>=512 KiB) can regress
throughput and waste memory. The only effect of the buffer is holding one
batch's wire bytes so it flushes in a single syscall, so size it to roughly
MaxBatchSize x average-command-bytes. Update the recommendation, example, and
memory-impact figures accordingly.

* feat(pool): register the dedicated pipeline pool with OTel

The optional pipeline connection pool was created, used and closed but never
registered with the OTel recorder, so its connection-count / pending-request
metrics were invisible. Add a pipelinePool param to otel.RegisterPools /
UnregisterPools (registered as a regular pool under a _pipeline name suffix)
and pass c.pipelinePool from NewClient and the client teardown. nil when no
pipeline pool is configured, so default clients are unaffected.

* fix(pool): limiter on pipeline-pool path, hook-attach race, test addr

Address Copilot review on PR #3865:
- withPipelineConn now honors opt.Limiter (Allow() before acquiring,
  ReportResult() on every exit incl. the early init/re-acquire failures),
  mirroring getConn/releaseConn. Enabling the pipeline pool no longer silently
  bypasses throttling and failure reporting.
- InitPoolHookForPool now holds hooksMu across the closed-check, append and
  AddPoolHook, closing a (construction-only) window where Close could tear the
  hook down between append and attach, leaking it. AddPoolHook is lock-free and
  never re-enters the manager, so this is deadlock-safe.
- pipeline_buffer_test.go uses the shared redisAddr instead of a hardcoded
  localhost:6379 so it respects the env/CI Redis address.

* perf(autopipeline): decouple shard count from MaxConcurrentBatches

Shard count was derived as min(GOMAXPROCS, MaxConcurrentBatches, 16), which
silently fragmented the command queue as soon as a user raised
MaxConcurrentBatches for throughput: 16 shards each accumulate a sliver of the
load, so batches collapse toward one command per flush and pipelining
disappears. Measured on a 14-core host (get=80/set=20, 64B, 128 goroutines,
async window 8): MaxConcurrentBatches=50 meant 16 shards and 413k ops/s at
1.4ms p50, while the identical permit budget on ONE shard reaches 1.75M ops/s
at 0.51ms p50 — the fragmentation, not the permit count, was the regression.

Add AutoPipelineConfig.NumShards (0 = auto: one shard) and stop deriving the
shard count from the permit budget. One deep queue plus 2-4 permits is the
sweet spot: batch N+1 accumulates and executes while batch N's replies are in
flight (async, window 8: 1.78M ops/s at 0.53ms p50 versus 1.0M at 1.0ms with a
single permit). Every shard keeps at least one permit, so the effective global
concurrency is max(NumShards, MaxConcurrentBatches). numAutoPipelineShards
stays as the cluster-wiring default, where slot-routed shards keep each batch
on one node.

* perf(autopipeline): stripe the enqueue queue where ordering permits

Profiling the windowed-GET throughput benchmark showed the shard enqueue mutex
as the hottest lock in the engine: with 128 concurrent callers on one shard,
lock/unlock slow paths (parking and waking contended waiters) accounted for
roughly half of all CPU samples.

Split the shard's queue into 8 stripes, each with its own mutex, queue, and
batch-completion signal; enqueue picks a stripe round-robin, and the flusher
drains every stripe into one merged pipeline so batches stay as deep as before.
Striping applies where reordering is impossible or waived: unordered configs,
and every blocking-face shard (a blocking caller waits for each command before
issuing its next, so stripes cannot reorder its stream). The async ordered mode
keeps a single stripe — with several stripes a caller's consecutive commands
could land on opposite sides of an in-progress drain and execute out of order.
Stripes are padded so neighbouring mutexes do not share a cache line.

Measured (14-core, in-process throughput bench): windowed GET 5.2M -> 6.0M
ops/sec; blocking mixed load 0.60M -> 0.77M ops/sec. External-process harness
shows +2-7% with lower p99 (client CPU is not the bottleneck there).

* fix(autopipeline): resolve pre-ship review findings in the engine

Review of the feature (five independent passes; the two ordering defects were
reproduced against a live server) surfaced correctness holes. Engine fixes:

- Do executes on a NORMAL connection, outside the pipeline. Raw command names
  can carry connection state (SELECT/MULTI/SUBSCRIBE/CLIENT ...) or block the
  connection (BLPOP ...); batched onto a shared pipeline conn they silently
  poisoned it for every later batch, or stalled and retried unrelated commands.
  Do now carries plain Client.Do semantics (documented); both faces keep their
  call shape (blocking waits; async defers via a ready channel). Zero-arg Do
  reports a real error instead of ErrClosed.

- Submit is rejected on the blocking face. Submit does not wait, so a windowed
  caller defeats the invariant the blocking face's enqueue striping relies on
  ("every caller waits per command") — reproduced as a deterministic reorder of
  25% of adjacent submissions from a single goroutine. The deferred face is
  the intended home for windowed submission and is unchanged.

- NumShards > 1 without Unordered fails construction on the deferred face:
  round-robin sharding flushes concurrently and does not preserve submit order
  (reproduced: 42% of windowed pairs reordered at NumShards=4). The blocking
  face is exempt (callers wait; Submit rejected), as is cluster slot sharding
  via the new unexported contentSharded marker (same-key commands share a
  shard, so per-key order holds).

- Close no longer errors already-accepted commands: batch execution and permit
  waits run on a background context (wire timeouts still bound them; Close
  waits via wg/batchWg before pools tear down), and the shutdown drain now
  acquires the shard permit so a drained command cannot execute concurrently
  with — and be observed out of order against — a still-in-flight batch.

- Validate rejects AdaptiveDelay with MaxFlushDelay <= 0, which silently
  disabled batch accumulation entirely while reading as a smart-tuning knob.

- numAutoPipelineShards (the cluster wiring default) no longer derives from
  MaxConcurrentBatches: that coupling collapsed cluster slot routing to a
  single shard at the default budget, making per-node sharding dead code.

- Minors: apStripe padded to a 128-byte stride (the 88-byte stride false-shared
  hot fields with the next stripe's mutex), 32-bit-safe unsigned modulo in the
  shard pick (int conversion panicked after counter wrap on GOARCH=386/arm),
  zero AutoFuture.Wait returns an error instead of nil-panicking, stale
  "maximizes latency" wording corrected.

* fix(pool): pipeline pool survives clone; maintnotifications retires it too

Two integration gaps from the pre-ship review:

- baseClient.clone() dropped pipelinePool, so a WithTimeout clone silently fell
  back to the main pool: configured pipeline buffers and pool sizing evaporated
  with no error, and pipeline traffic polluted main-pool metrics.

- The maintnotifications runtime downgrade retired tracked connections only
  through the primary pool; pipeline-pool connections (tracked identically via
  initConn) stayed in service with the feature enabled but no hook attached,
  silently dropping MOVING/MIGRATING pushes. Retire now offers the snapshot to
  every pool the manager attached a hook to — RetireConns skips connections a
  pool does not own, so this is safe by construction.

* polish(autopipeline): pre-ship review polish — accessors, WaitContext, tidy

From the deep review (improvements/docs/dead-code passes):

- AutoFuture.WaitContext: ctx-aware wait (abandons the wait, never the
  command — per-command contexts are still not honored after enqueue, and now
  there is an escape hatch for callers that need one).
- IsBlocking() and Config() accessors: code handed an *AutoPipeliner can tell
  which face it holds and what the effective (default-filled) config is,
  instead of probing Submit for an error.
- Operator breadcrumb when the 30s permit backstop expires (both the flush and
  shutdown paths) — previously the one "should never happen" wedge case failed
  batches silently, with no internal.Logger call anywhere in the engine.
- Static errors are errors.New sentinels (errSubmitBlockingFace,
  errZeroAutoFuture, errDoNoArgs) per house style.
- Micro: putQueueSlice zeroes only the used prefix (tail invariant documented;
  was memclr'ing up to 8 KB per flush for small batches); flushBatchSlice skips
  provably-empty stripes without locking (safe only in that path — the wake
  protocol re-observes racing enqueues; the shutdown drain keeps locking).
- Docs: the AutoPipeliner type doc's flush-trigger list rendered broken in
  godoc and implied a lone command never flushes — rewritten, plus a retry
  caveat (a batch is retried whole on a network error, so non-idempotent
  commands can execute twice on a dropped connection) and a pointer that Do
  runs outside the pipeline. Stale backstop comment (claimed Close cancels the
  permit wait; it deliberately does not) corrected. MaxFlushDelay doc no
  longer references an unexported identifier.
- Dead code: commented-out process() stub removed; unused FIFOSemaphore
  methods removed (AcquireBlocking, Len, and Close — which would have panicked
  Release had anyone called it); defaultAccumulateGap is a const;
  closedBatch renamed completedBatch (it also signals successful direct
  execution, not just closure).

* docs(autopipeline): WaitContext — result may become available later

Spell out the post-deadline contract: a ctx error means the wait was
abandoned, not the command — the batch is still in flight and may populate
the command at any moment. Reading Cmd()'s value or error directly at that
point races the executing batch; Wait again and only read after a
non-context-error return.

* perf(autopipeline): RTT-adaptive coalescing on the default window

On a real network the fixed coalescing constants fragment batches. Measured
against a Redis Enterprise QA endpoint (RTT ~52ms), 512 workers: when a
batch's replies land, hundreds of goroutines wake over several milliseconds,
but the flusher flushed 20µs after the first re-enqueues — the herd split
into partial batches whose serialization costs a full RTT per split
(blocking p50 174ms ≈ 3.3 RTT; manually setting MaxFlushDelay=5ms recovered
7.6k ops/s @ 67ms from 3.4k @ 174ms). On loopback the same constants are
exactly right, so no fixed value serves both.

The engine executes every batch, so it already knows the round trip: keep an
EWMA (alpha 1/8) of batch execution time and derive the default-window
timings from it —

	gap    = clamp(RTT/64, 20µs, 3ms)
	window = clamp(RTT/4, 200µs, 15ms)

Loopback RTTs clamp to the historical 20µs/200µs (behavior bit-identical);
WAN RTTs widen both so reply-wakeup herds coalesce into one batch per round
trip, automatically doing what a hand-tuned MaxFlushDelay did. Explicit
MaxFlushDelay / AdaptiveDelay configurations are untouched. The derivation is
a pure function (adaptiveCoalesce) for unit testing; EWMA updates are
deliberately racy read-modify-writes (losing a sample is harmless, keeps the
batch path CAS-free).

* perf(autopipeline): merge reply cohorts after a blocked permit wait

At MaxConcurrentBatches=1 the flusher takes the queue and THEN blocks on the
permit for a full batch round trip. Callers whose replies land just after the
take re-submit into the fresh queue during that wait; executing without them
splits the herd into two alternating cohorts — each observing two round trips
at half throughput — and the state is stable once entered. Measured on a 52ms
link: p50 pinned at 120ms (2xRTT) for entire runs at 64 workers x inflight 10
while neighbouring worker counts ran at 1xRTT, and run-to-run bistability at
other counts (whichever cohort structure forms at startup persists).

After a blocked permit acquire on the default window, debounce the
resubmission herd (accumulateBatch's RTT-adaptive gap) and fold the late
arrivals into the already-taken batch before executing. The herd collapses
back to one batch per round trip. TryAcquire successes (uncontended, or
MaxConcurrentBatches>1 with free permits) skip the top-up entirely, and
explicit MaxFlushDelay/AdaptiveDelay configs keep their own timing.

* perf(autopipeline): replace debounce timer with cohort-tracked coalescing

The default coalescing path armed a ~20us debounce timer on every flush.
A Go timer that short fires ~1ms late on an idle or low-core host (wakeup
latency dominates the requested delay), taxing every low-concurrency
command ~5x its round trip: on an in-network endpoint (0.25ms RTT) a lone
caller measured p50 1.31ms vs 0.24ms for a plain client.

Replace wall-clock guessing with two facts the engine already has:

- resubmitDebt: a completed batch of N commands wakes its N waiters
  together; in a closed loop they resubmit. Credit N at batch completion,
  debit 1 per enqueue, and hold the flusher until the debt drains - the
  herd flushes as one deep pipeline, exiting the moment it has landed.
  An exact per-herd count cannot ratchet into fragmentation the way a
  smoothed estimate does (an EWMA exit undershoots its own herd and
  locks into fragmented flushes; measured on a 57ms WAN link).
- inFlight: executing batches mean more wakeups are coming. Hold
  stragglers while anything is in flight (gap-bounded, and below
  coalesceMinFlush hold them for the next herd rather than burning a
  pipeline connection on a near-empty flush); when nothing is in flight
  and no debt is outstanding, the shard is idle and flushes immediately
  with no timer armed.

The silence-gap fallback (200us floor, execEWMA/8, 5ms ceil) only ends
waits for callers that left; it never fires in steady state.

Also dispatch the single-command fast path in its own goroutine: running
Process inline blocked the flusher for a full round trip, and on a slow
link a solo straggler then held a landed herd hostage for one RTT whose
flush delayed the straggler again - a stable phase-lock at exactly 2x RTT
(~25% of runs on a 57ms link).

Measured (get=80/set=20, 64B, unordered, MaxConcurrentBatches 50):
- loopback: w1 p50 0.156->0.130ms; w64 +16% tp; w256 +14%; w1024 even
- in-network 2-vCPU (0.25ms RTT): w1 p50 1.31->0.235ms, 4.7x tp; w16
  3.2x tp; w64 2.3x tp; w1024 +4%; w256 -20% (fast-link partial-batch
  overlap the old timer bought; accepted, fixing it re-fragments WAN)
- WAN 57ms: w64/w256/w1024 within baseline variance, phase-lock gone

The stops-growing/adaptive-window tests are superseded by
autopipeline_coalesce_test.go; autopipeline_stops_growing_test.go and
autopipeline_adaptive_rtt_test.go (pipeliner branch) test removed
symbols and are replaced by it on rebase.

* fix(autopipeline): bound the straggler hold; never erase a fresh herd's debt

Two review findings on the cohort coalescing:

- The gap-fire stale-debt reset was a blind Store(0) on the
  pipeliner-global counter. A batch completing on another shard (or
  racing the fire) between the flusher's last look and the reset would
  have its just-credited herd erased, fragmenting a herd that is really
  coming. Reset now requires the debt not to have grown since the timer
  was last armed, and uses CompareAndSwap so a credit racing the reset
  itself also survives.

- The straggler hold (gap-fire with a near-empty queue while batches
  are in flight) re-armed unboundedly. With read timeouts disabled a
  wedged batch pins inFlight forever and up to coalesceMinFlush-1
  queued commands would hang with it. The hold is now bounded by
  autoPipelinePermitBackstop, mirroring the permit wait's safety net.

Also drop a stale test comment claiming Options.AutoPipelineConfig is
not consulted by the blocking face (it is, when no config argument is
given).

* refactor(autopipeline): rename debt/herd vocabulary to expected arrivals/wave

resubmitDebt reads as if something is owed; nothing is — the counter is
an expectation: how many commands the engine knows are about to arrive
because it just woke that many waiters. And 'herd' named the woken group
by analogy rather than by mechanism. Rename for the next reader:

  resubmitDebt   -> expectedArrivals   (announce on completion, account
  coalesceCohort -> awaitExpectedArrivals          per enqueue)
  cohortGap      -> silenceGap (+Floor/Ceil consts)
  herdExpected   -> expectingWave
  herd / cohort  -> wave (comments; 'wave merge' at the permit site)

Behavior unchanged; comments rewritten in the same vocabulary.

* perf(autopipeline): pad stripes with x/sys/cpu.CacheLinePad

The stripe pad was a hand-tuned [80]byte to reach a 128-byte stride. Use
golang.org/x/sys/cpu.CacheLinePad instead: it is sized per GOARCH (64 B
on x86-64/arm64, 128 B on ppc64, 256 B on s390x), so stripes land on
their own cache line on every target rather than a constant that happens
to fit the common ones and still false-shares on s390x.

x/sys was already in the module graph (indirect); this promotes it to a
direct dependency. A contended microbenchmark (8 goroutines, adjacent
stripes) shows padded vs unpadded ~16x, and CacheLinePad vs the old
[80]byte within noise on arm64 — so no regression, just arch-correct.

* perf(autopipeline): lower silence-gap ceiling from 5ms to 2ms

* chore(examples,extra): go mod tidy for golang.org/x/sys indirect dep

* fix(autopipeline): filter the pipeline pool on Sentinel failover

onFailover only filtered connPool, so after a Sentinel promotes a new master the
dedicated pipeline pool kept idle connections dialed to the old master and
pipelined traffic could keep using them. Filter pipelinePool with the same
predicate. Also reword the TestPipelineBufferSizes comment: it verifies
end-to-end pipelining with the buffer options set, not the actual socket buffer
sizes (which are not observable here).

* ci(autopipeline): govulncheck on go 1.26.5 (GO-2026-5856)

The govulncheck workflow pinned go-version "1.26.x", which resolves to 1.26.4
and reports GO-2026-5856 (crypto/tls). Pin 1.26.5 (the fixed release), matching
the pipeliner/benchmarks branches.

* feat(autopipeline): #3 wire into Client and ClusterClient (#3867)

* feat(autopipeline): wire AutoPipeliner into Client and ClusterClient

Add Client.AutoPipeline() and ClusterClient.AutoPipeline() returning a
deferred *AutoPipeliner, plus WithAutoPipeline() and propagation of the
pipeline-pool options. Options gains AutoPipelineConfig.

* test(autopipeline): cover correctness, ordering, config and cluster routing

Standalone and cluster tests for the deferred AutoPipeline API: per-goroutine
ordering, no cross-talk or lost commands, error isolation, config validation
(panic on MaxConcurrentBatches>1 without Unordered), zero-copy
GetToBuffer/SetFromBuffer, adaptive delay, and cluster cross-slot routing.

* docs(autopipeline): add README section and 4-way decision-guide example

README documents the deferred AutoPipeline API and the ordered-default /
Unordered-for-parallel rule. example/autopipeline benchmarks four usage
styles (normal blocking, ordered blocking-read, ordered read-later, unordered
read-later) and prints a decision guide with throughput and ordering.

* feat(autopipeline): AutoPipeline/AsyncAutoPipeline return an error

The four AutoPipeline / AsyncAutoPipeline methods (Client and ClusterClient)
now return (*AutoPipeliner, error) instead of *AutoPipeliner. These run from
post-init calls, so an invalid config must surface as a returned error the
caller can handle rather than a panic. On error no instance is cached, so a
later call with a valid config still succeeds. Tests updated to the two-value
form; the unsafe-config test now asserts an error instead of a panic.

* test(autopipeline): drop test for removed AutoPipeline() self-accessor

The (*AutoPipeliner).AutoPipeline() stub was removed (orphan, satisfied no
interface); drop the test that asserted it returns itself.

* test(autopipeline): use AsyncAutoPipeline after unexporting the constructor

NewAutoPipeliner is now unexported; the unsafe-config test calls
c.AsyncAutoPipeline (same error-on-bad-config contract). Update a stale doc
comment reference too.

* style: gofmt redis.go and osscluster.go

Remove a stray double blank line after the AsyncAutoPipeline methods so
golangci-lint's gofmt check passes.

* ci(spellcheck): add autopipelining, goroutine(s) to the wordlist

The README autopipeline section uses 'autopipelining' and 'goroutine(s)',
which aren't in the spellcheck dictionary, failing check-spelling on the PR.
Add them to .github/wordlist.txt.

* test(autopipeline): fix CI failures on the pipeliner PR

Three independent test-job failures, all in test code / the example module:

- Blocking-face ginkgo specs panicked: ap.Do returns a generic *redis.Cmd, but
  the specs type-asserted *StringSliceCmd/*StringCmd. Use *redis.Cmd with its
  StringSlice()/Text() accessors.
- Cluster autopipeline tests failed in the standalone test-ce job: the :7000
  tests had no skip guard, and the :16600 tests guarded only on Ping (which
  passes against unjoined nodes that then return CLUSTERDOWN/MOVED). Add a
  shared skipIfClusterUnhealthy that gates on CLUSTER INFO cluster_state:ok, and
  use it in all cluster autopipeline tests.
- example/autopipeline didn't compile (govulncheck 'all modules' load error):
  it called AutoPipeline()/AsyncAutoPipeline() in single-value context after they
  gained the error return. Assign (ap, err) and panic on err.

* ci(spellcheck): add async, runnable to the wordlist

The README autopipeline section uses 'async' and 'runnable' in prose; add them
to .github/wordlist.txt. Verified the full README passes pyspelling via the
exact CI image (jonasbn/github-action-spellcheck:0.62.0).

* docs(readme): autopipeline examples use the (ap, err) return

AutoPipeline()/AsyncAutoPipeline() return (*AutoPipeliner, error); the README
snippets showed single-value calls that wouldn't compile. Capture and check err.
Reported by Copilot on PR #3867.

* test(autopipeline): cover stops-growing early exit and explicit-delay gating

Two white-box tests for the default-window stops-growing behavior:

- StopsGrowingDefaultWindow enlarges defaultAccumulateWindow to 1s and asserts
  a lone caller still flushes in well under it (deterministic, no sub-ms timing).
- ExplicitDelayWaitsFullWindow guards the gating: an explicit MaxFlushDelay is
  still waited in full, so stops-growing never short-circuits a configured window.

Config is passed as an argument to AutoPipeline (the blocking face does not read
Options.AutoPipelineConfig).

* docs(autopipeline): AutoPipeline default is a single ordered stream, not parallel batches

* feat(autopipeline): cluster keeps multi-shard default; cover NumShards

The engine now defaults to a single shard (deep queue) for standalone clients.
Cluster clients still want several shards — commands are routed to shards by
slot so each batch stays on one node — so the cluster wiring fills in the
numAutoPipelineShards default when the user leaves NumShards at 0, copying the
config rather than mutating the caller's struct.

Tests cover: standalone defaulting to one shard regardless of the permit
budget, an explicit NumShards override, negative-NumShards validation, and the
cluster default (copy semantics + explicit passthrough).

* test(autopipeline): adaptive-delay test pokes stripe 0 after enqueue striping

The white-box adaptive-delay test set the shard's queueLen directly; that
counter now lives on the shard's stripes. Point it at stripe 0 (these configs
are ordered, so the shard has exactly one stripe).

* fix(autopipeline): cluster review fixes — push drain, slot-shard default, docs

Cluster-side resolutions from the pre-ship review:

- ClusterClient.pipelineReadCmds drains buffered RESP3 push notifications
  before reading each reply, matching the standalone pipeline and cluster
  TxPipeline read loops. It was the only push-blind reader: a push frame
  (e.g. a maintnotifications MOVING notification) arriving mid-batch was
  consumed as a command's reply, shifting every subsequent reply in the
  pipeline by one command — and the autopipeliner routes all cluster traffic
  through this loop.

- Cluster wiring uses the decoupled numAutoPipelineShards() default and marks
  the config contentSharded, restoring per-node slot sharding at the default
  configs (it had collapsed to one shard, making installAutoPipelineSharding
  dead code) while remaining exempt from the async NumShards ordering check —
  slot routing keeps every key on one shard, so per-key order holds.

- Docs: ClusterClient.AutoPipeline's godoc was fused into the unexported
  clusterAutoPipelineConfig helper (the public method rendered undocumented);
  split and given a per-key ordering qualifier. Stale wording fixed in
  redis.go ("maximizes latency"), the example ("parallel-batch config"), and
  command.go (references to never-shipped FutureAutoPipeline/ChanAutoPipeline).

- Tests: Submit rejected on the blocking face; NumShards>1 requires Unordered
  on the async face (blocking and cluster slot-sharded configs exempt);
  AdaptiveDelay without MaxFlushDelay rejected; Do bypasses batches (a MULTI
  via Do can no longer corrupt co-batched commands); cluster default config
  keeps multiple slot-routed shards; stops-growing tests use typed calls now
  that Do no longer enters the engine.

* polish(autopipeline): unify getters, closed-client guard, config fallback, docs

Wiring polish from the deep review:

- The four AutoPipeline/AsyncAutoPipeline getters (Client and ClusterClient)
  shared a hand-rolled 15-line caching protocol each; they now delegate to one
  getOrCreateAutoPipeliner helper, which is also where the new closed-client
  guard lives: calling a getter after Close returns ErrClosed instead of
  silently building a fresh pipeliner against closed pools whose flusher
  goroutines nothing would ever stop.

- Options.AutoPipelineConfig now feeds BOTH faces (it was silently ignored by
  the blocking AutoPipeline — a generically-named option honored by only one
  of two identically-shaped methods). Each face still falls back to its own
  default when the option is nil. FailoverOptions gains the AutoPipelineConfig
  field (failover clients could reach the feature but not configure it) and
  plumbs it to both the sentinel-backed client and the failover cluster client.

- Docs: getter godocs state the Options fallback; ClusterClient.AsyncAutoPipeline
  documents per-key ordering across nodes; the README's stale "parallel
  pipelines" claim is corrected to the ordered-stream default, throughput
  figures are qualified, and the section gains the connection-count benefit
  plus a caveats paragraph (per-command contexts, blocking commands and Do
  bypass batching, whole-batch retry duplication, shared cached instance).
  Buffer-size option docs mention both pipeline entry points.

- The scoped "unused" linter exemption for the stacked-PR period is removed:
  the wiring lands on this branch, so every engine symbol is referenced.
  Example build artifact gitignored.

* docs(example): autopipeline example becomes a usage tour + benchmark

The example was a throughput harness only — valuable, but it never showed how
to actually use the API (nothing read a value back). It is now two acts:

Act 1, a usage tour: the blocking face as a drop-in (goroutines Set+Get with
plain-client call shape), the async face's submit-a-window-read-later pattern,
Submit/AutoFuture for raw commands (async-only by design, with WaitContext
noted), the Do escape hatch (runs on a normal connection outside the pipeline —
plain Client.Do semantics), in-code tuning notes (Unordered +
MaxConcurrentBatches 2-4, NumShards stays 0, first-call-config-wins caching,
dedicated pipeline pool options), and an env-gated cluster tour
(REDIS_CLUSTER_ADDRS) showing slot sharding needs no extra code.

Act 2, the existing four-way throughput comparison, with review fixes: the
unused rdb parameter dropped, the bench-tuned MaxConcurrentBatches: 80 magic
number replaced by the recommended 2-4 (with a comment where big numbers
belong), the baseline's PoolSize asymmetry explained where it stands, panic
replaced by the file's print-and-exit convention, and the stale "parallel
pipelines" package comment corrected to the ordered-stream default.

Adds the README the example was missing (15 of 17 examples have one): run
instructions, what each act shows, a sample results table, and the caveats
users must know (per-command contexts, whole-batch retry duplication, per-key
cluster ordering).

* test(autopipeline): re-point tests that the Do change made vacuous

AutoPipeliner.Do now executes on a normal connection outside the engine, so
every test that drove ap.Do to exercise batching/flushing/queueing was passing
vacuously against the plain-client path. Re-pointed at the engine:

- ap.Do(ctx, "SET", ...) becomes the typed ap.Set(...) throughout the ginkgo
  suite, TestAutoPipelineBasic/MaxFlushDelay/Concurrency/SequentialSingleThread,
  the sequential tests, and the cluster tests (which now genuinely exercise
  slot sharding).
- The error-isolation spec uses a typed WRONGTYPE failure inside a batch
  instead of Do("INVALID_COMMAND"), which no longer touches a batch at all.
- The queue-length spec moves to the deferred face with a wide explicit
  window, making Len assertions deterministic and meaningful (they previously
  observed an untouched queue: always zero).
- TestAutoPipelineSecondaryAccessors moves to the deferred face — the missing
  await() regression it guards is only expressible there (the blocking face
  waits inside the call, so it could never catch it). Stale reference to the
  removed autoPipelineCmd type dropped.
- The sequential submit-then-read tests move to AsyncAutoPipeline (that is the
  deferred pattern they describe); TestOrderedModeWindowed likewise, making
  its "never blocks between submits" claim true. TestFutureFaceOrdering
  deleted — it duplicated TestBlockingFaceExecutesOnCall.

Note: the ginkgo AutoPipeline specs require the full test rig (BeforeSuite
spins up cluster/sentinel processes) and are validated in CI; everything
reachable locally runs green under -race.

* docs(readme): list Automatic pipelining under Features (experimental)

Adds the feature-list entry linking to the section, marked experimental and
scoped to its intended audience: high-throughput / high-load / scale use
cases. The section itself now carries the same experimental note so readers
landing via the anchor see it too.

* docs(readme): point to the example app from the autopipelining intro

The example pointer sat at the end of the caveats paragraph where nobody
scrolling the section would find it; it now lives in the section's opening
note (and the duplicate tail mention is removed).

* test(autopipeline): pin the RTT-adaptive coalescing derivation

Unit tests for adaptiveCoalesce (loopback clamps to the historical constants,
WAN scales gap/window, ceilings bound the tax, enlarged-floor interaction with
the stops-growing test hook) and the execEWMA smoothing behavior.

* test(autopipeline): drop tests of removed coalescing internals

autopipeline_stops_growing_test.go and autopipeline_adaptive_rtt_test.go
exercised defaultAccumulateWindow / adaptiveCoalesce, which no longer
exist; autopipeline_coalesce_test.go covers the replacement mechanism.

* fix(autopipeline): non-pipelineable command fails itself, not the merged batch

mapCmdsByNode returned a batch-wide error when any command's policy
failed CanBeUsedInPipeline, so under the opt-in dynamic resolver one
caller enqueueing e.g. a multi-shard MGET errored every unrelated
caller co-batched in the same autopipeline flush (review finding M9).
Set the error on the offending command and continue mapping the rest;
the skipped command never joins a node pipeline, is not retried, and
its waiter reads the policy error while everyone else proceeds.

* docs(example): autopipeline example catches up with the engine

- Do guidance: typed blocking commands (BLPop, XRead Block, Wait, ...)
  are diverted to a normal connection automatically — Do is not the
  escape hatch for them; and stop suggesting SELECT/SUBSCRIBE through
  Do (connection-state commands poison a pooled conn with plain Do
  semantics too — Options.DB and the PubSub API are the answers).
- Tuning notes: extra MaxConcurrentBatches permits add overlapping
  batches bounded by the pipeline pool; with wave-boundary coalescing
  they no longer fragment batches. Reworded in main.go and the bench
  comment.
- README: refresh sample numbers on the current engine (blocking face
  552k -> 633k on the same loopback box) and note the pipeline-hook
  instrumentation shape in caveats.

* ci(autopipeline): wordlist loopback/autopipeliner; go mod tidy example/autopipeline

* chore: stop tracking .claude/worktrees gitlinks; gitignore them

* perf(autopipeline): dispatch drained batches straight to processPipelineHook

The flusher built a Pipeline per batch: one Pipeline alloc, a nil cmds slice
growth-reallocating ~log2(N) times, and one interface Process call per command
— then Exec handed the slice away, nothing reused. Extend cmdableClient with
processPipelineHook (the exact hook-wrapped entry Pipeline.Exec wires, so
hooks/OTel see the identical call) and hand the drained queue slices to it
directly: zero-copy for single-stripe drains (every ordered shard), one pooled
merge for striped drains. Shutdown flush uses the same path.

Local A/B (M4 Max, redis 6379, count=6): B/op -12-14% on windowed workloads
(p=0.002); blocking-face throughput +10% (1.09M -> 1.21M ops/s); windowed
throughput unchanged (RTT-bound). Note: hooks now receive a pooled slice —
retaining []Cmder past the hook call was never sanctioned; contents are zeroed
on reuse.

* perf(autopipeline): skip the shard round-robin atomic when there is one shard

len(shards)==1 is the standalone default; every enqueue still bumped ap.next —
a shared cache line — for a pick that is constant. Same guard the stripe pick
already has. Also adds a goroutine-leak regression test (open/close many
autopipelines, assert goroutines return to baseline).

Local A/B: unordered dispatch ~-3% ns/op, windowed throughput +3-5%.

* test(autopipeline): de-flake TestDoBypassesPipeline

Force a real multi-command batch (MaxFlushDelay) so the batched SETs run on the
dedicated pipeline pool, instead of racing the single-command fast path onto the
MULTI-poisoned normal-pool connection. Fixes intermittent failures under -race
and on loaded CI runners.

* fix(autopipeline): surface retry-exhaustion errors and route streaming re-auth to the owning pool

generalProcessPipeline only set command errors on the early-exit branch, not
when the retry loop was exhausted. Manual Pipeline surfaces that via Exec's
return value, but AutoPipeline discards the pipeline error and observes results
only per-command, so a network failure that exhausted retries silently returned
a nil error and zero values. Mirror the early-exit setCmdsErr on exhaustion,
guarded by !isRedisError so a per-command redis error keeps its own reply.

Streaming-credentials re-auth hardcoded c.connPool even for pipeline-pool
connections, so a failed pipeline conn was closed and accounted against the main
pool. Route the SingleConnPool base and the auth-error CloseConn to the owning
pool via poolForConn (pool.Conn.PoolName).

* test(autopipeline): retries, cluster MOVED, and pipeline-pool feature coverage

- pipeline and autopipeline retry re-drive: success-after-retry, exhaustion
  surfaces the error, WRONGTYPE isolation, context-cancel not retried
  (scriptable bad-conn dialer, no external deps)
- cluster MOVED redirect inherited by AutoPipeline via the shared execer, with a
  MaxRedirects=-1 anchor proving the redirect path is exercised
- dedicated pipeline pool: ACL AUTH + SELECT handshake, StreamingCredentialsProvider
  re-auth, and maintnotifications all verified on pipeline-pool connections

* feat(autopipeline): expose AutoPipeline on the UniversalClient interface

Add AutoPipeline/AsyncAutoPipeline to the UniversalClient interface. *Client
(including sentinel-backed failover clients) and *ClusterClient already implement
them; *Ring returns errRingAutoPipelineUnsupported so it satisfies the interface
and fails explicitly instead of being silently unavailable.

* test(autopipeline): context contract, reply-shape demux, fuzz, backpressure, UniversalClient

- AutoPipeline context contract: WaitContext abandons only the wait, caller ctx
  does not cancel execution, blocking commands honor ctx, submit-after-Close
- heterogeneous reply-shape demux (bulk/array/int/map/nil/double/large/binary)
  under RESP3 and RESP2
- differential fuzz: identical results plain client vs autopipeline over random
  type-diverse command streams
- blocking-command isolation, double-Close, windowed backpressure, bounded soak
- AutoPipeline reachable via the UniversalClient interface (Ring returns unsupported)

* chore(autopipeline): fix CI — remove unused putPipeliner, pin govulncheck to go 1.26.5

- lint: putPipeliner (pipeline.go) was documented but never wired to a caller,
  so golangci-lint flagged it (unused); remove it.
- govulncheck: pin the scan's Go toolchain to 1.26.5, fixing the crypto/tls
  standard-library advisory GO-2026-5856 (was resolving to 1.26.4).

* feat(autopipeline): AutoPipeliner implements UniversalClient (drop-in)

Widen the internal cmdableClient to UniversalClient and delegate the non-batched
surface (AddHook, Watch, Subscribe/PSubscribe/SSubscribe, PoolStats, Do,
AutoPipeline/AsyncAutoPipeline) to the underlying client, so an *AutoPipeliner
can be used anywhere a redis.UniversalClient is expected: data commands are
batched, everything else runs on the underlying client. Narrow Do to return *Cmd
to match the interface; drop the now-redundant .(*redis.Cmd) assertions in the
tests. Close() still closes only the autopipeliner, not the underlying client.

This lets tests (the command-suite matrix and maintnotifications e2e) parametrize
a single UniversalClient subject that includes the autopipeliner.

* refactor(autopipeline): take *AutoPipelineConfig (nil=default) not variadic

Address review feedback (cxljs, #3867). The variadic
`config ...*AutoPipelineConfig` reads like the functional-options pattern but
isn't; a single `*AutoPipelineConfig` where nil selects the default is clearer.

Changes AutoPipeline / AsyncAutoPipeline on *Client, *ClusterClient, *Ring, the
UniversalClient interface and the AutoPipeliner drop-in, plus
getOrCreateAutoPipeliner's override parameter. Call sites now pass nil instead
of no argument. README, example and doc comments updated.

* fix(autopipeline): PR #3867 review fixes

- clone(): copy pipelinePoolName so timeout-derived clients keep the dedicated
  pipeline pool for re-auth routing (poolForConn) instead of falling back to the
  main pool.
- clusterAutoPipelineConfig: mark contentSharded even when the caller sets an
  explicit NumShards. A cluster always routes by slot, so per-key order holds
  regardless of shard count; the async face was previously rejected.
- TestFutureFaceTyped: build the deferred face with AsyncAutoPipeline so it
  actually exercises await()/deferred accessors.
- Options docs (sentinel/ring/osscluster): correct the pipeline-pool enable
  condition — set by PipelineReadBufferSize/PipelineWriteBufferSize;
  PipelinePoolSize alone does not enable it.
- README: define ctx in the AutoPipeline examples so they compile as-is.
- Update the cluster shard-config test for the corrected contentSharded behavior.

* refactor(autopipeline): AutoPipelineOptions + no-arg / WithOptions methods

Rename the config type AutoPipelineConfig -> AutoPipelineOptions (matching the
Options naming family: Options, ClusterOptions, ...) and split each face into a
clean no-arg method plus an explicit variant:

  AutoPipeline()                          // Options.AutoPipelineOptions or default
  AutoPipelineWithOptions(*AutoPipelineOptions)
  AsyncAutoPipeline()
  AsyncAutoPipelineWithOptions(*AutoPipelineOptions)

Drops the awkward AutoPipeline(nil) for the common default case while keeping an
explicit per-instance override. Both faces remain independent (a client can hold
a blocking and an async autopipeliner at once). Also renames
Default{,Blocking}AutoPipelineConfig -> ...Options and the *Options
AutoPipelineConfig fields -> AutoPipelineOptions. Applied across *Client,
*ClusterClient, *Ring, the UniversalClient interface and the AutoPipeliner
drop-in; call sites, README and example updated.

* docs(autopipeline): reword no-arg -> no-argument (check-spelling)

check-spelling flagged "arg" (tokenized from "no-arg") in the README. Use
"no-argument" instead.

* fix(autopipeline): hooks reading deferred results no longer deadlock

A pipeline (or process) hook that reads a deferred command's result —
cmd.Err()/Val()/Result() — blocked on the batch's done channel, which the
dispatching goroutine closes only after the hook chain returns: a
deterministic self-deadlock on the async face (#3867 review). Any hook that
inspects per-command results inside the chain — a common pattern in custom
logging/metrics instrumentation — triggers it. The blocking face was
unaffected.

Fix, leaving the execution path untouched:

- Complete batches at the innermost exec seam. The async dispatch now runs
  the user-hook chain via withProcessPipelineHook around the base
  processPipeline, closing the batches (and announcing expected arrivals,
  preserving the wave-merge ordering) the moment replies are populated —
  still inside the chain, so a hook reading results after next() (the
  span-status/metrics pattern) finds them ready. The lone-command fast path
  and Do get the same treatment through
  withProcessHook. The flusher's deferred close stays as a panic backstop
  (apBatch.close is now idempotent). The blocking face keeps its previous
  flow unchanged.

- Guard await() against self-deadlock for reads BEFORE next(): the batch
  records its dispatcher's goroutine id while inside the hook chain; a
  reader on that goroutine gets the not-yet-executed view — exactly what a
  plain pipeline hook sees before next() — instead of blocking on a channel
  only its own goroutine can close. The goroutine id is parsed only on the
  about-to-block slow path, never on the fast path.

- Make cmd.ready an atomic pointer: processAsync wired it after the command
  was already published to the flusher, racing a hook's read. The atomic
  store keeps that race benign — a hook that wins sees nil and takes the
  non-blocking not-yet-executed view — with nothing added under the hot
  stripe enqueue lock.

- Arm the goroutine-id guard only when user hooks are installed
  (hooksMixin.hookCount): hook-free clients never stamp the batch, so a
  blocked reader pays one atomic load, not a runtime.Stack parse.

Benchmarked against the unfixed base (benchstat; sequential n=8 rounds plus
drift-controlled interleaved runs for the sensitive benches): DispatchPath
ordered/unordered/solo, Submit, FutureFace and Throughput are all
statistically unchanged (~, p>0.05); allocs identical (4/op dispatch).
An earlier iteration that put setReady under the hot stripe enqueue lock
benched at -9% Submit ops/sec and was rejected; the shipped shape adds
nothing under that lock and keeps the guard to one atomic load unless hooks
are installed.

Note: results become readable the moment execution completes, so a hook that
mutates a command's result after next() is racy on the async face; hooks
should treat results as read-only there.

Regression tests cover before/after-next hooks across the batch, solo and Do
dispatch paths plus the blocking face, race-enabled; the deadlock reproduces
on the unfixed tree.

* test(autopipeline): consolidate per-topic test files into two

Collapse the 29 autopipeline_* / osscluster_autopipeline_* test files into:

  autopipeline_test.go           all blackbox tests (package redis_test),
                                 including the cluster autopipeline tests
  autopipeline_internal_test.go  whitebox tests (package redis): shard-count
                                 decoupling, coalescing, review-fix regressions

Pure reorganization: no test bodies changed, only regrouped (matches the
consolidation already done on the benchmarks branch). Counts preserved
(68 Test funcs, 3 Describe suites). The pipeline_*_test.go files are left
as-is: they cover the dedicated pipeline pool and plain-pipeline behavior,
not the autopipeline engine.

* fix(autopipeline): deep-review fixes — hook short-circuit, cluster guard, tests, docs

Engine:
- A hook that returns WITHOUT calling next (allowed by the Hook contract;
  circuit-breaker shape) used to wake every waiter with a silent zero value:
  the chain's error was discarded on all dispatch paths. The innermost now
  records execution; when a hook short-circuits, its error (or a dedicated
  sentinel) is set on every command before the batch completes. Covers both
  faces, the lone-command fast path, Do and the shutdown drain.
- The async self-deadlock guard is now always armed on cluster clients:
  node clients carry their own hooks (OnNewNode + AddHook, the redisotel
  pattern) that hookCount() cannot see. Node-level hooks run on node-worker
  goroutines, which the goroutine-id guard cannot protect pre-next(); that
  limitation is documented (results are safe to read after next()).
- AutoFuture.Wait/WaitContext get the same dispatcher-goroutine guard as the
  command accessors.
- Lone-command dispatch releases its permit and inFlight slot before waking
  the caller, so a closed-loop lone caller's next command takes the
  immediate-flush path instead of arming the silence-gap wait.
- AddHook mutates the hook slice under hooksMu, and the dispatch snapshots
  the slice under RLock (background flushers iterate it concurrently).

Tests:
- TestAutoPipelineConcurrency asserted a locally-incremented counter and
  could not fail; it now checks every command and verifies DBSize.
- The autopipeline reply-demux test ran on the blocking face sequentially,
  so every command took the lone-command fast path and the batch demux it
  claims to cover never executed; it now uses the async face with a flush
  window so all nine heterogeneous commands land in one pipeline batch.
- The three cluster autopipeline tests dialed :7000-:7002 and always
  skipped; they now use the test harness's :16600-:16602.
- Plain go tests use apTestAddr() (REDIS_PORT-aware, :6379 default): the
  suite variable redisAddr defaults to :6380 and is only corrected inside
  the ginkgo BeforeSuite, which plain go tests may run without.
- Cold-start latency tests warm up dial+handshake before timing; goroutine
  leak checks poll with a deadline instead of fixed post-GC sleeps; the
  flush-on-timer test waits on a completion signal instead of sleeping.
- New coverage: hook short-circuit (both faces, batch+solo), hooks added
  after the autopipeliner exists (client and delegate AddHook), Close
  flushing pending deferred futures, client-level Options.AutoPipelineOptions
  taking effect on both faces, BLPOP on the async face (readTimeout
  diversion), and AdaptiveDelay end-to-end traffic.

API/docs:
- UniversalOptions gains AutoPipelineOptions, threaded through Simple/
  Failover/Cluster (the other option structs already had it).
- ErrRingAutoPipelineUnsupported is exported (it was named in public godocs
  while unexported and unmatchable with errors.Is).
- ClusterOptions.AutoPipelineOptions doc corrected: it configures BOTH
  faces, not only AsyncAutoPipeline. Client/ClusterClient Close docs now
  mention both cached autopipeliners.
- README: stale WithConfig name fixed, the parallel-batches example uses a
  realistic MaxConcurrentBatches (8) with a note that more permits don't
  deepen batches; godoc throughput claims hedged as local loopback
  measurements; stale setReady/await comments refreshed; MaxBatchSize doc
  notes the blocking face's 300 preset.
- Remove three stray benchmark screenshots (513 KB) accidentally committed.

* fix(osscluster): route cluster pipelines through the dedicated pipeline pool

Cluster pipelines acquired connections via node.Client.getConn — the node's
main pool — so the dedicated pipeline pool was never used on ClusterClient,
even though node clients inherit PipelineReadBufferSize /
PipelineWriteBufferSize / PipelinePoolSize through clientOptions (review
finding by cxljs on #3867).

processPipelineNode and processTxPipelineNode now acquire through
withPipelineConn: the node's dedicated pipeline pool when configured, with
the exact previous behavior as the fallback (withPipelineConn delegates to
withConn when no pipeline pool exists). An entered flag distinguishes
acquisition failures — which keep the old MarkAsFailing / mapCmdsByNode /
setCmdsErr handling — from execution errors, which processPipelineNodeConn
already handles.

TestClusterPipelineUsesDedicatedPool covers both the manual pipeline and the
autopipeline batch path against a real cluster and asserts the node pipeline
pools actually carried the traffic.

* fix(autopipeline): DoRaw escape hatch + node-hook short-circuit surfacing

Address review feedback (ofekshenawa, #3867):

- AutoPipeliner embeds cmdable, so DoRaw and DoRawWriteTo rode the batching
  engine even though Do deliberately runs outside it. Raw commands carry
  Do's caveats, and DoRawWriteTo streams to caller-provided io.Writers —
  user I/O that must not execute inside a shared batch's reply loop. Both
  now override with Do's exact escape-hatch shape through a shared
  runOutsidePipeline helper: synchronous Process on the blocking face;
  returns-immediately with hook-safe batch completion (and hook
  short-circuit surfacing) on the deferred face. Do is refactored onto the
  same helper.

- processPipelineNode and processTxPipelineNode discarded the return of the
  node-level hook chain: a node hook returning without calling next (the
  circuit-breaker shape the Hook contract allows) left every command
  error-free, so a cluster pipeline could report success for commands that
  were never sent. The chain now records execution; when it never reached
  the transport, the chain's error — or the errHookShortCircuit sentinel —
  is set on every command. Deliberate aborts are not remapped for retry:
  a retry would re-run the same hook.

Tests cover DoRaw/DoRawWriteTo on both faces under result-reading hooks
(including the no-args guard) and the node-level short-circuit against a
real cluster, using an armable pipeline-only breaker installed via
OnNewNode — disarmed during warmup because connection-init handshakes are
themselves pipelines through the hook chain.

* fix(autopipeline): retry tests honor REDIS_PORT, doc nits

- pipeline_retry_test.go hard-coded ":6379" four times, silently
  skipping (or hitting the wrong server) when tests run with
  REDIS_PORT set; use apTestAddr() like the rest of the suite
- drop a duplicated sentence in the ClusterOptions doc
- the example benchmark's comment promises a goroutine-sized pool for
  the plain-client baseline but set 50 for 500 goroutines; size the
  pool to the goroutine count so the baseline is not pool-starved

* fix(autopipeline): honor a hook's post-next verdict; complete batches after the chain

A hook that called next and then returned an error had that verdict
discarded (review finding by ofekshenawa on #3867): batches completed at
the innermost exec seam, so waiters were already awake and the executed
flag suppressed the chain's error.

Batches now complete at the caller's deferred closes, after the whole hook
chain has returned. The dispatcher-goroutine-id guard in await()/Wait()
carries the self-deadlock protection instead: a hook on the dispatch
goroutine reading a command before next() gets the not-yet-executed view,
and after next() the populated results — without blocking on a channel
only its own goroutine completes.

This restores plain-pipeline hook semantics across the board:

- a post-next injected error (exec succeeded, hook returned an error)
  reaches every command that has no error of its own; per-command errors
  recorded by the exec itself always win, and a hook's rewrap of an exec
  failure is dropped rather than clobbering precise per-command results;
- the lone-command fast path and Do record the chain's final verdict
  exactly like Client.Process — short-circuits, rewrites and suppressions
  are all honored;
- hooks may adjust results before any waiter wakes, removing the previous
  'results are racy to mutate after next()' caveat;
- the expected-arrivals announce still precedes the closes on both faces,
  preserving the wave-merge ordering.

Also: the differential fuzz passed a raw 1000 (nanoseconds) to Expire,
which truncates to EXPIRE 0 (a delete) — now a real 100s TTL (review
finding by cursor).

New TestAutoPipelineHookPostNextError covers both faces across the batch,
solo and Do paths; the full hook battery (deadlock before/after next,
short-circuit, hooks-added-late, DoRaw) stays green through the guard, as
do the AP race suite and the cluster tests against a live cluster.

* chore(examples): tidy the new example modules against this branch's root

The master merge added four example modules (ft-aliaslist, himport,
search-aggregate-collect, ts-querylabels) tidied against master's root
go.mod. This branch's root additionally requires golang.org/x/sys (cache
line padding in the pipeline pool), and the examples replace the root
module, so on this branch they need 'go mod tidy' — which made the
govulncheck CI job fail while loading packages (no vulnerability
involved). Tidy adds the x/sys indirect requirement to each.

* fix(autopipeline): skip the dispGid stamp on the blocking face's solo path

The lone-command fast path stamped the dispatcher goroutine id (a
runtime.Stack header parse) whenever hooks were installed, without the
!ap.blocking guard the multi-command and shutdown dispatch paths already
have. On the blocking face the stamp is never consulted — blocking
commands never carry a batch, so their result accessors don't block —
making it pure overhead on every single-command flush (review finding by
cursor on #3867).

Also document, at the verdict guard in dispatchCmds, why a post-next hook
error is applied only to an all-clean batch: hooks conventionally return
next's error, so after a partial failure the chain error is presumed to be
that echo, and stamping it on the commands that did succeed would
overwrite valid replies with their batchmates' failure. Behavior
unchanged.

* fix(autopipeline): never read user command results on the execution path

A full re-review of the PR found that the async autopipeliner deadlocked
in the DEFAULT configuration. pipelineProcessCmds re-read the first
command's error after the himport re-issue pass via Err(); on the async
face Err() awaits the command's own batch — which this very goroutine is
executing and only closes after the hook chain returns. With no hooks
installed the dispatcher-goroutine-id guard is unarmed, so a multi-command
batch whose first reply was any error (a plain redis.Nil cache miss is
enough) blocked the dispatch goroutine on itself forever: shard permit
never released, every waiter hung, Close() hung on the batch WaitGroup.
Reproduced with 50 async GETs on missing keys — the runtime reports 'all
goroutines are asleep'. The same execution-path Err() reads existed at
five himport sites (himportAfterBatch, himportRetryFailedSets, the cluster
re-queue), which additionally run on per-node worker goroutines where the
guard can structurally never match — any async batch containing an HIMPORT
command wedged even when every command succeeded. All six now use
rawErr(), matching the already-swept neighboring sites, with comments
stating the rule. Two regression tests pin the redis.Nil-first batch and
the himport-in-batch cases on the hook-free default.

Also from the re-review:

- TSNRangePivotRowSliceCmd.Val()/Result() were the only command accessors
  missing await(): async-face TSNRange/TSNRevRange returned zero values
  while racing the flusher's readReply. Added; a mechanical sweep of every
  accessor confirms no other misses.
- Client.WithTimeout copied the client struct while AutoPipeline()/Close()
  can concurrently mutate the mutex-guarded autopipeliner fields — a data
  race under -race; the snapshot is now taken under the guard.
- NewFailoverClient created the dedicated pipeline pool but never set
  pipelinePoolName, so poolForConn would route failover pipeline conns to
  the main pool (latent until failover clients gain streaming credentials;
  wrong-pool CloseConn/accounting once they do).
- AutoPipelineOptions coerced non-positive MaxBatchSize and
  MaxConcurrentBatches to defaults BEFORE Validate ran, so the documented
  negative-value construction errors were unreachable. Validate now runs
  first; zero still selects the default.

Test-quality fixes (found by auditing what the suite actually pins):

- The pipeline/AP 'retries on network error' tests were vacuous: the
  broken conn they seeded into the idle pool embeds a zero net.TCPConn,
  which the pool health check discards before use — both tests passed
  with retries disabled. They now use a dialer whose conn fails exactly
  one write at the wire after the handshake, and assert the failure was
  consumed and exactly one redial occurred, so the first attempt genuinely
  dies and the re-drive is exercised.
- The differential fuzz drove the blocking face sequentially, so every
  command took the lone-command fast path: zero multi-command batches,
  meaning batch demux had no randomized coverage. Added an async windowed
  phase (submit all ops, then read — the ordered default preserves submit
  order, so the sequential oracle still applies) with a counting hook that
  fails the test if no real batch formed. Validated by mutation: swapping
  two replies in dispatchCmds now fails the fuzz.
- The cluster NumShards default test asserted >= 2, which fails on
  single-vCPU runners (the default is clamp(GOMAXPROCS,1,16)); it now
  asserts the computed default.
- README: the AutoPipeline getters also return ErrClosed on a closed
  client, not only invalid-config errors.

* fix(autopipeline): async-face parity for metric-only result reads; CI and doc fixes

Publish/SPublish and XRead read their command's result only to record OTel
metrics. On the async autopipeline face a result read awaits execution, so
with no recorder installed these silently turned fire-and-forget publishes
into blocking calls. internal/otel gains Enabled() and the three sites are
gated on it; TestAsyncAutoPipelinePublishDoesNotAwait pins submission
completing inside the flush delay. The Eval family's post-issue read stays:
it normalizes NOSCRIPT to ErrNoScript, which Script.Run/RunRO depend on —
now documented as synchronous on the async face.

The failover client now registers its pools (main, pubsub, pipeline) for
OTel gauge metrics, matching NewClient; it previously registered none while
still unregistering on close.

example/client-side-caching (new with the csc master merge) is tidied
against this branch's root module — the govulncheck job failed loading it
(go mod tidy needed; no vulnerability involved), the same pattern as the
four examples fixed before the rebase.

Documented from the cross-cutting review: a hook must never issue a command
on the same autopipeliner and synchronously wait for it (the nested command
needs the dispatch slot the hook chain holds; the engine recovers only by
failing that flush after the 30s permit backstops, taking co-drained
bystanders with it); Options.Limiter is consulted once per batch dispatch
like a manual pipeline, not once per command; an autopipeliner created on a
WithTimeout clone is not stopped by the parent's Close and must be closed
explicitly. README caveats plus the armSelfDeadlockGuard and WithTimeout
doc comments carry the warnings.

* chore(autopipeline): consolidate pipeline test files; mark the feature experimental

Consolidate the five loose pipeline test files into two, mirroring the
engine PR's test consolidation: the dedicated-pool battery (handshake,
maintnotifications, streaming credentials) into pipeline_pool_test.go and
the execution-behavior tests (heterogeneous reply-shape demux, the
retry/re-drive loop) into pipeline_exec_test.go. Pure move — ten test
functions before and after, with section markers keeping provenance.

Mark autopipelining EXPERIMENTAL using the repo's existing marker line:
the API may still change as feedback lands. The marker covers every public
surface — the eight client getters (Client and ClusterClient, blocking and
async, no-arg and WithOptions), the AutoPipeliner and AutoPipelineOptions
types, both Default*Options constructors, the UniversalClient interface
block and Options.AutoPipelineOptions — plus banners in the README
autopipelining section and the example.

* chore(autopipeline): assert AutoPipeliner's UniversalClient conformance in universal.go

The compile-time assertion existed in autopipeline.go; move it into
universal.go's assertion block next to Client, ClusterClient and Ring,
where the interface and its implementors are declared.

* chore: gofmt — drop the double blank line left by the assertion move

* chore(autopipeline): skip the fallback options allocation when an override is supplied

getOrCreateAutoPipeliner called the fallback constructor (which allocates
a default AutoPipelineOptions) before checking for an explicit override,
discarding the result whenever one was provided (review finding by cursor
on #3867). Check the override first.

* fix(autopipeline): run SHUTDOWN and MONITOR outside pipeline batches

Both are on the typed command surface and rode pipeline batches. A batched
SHUTDOWN kills the server before it replies, so every batchmate failed
with EOF and the whole batch was retried against a dead server (late
review finding by cxljs on #3865). A batched MONITOR rebinds the shared
connection into a monitor stream, desyncing every batchmate reply behind
it. Both now execute directly on their own pooled connection, exactly like
blocking commands — one gate in submit(), shared by both faces.

The rest of the typed surface audits clean: Quit is a deprecated panic
stub; the connection-state commands (Auth, Select, Hello, SwapDB,
ClientSetName, ClientSetInfo) live on statefulCmdable and are not
reachable through the AutoPipeliner; ClientTracking is the same footgun on
a plain pooled client (client-side caching is the supported path); CLIENT
PAUSE and the BGSAVE/FLUSH family are server-wide and batch-safe; blocking
commands were already routed out via their read timeout.

TestAutoPipelineShutdownRunsOutsidePipeline pins both routings with
path-recording hooks against a guaranteed-closed port, so nothing real is
shut down by the test.

* fix(osscluster): honor a node-level hook's post-next verdict on clean sub-batches

processPipelineNode surfaced a node-level hook's error only for
short-circuits (hook never called next). A hook that called next and then
returned its own error — the node-level twin of the post-next case fixed
in dispatchCmds — had that verdict dropped: on an all-clean sub-batch
every command kept reporting success (suppressed Copilot finding on
#3867). Apply the same rule as AutoPipeliner.dispatchCmds: a post-next
error on a fully clean sub-batch can only be the hook's own verdict, so it
is applied to the commands; on a mixed batch the exec-recorded outcomes
win (hooks conventionally echo next's error, and stamping the echo would
overwrite successful replies). No remap for retry — retrying would re-run
the same hook. cmdsFirstErr reads rawErr, so the check cannot await on the
node goroutine.

TestClusterNodeHookPostNextError pins the behavior against a live cluster
with an armable post-next hook (disarmed during warmup so conn-init
handshake pipelines pass).

Also: example/autopipeline/go.mod pinned v9.21.0 while every other example
pins v9.22.0-beta.1 — aligned and tidied.

* fix: deflake the release-time push peek and the handoff-removal test

TestReleaseConnRemovesConnectionAfterPartialPushRead failed randomly
across matrix cells, and the root cause is in product code:
peekAndProcessPushNotifications set a 10µs read deadline after
MaybeHasData() had already confirmed kernel-buffered bytes. On a loaded
machine the deadline routinely expired before the first peek syscall ran;
the push processor treats a zero-consumed timeout as 'no pending data', so
a connection with buffered push bytes went back into the pool undrained
instead of being drained or removed. The first read never waits on the
network here — the deadline only needs to survive scheduler pauses — so
raise it to 1ms. Reproduced on the base branch (300 plain / 100 -race
iterations both fail); the same loops are fully green with the fix.

TestConnectionHook/ConnectionRemovalOnHandoffFailure slept a fixed 100ms
and then asserted the connection had been removed — racing the handoff
worker's three retries with backoff on slow runners. Poll for the removal
with a bounded deadline instead.

* feat(autopipeline): #4 tests and benchmarks (#3892)

* feat(autopipeline): #4 benchmarks and parametrized command-suite runs

Rebuilt on the post-#3867 integration: the branch's history carried
pre-rebase copies of pipeliner content that conflicted with the squashed
merge, so this is the branch's actual payload as one commit.

- autopipeline_bench_test.go: eight honest benchmarks — plain-client and
  manual-pipeline baselines, the dispatch path (per-command ns/op and
  allocs/op), the future face, Submit, zero-copy reads, cluster
  throughput, and the plain-vs-blocking-vs-windowed headline comparison —
  updated to the shipped API. Every fixed-duration driver divides by the
  timed region (the previous fixed divisor inflated cluster windowed ~2x
  and the plain baseline ~27%), zero-copy read buffers live outside the
  timed region so B/op reflects the command path, and every bench skips
  without a server. The earlier tuning sweeps (batch/flush-delay/buffer
  sizes) were deleted: at low parallelism they reported the configured
  MaxFlushDelay timer for every swept value — noise dressed as signal.
  autopipeline_bench_README.md rewritten multiplier-first: absolute numbers
  are machine/load-dependent, so the documented signal is the within-run
  ratio vs the plain-client baseline (plus environment-independent
  allocs/op); the main README's throughput claims got the same treatment.
- iterator.go: ScanIterator.Next read the command's page/cursor fields
  without awaiting the fetch, which only enqueues on the deferred
  autopipeline face — iteration spun re-issuing SCANs with a stale cursor
  forever, flooding the engine with an unbounded batch and hanging the
  caller. Next now awaits the fetch before reading (a no-op on every
  other path); TestAsyncAutoPipelineScanIterator pins it.
- Parametrized-suite fixes: BFScanDump/CFScanDump compared command structs
  (each async command carries its own batch pointer, so equality is
  structurally false there) — compare values; the SlowLog argument-count
  spec and the BLMoveM timeout spec now keep admin/conn calls on the raw
  client like their siblings; the commands/bitmap suites build the subject
  closer before their first assertion so a failed BeforeEach cannot leave
  AfterEach with a nil or stale closer.
- CI: the subjects job focus covers all nine parametrized suites (the old
  case-sensitive focus silently ran one), each subject run carries an
  explicit -timeout, and the run-tests action refuses an empty make
  target.
- Command-suite parametrization (#3885): the commands/array/bitmap/
  iterator/json/probabilistic/search/timeseries/vectorset suites run
  against a subject selected by GOREDIS_TEST_SUBJECT (client, ap-blocking,
  ap-async) via the newUniversalSubject harness, so the whole surface can
  be replayed through either autopipeliner face without touching specs.
- maintnotifications: a failed manager Close no longer drops the
  not-yet-processed additional pool hooks — they are put back so a retried
  Close still sees them.
- pipeline_buffer_test.go: skip, rather than fail, when the docker test
  server is absent.
- CI: a test-autopipeline-subjects job replays the command suite through
  both faces via GOREDIS_TEST_SUBJECT (run-tests action gains an
  injection-safe make-target input; Makefile gains the target).

Dropped from the old branch: stale pre-rebase engine-file copies
(superseded by #3867), its pre-consolidation AP test files (integration's
are newer and complete), and three unreferenced PNGs.

* chore(autopipeline): review polish — close leaked test clients, align the docs

The ScanIterator regression test closes its autopipeliner explicitly and
the RESP3 search suite closes client2 in AfterEach (it leaked one client
per spec) — both flagged by Copilot. From the cold-read review: the main
README attributed the parallel-batch ~10x multiplier to the blocking
face's ordered default (the bench README says the default lands lower —
the two now tell one story); pipeline_buffer_test.go gets its honest
pre-existing comment back; the Makefile comment says the subjects focus
'covers' every parametrized suite rather than 'lists explicitly'.

* fix(bench): feasible zero-copy load, exact b.N accounting; CI polish

The zero-copy benchmark at 64KiB payloads drove 500 goroutines through a
PoolSize-10 client — on a 4-vCPU CI runner that blows the default 3s write
deadline, and the newly surfaced errors (previously swallowed into the
ops/sec count) correctly failed the bench on two matrix cells. Large-payload
sizes now run 100 goroutines with generous deadlines, so the benchmark
measures the command path rather than write-deadline queueing; the B/op
result is unchanged (~100x less at 64KiB).

From Copilot's follow-up review: benchWindowed and the zero-copy driver
partition b.N exactly across workers (the first b.N%goroutines take one
extra op) instead of floor-and-min-1, so ns/op and allocs/op are per
command at every -benchtime — the old split over-ran for small b.N and
dropped the remainder for large; and the run-tests action invokes
make -- "$MAKE_TARGET" so a target value starting with a dash cannot
inject make options.

Also backtick MaxConcurrentBatches/Unordered in the README prose the
previous commit added — the spellchecker exempts code spans.

* fix(autopipeline): full review round for the master merge

Required fixes from the integration review: ReadStringInto's fast path
gates on len instead of cap, so the trailing CRLF can never be written
past the slice the caller handed over (a sub-slice of a packed buffer
exposed trailing capacity belonging to the next segment — the pattern the
zero-copy example itself teaches); cluster mapCmdsByNode is strictly
all-or-nothing again for user pipelines, with fan-out-policy commands
rejected at autopipeline submit through a new engine preflight hook (so a
merged batch cannot be poisoned and the lone-command path agrees with
batched dispatch); both pipeline read loops carry push-frame-mid-batch
regression tests (the cluster one mutation-validated); and Submit() marks
commands ready so result accessors before Wait() await instead of racing
the dispatcher.

Codex findings: a bad-conn push-drain error on the dedicated pipeline pool
removes the connection instead of pooling a desynced reader;
PipelineReadBufferSize gets the RESP3 minimum clamp on both construction
paths; a CSC WithTimeout clone's Close delegates to the owner's
Client.Close so cached autopipeliners stop and the closed flag flips.

Engine: AutoPipelineOptions.MaxBatchBytes caps flushes by approximate
payload volume and dispatch splits oversized drains into byte-bounded
chunks (batches still complete once, after all chunks) — many large values
flush as several bounded writes instead of one multi-megabyte burst that
can stall a constrained link past its write deadline; dispatch goroutines
recover panics into per-command errors instead of crashing the process (a
plain client unwinds a hook panic into the caller; the engine's internal
goroutines had nobody to recover); the outside-pipeline guard covers the
full connection-hostile command set for raw Submit/Do; internal backstop
timeouts surface ErrAutoPipelineTimeout rather than a misleading
context.DeadlineExceeded; ErrHookShortCircuit and ErrSubmitBlockingFace
are exported; the content-shard index is MinInt-safe.

Docs and tests per the review: pending-command String() formatting
documented as unsynchronized; AfterEach drains subjects before flushing in
the module suites; the CI subjects job claims results parity rather than
batch coverage; new tests cover RESP2 batched demux, blocking-face ctx
cancel, dispatch-panic containment, byte-capped batching, Submit misuse
under -race, push drains on both read loops, policy rejection on both
pipeline kinds, and the never-past-len reader guarantee; assorted minors
(govulncheck minor-series pin, apTestAddr in buffer tests, slotCache
happens-before note, twin-path drift warning, Close worst-case and
Validate caveats, EXPERIMENTAL markers on new exports).

* test: flush through the subject before closing it in the module suites

The previous commit ordered closeSubject() before rawClient.FlushDB — but
the closer closes rawClient, so the flush silently failed on a closed
client and state leaked across specs (SEARCH_INDEX_EXISTS on the next
protocol iteration, red on every CI test cell). Flush through the SUBJECT
instead, in the original order: on the async autopipeline face the flush
is ordered after any queued writes and closeSubject's drain guarantees it
executes, which is what the reorder was trying to achieve; on the plain
and blocking paths it is identical to the old rawClient flush.

* fix(autopipeline): deferred face returns immediately for diverted commands; timeseries AfterEach

Typed blocking and connection-hostile commands (BLPOP, SHUTDOWN, ...) were
executed inline on the deferred face, stalling the submitter for the full
block duration — Do already honored the deferred contract via its
background-goroutine escape hatch. The diverted submit path now routes
through the same runOutsidePipeline, keeping each face's call shape: the
blocking face runs synchronously, the deferred face returns immediately
with a ready batch and the result accessors block (review finding by
cursor on #3942). A regression test pins submission returning immediately
while BLPOP still blocks server-side for its full window.

Also: the timeseries suite gets the same subject-flush AfterEach the
previous commit applied to json/probabilistic/vectorset — flushing via
rawClient before closing the subject let a queued async write land after
the flush.

* test: count solo dispatches in the MaxBatchBytes test

The counting hook only watched the pipeline hook, but a drain cycle that
catches exactly one command takes the engine's lone-command fast path
(ProcessHook, no pipeline) — on a loaded runner one of the ten commands
split off that way and the total came up short. Record solo dispatches as
size-1 entries, filter to the test's own SET commands so a fresh-conn
handshake pipeline cannot pollute the counts, and allow the uncapped
control one straggler split.

* test: widen the sentinel resolution budget for loaded runners

The regression test demanded the full sentinel resolution chain inside a
500ms context — routinely missed on loaded CI runners over docker
networking. Three seconds keeps the signal (the retry-storm exhaustion it
pins burned the whole context regardless of size) without the flake.

* fix: legacy DisableIndentity mapping in cluster node options; align checkout action version

The cluster node clientOptions mapped the deprecated DisableIndentity
field from the NEW DisableIdentity option, silently ignoring a
user-supplied legacy value — Ring and the failover client already copy the
legacy field. Also bump the run-tests composite action's internal checkout
to v7, matching the workflow-level version. Both pre-existing upstream,
folded in on the way to master.

* fix(autopipeline): delegate cluster-wide commands, abort chunks after failed prefix, share the closed flag across clones

Three review findings (codex on #3942) plus a test seam:

AutoPipeliner now delegates ClusterClient's cluster-wide method overrides
(DBSize, ScriptLoad, ScriptFlush, ScriptExists) to the underlying client —
the embedded cmdable shadowed them and sent each to a single random node.

Chunked dispatch (MaxBatchBytes) stops dispatching remaining chunks once an
earlier chunk dies on a transport-class failure, failing them with the same
error: the unchunked path fails or retries the batch as a unit, so in the
ordered stream later commands must not overtake a failed prefix.
Per-command redis errors are normal chunk outcomes and do not abort.

The pool-closed flag (baseClient.apClosed) becomes a shared *atomic.Bool
copied by WithTimeout/clone: closing a clone closes the SHARED pools, so
the parent's AutoPipeline getters now see ErrClosed instead of building a
fresh flusher against dead pools. ClusterClient passes nil — its per-node
clients own their pools.

The maintnotifications Manager gets a shutdownTimeout field (default 10s,
behavior unchanged) so the Close put-back path — a failed shutdown returns
the pool hook and reopens the manager for a retry — is pinned by a test.

Tests: TestAPClusterWideCommandsDelegate,
TestDispatchChunkedAbortsAfterFailedPrefix (unit-level: through the public
API the byte cap wakes the flusher mid-submission, so one logical batch may
split into several drains and a single-dispatch assertion races),
TestWithTimeoutCloneCloseBlocksParentResurrection,
TestCloseRetriesAdditionalHookAfterShutdownError.

* fix: String() awaits deferred commands; share pooled-conn helpers between main and pipeline pools

Public String methods on commands read their result fields before
awaiting, so formatting an in-flight deferred-autopipeline command from
another goroutine raced with reply processing. Every String method now
calls await() first — the same accessor contract as Err/Val/Result. The
dispatch-gid guard in await keeps a hook that formats its own batch on the
dispatch goroutine safe (it sees the not-yet-executed view instead of
deadlocking), and cmdString remains the non-waiting internal formatter on
rawErr. The hook-peek regression tests now format commands from inside
hooks as well (review finding by ofek and cursor on #3942).

withPipelineConn duplicated getConn/releaseConn by hand and had drifted
again: the dedicated pipeline pool missed the client-side-cache post-read
probe on release and the connection-create-time metric. Conn preparation
and release now go through pool-parameterized helpers shared with the main
path (initPooledConn, releaseConnToPool), closing the drift class. This
also fixes a main-path slot leak — a conn that cannot be re-acquired after
its handshake is now Removed from the pool instead of abandoned.

The reported Acquire hang on Go 1.23+ timer channels is refuted
empirically and pinned by regression hammers: under synchronous timer
channels Stop returns TRUE for an expired-but-undelivered fire (the
delivery is aborted), returning false only once the value was actually
received — and the sole receiver is Acquire's own select, so a taken token
implies an unreceived fire and the drain branch is unreachable. Under
GODEBUG=asynctimerchan=1 (a consumer main module on go < 1.23) the fired
value sits buffered and the drain consumes it before pooled Reset-reuse.
The hammers race Release against timeout expiry under both semantics.

* fix(autopipeline): register cluster node executors on batches; harden pooled semaphore timers

A node-level hook reading a command result before next() — exactly what
redisotel's tracing does on cluster node clients via OnNewNode plus
rediscmd.CmdsString's Err() peek — permanently wedged the deferred cluster
face: the read blocked on a batch that completes only after the node call
returns, and the single dispatch-gid guard cannot vouch for the cluster
fan-out goroutines. Batches now carry a registry of node executor
goroutines, entered and left around each node call's hook chain, and the
accessor guards (await, Wait, WaitContext) treat registered goroutines
like the dispatcher: result reads get the not-yet-executed view instead of
self-deadlocking. Reproduced against a live cluster and pinned by
TestClusterNodeHookResultPeekNoDeadlock.

The pooled semaphore timers get a portable drain: it moves to a single
putSemTimer at pool put-back using select-with-default, correct under both
timer-channel semantics. Under go >= 1.23 synchronous channels Stop
discards a fired-but-undelivered value, so the drain branch is unreachable
— the default arm is a safety net so no semantics shift can ever turn it
into a blocking receive. Under GODEBUG=asynctimerchan=1 (a consumer main
module on go < 1.23) the buffered fire is consumed before Reset-reuse.
Stale-fire hammers pin both semantics against spurious instant timeouts.

* test: make the search RESP2/RESP3 equivalence suite hermetic

The subtests drop only the index between runs, so earlier subtests'
hashes were re-indexed by the next one (the aggregate subtest saw the
search subtest's third document), and waitForIndexing polled FT.INFO a
hundred times without sleeping — returning in microseconds on a partial
count. On a slow runner the index was still churning between the RESP2
and RESP3 queries, which surfaced as a transient extra empty row in the
RESP3 aggregate reply. Each subtest now starts from a flushed keyspace,
and the wait requires the exact document count with ingestion settled
(Indexing == 0), sleeping between tries.

* test: wait for search ingestion in the home_json doctests

The search examples write three documents and FT.SEARCH them back to
back, but indexing is asynchronous — on a loaded runner the hash example's
query raced ingestion and returned zero results. Both examples now wait
for the exact document count with ingestion settled before querying. The
wait lives in a separate non-example file and is called inside
REMOVE_START/REMOVE_END markers, so the generated documentation snippets
are unchanged.

* test: assert module-suite teardown; scale semaphore hammers under -short

The json, probabilistic, timeseries and vectorset suites ignored the
results of their AfterEach FlushDB and closeSubject calls. On the async
autopipeline subject the flush is enqueued and its failure only observable
through the command's Err(), while AutoPipeliner.Close reports nil — so a
failed teardown could silently leak keys into later specs. Both steps are
asserted now, and awaiting the flush also pins it to execute at teardown
time rather than inside the close drain.

The semaphore timer hammers scale down under -short (200k -> 2k race
iterations, 200 -> 20 stale-fire rounds), following the repo's existing
short-mode convention for stress tests; full runs keep full coverage.

* perf(autopipeline): restore the accessor guards' no-executor fast path

The node-executor guard replaced the dispatch-gid short-circuit in the
accessor guards (await, Wait, WaitContext) with a check that parsed the
goroutine id and took the batch mutex unconditionally. Every blocking-face
command and every pre-done future passes that guard once per wait, so
concurrent waiters serialized on one batch's mutex with a stack parse
each: the blocking face dropped from ~830k to ~138k ops/sec (6x) on the
loopback throughput benchmark, while the windowed face was unaffected
(its reads mostly land after batch completion).

isExecutorGoroutine now answers the common case — dispatch gid unset and
no registered node executors — from two atomic loads before any goroutine
id parse or lock; the mutex is reachable only inside a cluster batch's
node fan-out window. Blocking-face throughput measured back at the
pre-regression band, and the node-hook deadlock regression test still
passes: the guard still vouches for cluster node executors.

* fix(autopipeline): honor shared pool closure at submit; stamp ready under the stripe lock; divert conn-state cluster commands

Three review findings (codex on #3942):

A cached AutoPipeliner kept accepting enqueues after a WithTimeout clone
closed the SHARED pools — the shared closed flag guarded only the getters.
It now threads into the pipeliner and every submit path (batched and
diverted) checks it, so work is refused with ErrClosed at submit instead
of failing dispatch by dispatch against dead pools.

The deferred face published a command to its shard before setReady stamped
the gating batch, so a fast flush could take a command whose readyBatch
was still nil — the cluster node-executor registration would skip that
batch, and a node hook reading the command mid-dispatch could block on a
batch its own call chain completes. enqueue now stamps ready under the
stripe lock, before the command is visible to any drain; the ordering is
guaranteed by the lock both sides take.

readonly, readwrite and asking join the outside-pipeline divert set:
they mutate connection-scoped cluster state (replica reads, a pending
redirect) that must not leak onto shared pipeline connections — and
ReadOnly/ReadWrite are reachable from the typed surface, not just Do.

Tests: TestWithTimeoutCloneCloseStopsCachedAutoPipeliner,
TestEnqueueStampsReadyUnderStripeLock,
TestConnStateClusterCommandsRunOutsidePipeline. Throughput bands
re-measured unchanged after the enqueue-path change.

* fix(autopipeline): bound Close on tracked diverted dispatches; divert raw blocking commands; delegate HIMPORT

Six review findings (codex, cxljs and ofek on #3942), all reproduced in the
code before fixing.

Close and diverted commands (one coupled change). Commands that run outside
a batch — blocking and connection-hostile ones — executed on untracked
goroutines, so Close could return while such a command still held a pooled
connection. They are tracked now. Tracking alone would trade that leak for a
worse bug: an accepted command is deliberately never cancelled by Close (the
flush contract the batched path documents), so a zero-timeout blocking
command, or a stalled read with ReadTimeout disabled, would hang Close
forever. The wait is therefore bounded by the same backstop the permit wait
uses, and on expiry Close returns an error naming what is still in flight
rather than blocking its caller.

Raw blocking commands. Typed helpers mark themselves with a per-command read
timeout, which submit already diverts on; a raw Cmder built by hand carries
no such marker, so BLPOP and friends could be queued onto a shared pipeline
connection and hold it for the block duration. They are recognized by name
now, with XREAD/XREADGROUP classified by their BLOCK argument so the common
non-blocking form stays batched.

Cluster-wide overrides. The HIMPORT admin trio joins the delegation set: like
DBSize and the SCRIPT admin commands, ClusterClient fans them out to every
master and updates the shared fieldset registry, so running them on a single
routed node lets a later HImportSet for a key on another master fail. A
reflection test now walks ClusterClient's method set and fails when an
override that shadows the generic cmdable implementation has no delegate —
this class has been found by review twice.

Maintenance notifications. A delayed idle handoff for a connection from a
dedicated pipeline pool queued on the primary pool hook, whose request
carries the primary pool; a failed handoff then closed the connection
without freeing its slot in the pipeline pool. Handoffs now route to the hook
that owns the connection's pool, matched by the pool name already stamped on
every connection.

Close's bound is its own named constant rather than a borrowed one, with the
reasoning recorded next to it: the bound is only reachable when a dispatch
cannot end by itself, so a tighter value would not speed up healthy shutdowns
— it would make Close report failure while a legitimate slow drain (a large
final batch, a relaxed maintenance window during failover) is still finishing.

Docs. The slot-to-shard mapping is described as what it is — a batch-depth
heuristic. With non-contiguous slot ownership a shard's batch can still span
nodes; correctness is unaffected because the node router resolves each
command's own slot, and a key always lands on one shard so its relative
order holds.

Also lands the truth battery: thirteen end-to-end tests that check order,
reply demultiplexing, exactly-once execution, at-most-once under retries,
chunked-dispatch integrity, pipeline-pool isolation, cancellation honesty and
the Close drain against an INDEPENDENT ground truth — a client-side model
value, a server-side structure recording the real execution order, or a
differential run of the same randomized workload through a plain client.

* fix(autopipeline): close the divert registration race, honor shared close everywhere, order limiter reporting

Five review findings on the previous commit (cursor Bugbot and codex on
#3942), each reproduced in the code first.

Shared close. Do, DoRaw and DoRawWriteTo guarded on this pipeliner's own
closed flag rather than the combined check, so once a WithTimeout clone had
closed the shared pool set they still accepted commands and dispatched
against pools that were already gone. IsClosed had the same gap while its
documentation promised that the owning client's close counts. Every guard
that gates accepting new work now goes through the combined check, with the
invariant recorded beside it: only Close's own compare-and-swap reads the
instance flag directly, because it claims the shutdown rather than testing
for it.

Divert registration race. A diverted command could pass its closed check,
Close could then observe an empty wait group and return, and only afterwards
would the goroutine register — leaving an accepted command holding a pooled
connection past Close, and racing Add against Wait. Registration now happens
under a mutex with a closed re-check, and Close passes through that mutex
once after marking itself closed, so a registration either completes before
the wait or is rejected. The gate precedes publishing the command's batch:
publishing first and then rejecting would leave the command gated on a batch
nobody closes.

Blocking detection. The BLOCK token is matched the way the encoder renders
it — a raw Cmder may carry RESP keywords as []byte or *string, and a
string-only type assertion let those XREAD forms be batched onto a shared
connection.

Cluster preflight. Diversion is decided before the preflight runs. The
preflight keeps fan-out-policy commands out of shared pipelines, but a
diverted command never rides one: it goes through Client/ClusterClient
Process, where the cluster-wide aggregation works. Running the preflight
first rejected typed WAIT/WAITAOF on a cluster whenever command policies
were enabled.

Limiter ordering. The dedicated pipeline-pool path registered the limiter
report and the connection release as two defers, which run LIFO — the
connection went back to the pool before the result was reported, so a
circuit breaker could admit another pipelined operation before observing the
failure. The main pool reports first; both paths now share one deferred exit
that preserves that order and still reports on the acquire and handshake
failure paths.

* fix(autopipeline): bound the whole close drain; gate raw Do and telemetry on the divert contract

Four review findings from codex plus two from Copilot, each reproduced in the
code first.

Raw Do on the blocking face. Do, DoRaw and DoRawWriteTo carried their own
inline blocking-face branch that called Process directly, bypassing
runOutsidePipeline and therefore the divert registration gate added in the
previous commit. A raw blocking Do was untracked, so Close could observe no
diverted work and tear down shared resources while it still held a pooled
connection. Both faces now go through the one path that registers.

Close's bound covered only the last wait. A dispatch that nothing can end
sits just as easily in a flusher the wait group is waiting for, or in the
shutdown sweep's own dispatch, as in the final wait — so Close hung before
ever reaching the bound it documents. The whole drain tail (flusher exit,
final shard sweep, batch and diverted waits) now runs under a single bound,
and on expiry the error names the precise stage still outstanding, because a
wedged flusher and a wedged blocking command call for different operator
responses.

Telemetry no longer changes the async call shape. The metric emissions after
Publish, SPublish and XReadGroup read the command's outcome immediately,
which AWAITS on the deferred face: with a recorder installed, an
autopipelined publish blocked until Redis replied, and an XReadGroup with the
default Block: 0 could block indefinitely in what should be a submit. They
now consult a non-blocking readiness check and skip the metric for a
submission that has not executed; emitting these from the execution path is
a follow-up in the OTel wiring rather than something a command wrapper can do.

Two Copilot findings: the ConnPool.Name doc block added last round swallowed
Size()'s Godoc, leaving an exported method undocumented — restored; and the
pipeline-buffer tests left fixed keys behind, one of which (test_key) is
also used by another test file in the package, which is exactly the
order-dependence that produced the search-suite flake earlier in this PR.

* fix(autopipeline): stop Config leaking internal sharding; keep Eval non-blocking on the deferred face

Two review findings from codex plus one from Copilot.

Config leaked an internal invariant. The effective config returned to callers
carried contentSharded, the bit cluster wiring sets to record that shards are
slot-routed and therefore cannot reorder same-key commands. Copying that
config into a standalone deferred autopipeliner silenced the check that
requires Unordered for NumShards>1 — and round-robin shards genuinely do
flush concurrently and break submit order. Internal-only fields are now
cleared before the copy is handed out.

Eval was synchronous on the deferred face. Normalizing NOSCRIPT to
ErrNoScript read the command's error immediately after submitting, which
awaits execution, so the whole Eval family lost the deferred call shape — a
caller submitting a window of scripts, or running one slow script, blocked in
the submit. The normalization now runs only when the result is already
readable, and Script.Run/RunRO accept the server's raw NOSCRIPT error so the
EVAL fallback keeps working when it was skipped. The synchronous and blocking
faces are unchanged; on the deferred face the raw error is byte-identical to
ErrNoScript, so errors.Is and MatchError assertions still hold.

Copilot: the pipeline-buffer key cleanup added in the previous commit ran
before the reachability check, issuing a DEL against a server that might not
be there and turning a unit-only run into a failure instead of a skip. The
four call sites skip first now, and the helper documents that ordering.

* fix(autopipeline): keep a no-retry command's policy off its batchmates; complete the blocking set

A command that forbids retries — today the zero-copy reads, whose reply
decodes into a caller-owned buffer that a retry could not un-write —
disables retries for the entire slice it is dispatched in. In a shared
autopipeline batch that silently stripped retries from unrelated callers'
ordinary commands, which would have been retried had they been flushed on
their own. Mixed batches are now partitioned by retry policy and each group
dispatched separately; uniform batches are returned untouched, so only the
mixed case pays a second dispatch. Splitting rather than diverting the
no-retry commands keeps zero-copy reads pipelined — measured after the change
at 507k ops/sec and 497 B/op for a 4 KiB value, still ahead of the regular
path.

The raw-blocking allowlist was incomplete: blmovem, migrate, and the
argument-driven ts.read BLOCK form could be queued onto a shared pipeline
connection when submitted as raw Cmders, holding it for the block duration.
The set is now derived from the typed helpers that call setReadTimeout rather
than enumerated by hand, with that derivation recorded beside it and a test
that pins every name and both arg-driven forms.

Also rewords PipelineReadBufferSize's tuning guidance, which described the
write side — sizing so a batch flushes without overflowing mid-write — and
cross-references the option that guidance actually belongs to.

codex's remaining finding, that ClusterClient.PoolStats does not fold in the
per-node pipeline pools, is a monitoring gap rather than a correctness one and
is left as a follow-up.

* fix(otel): read the global recorder under its lock when registering pools

RegisterPools and UnregisterPools read globalRecorder directly while
SetGlobalRecorder writes it under recorderMu, so installing telemetry
concurrently with creating or closing a client is a data race. A regression
test reproduces it — two race reports under -race before the change, clean
after — and both helpers now go through getRecorder.

Also fixes the pipeline-buffer key cleanup added earlier in this PR: its
post-test sweep ran in a t.Cleanup, which executes after the test function's
defers, and every caller defers client.Close(). The DEL therefore landed on a
closed client and silently did nothing, leaving exactly the keys the helper
exists to remove. The sweep now uses its own short-lived client and reports a
failure rather than swallowing it (both found by Copilot on #3942).

* fix(autopipeline): isolate retry policy without reordering; honor successful hook short-circuits

The no-retry isolation added in the previous commit split a mixed batch into
two policy groups and dispatched the retryable one first, which REORDERED the
stream: a zero-copy read submitted before a write to the same key executed
after it, so the read observed the new value on a face that promises submit
order. The batch is now sliced into contiguous runs and dispatched in order —
each dispatched pipeline is still policy-uniform, so a no-retry command cannot
strip retries from batchmates, and no command overtakes another. Uniform
batches are still one dispatch with no allocation. Both new tests fail if the
grouping is reintroduced, including the end-to-end one that reads the wrong
value.

A hook that returns nil WITHOUT calling next has short-circuited
successfully: it served the batch itself and set the command values, which is
exactly what a caching or mocking hook does and what plain Pipeline and Client
hooks are permitted to do. The engine rewrote that success into an error, so
such a hook worked on a pipeline but failed every autopipelined batch. All
five sites now propagate only the hook's own error, and the sentinel this PR
added for the synthesized case is retired.

Cluster commands whose routing is not slot-derived — ReqSpecial, such as
FT.CURSOR READ, which is sticky to the node holding the cursor — are now
diverted instead of coalesced. A solo flush reached ClusterClient.process and
its special routing, but inside a batch mapCmdsByNode routes by slot and can
send the cursor read to the wrong shard, which only shows up under concurrent
traffic. Diverting keeps them working on their own connection rather than
rejecting them (all three found by codex on #3942).

* fix(autopipeline): share the prefix-abort rule; report submit rejections from Process

Breaking a batch into ordered pieces happens in two places now — the
MaxBatchBytes chunker and the retry-policy runs — and each independently got
the same rule wrong: once a piece dies on a transport-class failure, the
remaining pieces must not be dispatched, because the unchunked path fails or
retries the batch as a unit and later commands must never overtake a failed
prefix. The runs loop dispatched them anyway. Both callers now go through one
dispatchSequential helper that owns the rule, so a third variant cannot drift
out of line; per-command redis errors remain normal outcomes that do not stop
the sequence.

The deferred face's Process discarded submit-time rejections. A closed
pipeliner or a cluster preflight refusal sets the error on the command and
queues nothing, so returning nil told the caller the command was accepted when
it would never run — and code reaching the engine through
UniversalClient.Process sees only that return value. Those rejections are
returned now, identified by the shared completed batch rather than by probing
for an error, so execution failures still stay off this path: the call must not
wait.

The type documentation still said String()/%v on a pending deferred command
does not wait. It has waited since the accessor round, and a test pins exactly
that, so the doc was steering users toward a race that no longer exists. It now
describes the real contract, including the dispatch-goroutine exception, and
points at Name()/Args() for logging a submission without waiting (all three
found by codex on #3942).
2026-08-03 19:59:50 +03:00
Nedyalko Dyakov 82b0213da3 chore(release): prepare 9.22.0-beta.1 (#3940)
- add 9.22.0-beta.1 release notes
  - note Redis 8.10 support in the README
  - bump version to 9.22.0-beta.1 in version.go and all extra/example go.mod files
2026-07-30 13:26:37 +03:00
Nedyalko Dyakov 90fd08805d chore(ci): point 8.10 testing at custom client-libs-test image (#3938)
Switch the 8.10.x version mapping in CI, the doctests service image,
and the local Makefile/docker-compose defaults from 8.10-rc2 to
custom-30445126297-debian.
2026-07-29 15:21:36 +03:00
Nedyalko Dyakov 93f961af67 feat(timeseries): support multiple aggregators per key in TS.NRANGE (#3937)
RedisTimeSeries (RedisTimeSeries/RedisTimeSeries#2079) lets TS.NRANGE and
TS.NREVRANGE apply several aggregators to one key: each key gets its own
AGGREGATION spec token, and a spec may list multiple comma-separated
aggregators (e.g. AGGREGATION avg,max sum <bucketDuration>).

The Go API could only express one aggregator per key. Change
TSNRangeOptions.Aggregators and TSNRevRangeOptions.Aggregators from
[]Aggregator to [][]Aggregator: one spec per key, comma-joined into a
single wire token. Validation now also rejects empty specs, and the pivot
row docs describe the flattened (key, aggregator) value layout.

Breaking for the unreleased TS.NRANGE API only; matches redis-py
(redis/redis-py#4225).
2026-07-29 15:05:46 +03:00
Nedyalko Dyakovandofekshenawa 49e004104c feat(himport): HIMPORT command with lazy per-connection prepare (#3919)
* feat(himport): HIMPORT command family with lazy per-connection prepare replay

Fieldsets are server-side session state scoped to one physical
connection, so pooled HIMPORT SET needs the fieldset prepared on
whichever connection executes it. Instead of replaying PREPARE in the
handshake, the client keeps a versioned fieldset registry and a
per-connection prepared flag, and injects the PREPARE into the same
round trip right before a SET that needs it - at most once per
connection session. A 'no such fieldset' reply for a registered
fieldset invalidates the flag and grants one retry that re-prepares.

* fix(himport): drain push notifications between injected PREPARE and command replies

proto.Reader.ReadReply consumes a RESP3 push frame as a normal reply, so
a notification arriving between the injected PREPARE's reply and the
command's reply was read as the command's reply and desynced the
connection. Drain pending notifications again after reading the PREPARE
replies, mirroring what the pipeline read path does before every reply.

The regression test drives a RESP3 mock server that emits a push frame
between the two replies; without the drain the SET fails with "can't
parse reply=\">2\"".

* fix(himport): wire registry into failover and tx clients, invalidate stale pipeline flags

Two review fixes for the lazy prepare-replay machinery:

- NewFailoverClient built its baseClient without the HIMPORT registry,
  so HImportPrepare appeared to succeed but never registered and pooled
  HImportSet replay never engaged. Tx (client.Watch) now shares the
  parent client's registry the same way Client.Conn() does, since its
  sticky pool borrows connections from the parent pool.

- A pipelined HImportSet failing with "no such fieldset" left the
  connection's prepared flag stale, so even a caller-initiated retry of
  the pipeline failed forever on that connection. The failed exec now
  invalidates the flag and the retried pipeline replays the PREPARE.
  Pipelines are deliberately not auto-retried: re-running the batch
  would repeat side effects of commands that already succeeded.

* test(himport): skip integration specs when the server lacks HIMPORT

The 8.10.x CI image is a pre-release build without the HIMPORT patch
series, so the version gate alone let the specs run into "ERR unknown
command 'himport'". Probe with HIMPORT DISCARDALL and skip when the
command family is absent.

* docs(himport): add example app

Walks through prepare/set basics, pipelined bulk ingestion timed
against plain HSET, concurrent pooled writers relying on lazy replay,
the template encoding surfaced by OBJECT ENCODING and INFO memory, and
fieldset discard semantics. Exits gracefully when the server does not
support HIMPORT.

* feat(himport): propagate discards lazily to all pooled connections

HIMPORT DISCARD/DISCARDALL execute on one borrowed connection, so other
pooled sessions kept their fieldset copies: a subsequent HImportSet was
nondeterministic (it succeeded against a session that still held the
fieldset and failed elsewhere), and the leftover copies held server
memory until the connections closed.

Discards now propagate the same way prepares do. A discarded name stays
in the client registry as a tombstone and DISCARDALL bumps a registry
epoch; before any HIMPORT command on a connection, the client injects
HIMPORT DISCARD for tombstoned fieldsets that session still holds, or a
single HIMPORT DISCARDALL when the session predates the current epoch.
Re-registering a name clears its tombstone - the replayed PREPARE
already replaces the fieldset server-side.

Post-discard behavior is now deterministic: HImportSet fails with "no
such fieldset" on every pooled connection, exactly as on a single
connection. Non-HIMPORT traffic never pays for the cleanup; sessions
that stop running HIMPORT commands are cleaned up by connection close,
as before.

* fix(himport): guard root-cause swap, discard stale fieldset

- the single-command path replaced the SET reply with an injected
  PREPARE failure even when the SET succeeded or failed with an
  unrelated error; gate it on "no such fieldset" like the batch path
- an injected re-prepare that fails leaves the session holding the
  older fieldset version, and the pipelined SET behind it then writes
  the old field names silently; discard the held version first so the
  SET answers "no such fieldset" and the root cause is surfaced
- move the test-only registry accessors epoch/empty into the test
  file: the linter runs with tests excluded and flags them as unused

* fix(himport): align recovery and discard semantics with HLD v21

- Pipelines: HIMPORT SETs of a registered fieldset that fail with "no
  such fieldset" are re-prepared and re-issued once on the same
  connection - and only those SETs; HIMPORT SET is a full replace, so
  the re-execution is idempotent and no other command of the batch runs
  again. The error therefore never surfaces for managed fieldsets
  (NF.4). Transactions keep surfacing it - an executed MULTI/EXEC
  cannot be partially re-run - and the invalidated flags make the
  caller's transaction retry succeed.

- HImportDiscard/HImportDiscardAll report the registry lifecycle per
  the HLD method shapes: 1 when the fieldset was registered on this
  client and removed, and the count of registered fieldsets removed.
  The executing session's reply passes through only for names never
  registered through the managed API.

- Document that HIMPORT commands sent through generic interfaces (Do,
  raw Cmd values) bypass the registry and carry none of the managed
  guarantees; the root README generalizes the caveat to every command
  that alters connection session state (SELECT, CLIENT SETNAME, RESET,
  CLIENT TRACKING, ...) and points session-scoped work at Conn().

The working tree also carries the remote "guard root-cause swap,
discard stale fieldset" changes verbatim, so the merge below resolves
cleanly.

* feat(himport): cluster and ring support via shared registry and fan-out

Every ClusterClient node client and Ring shard client now shares one
HIMPORT fieldset registry, adopted at client creation - replicas
included, since roles change with the topology: a promoted replica's
connections carry no prepared flags, so their first HIMPORT SET
replays the PREPARE lazily, and MOVED/ASK redirects re-prepare on the
target node through the existing per-connection machinery with no
cluster-specific code.

On top of the shared registry, user-issued PREPARE, DISCARD, and
DISCARDALL fan out eagerly to all masters (R.4) or all ring shards:
one connection per node is prepared or cleaned up front, server-side
validation surfaces immediately, and leftover session state stays
bounded. Fan-out copies carry a pre-assigned registry version/epoch so
the registry is touched exactly once per operation; a deterministic
server rejection (e.g. duplicate field name) withdraws the
registration, while transport failures keep it and lazy replay covers
the connections the fan-out missed (all-succeeded reporting).

* chore(ci): add fieldset to the spellcheck wordlist

* fix(himport): wire cluster pipelines into the managed layer, close review findings

Review findings from three independent passes (wire protocol,
concurrency/state, HLD compliance):

- ClusterClient pipelines and tx pipelines wrote commands directly to
  node connections, bypassing HIMPORT entirely: no PREPARE replay (the
  fan-out prepares one connection per master, so pooled pipelines hit
  "no such fieldset" nondeterministically), no registry updates for
  pipelined PREPARE/DISCARD (a pipelined DISCARD was later resurrected
  by lazy replay), no re-prepare after MOVED/ASK re-queues, and no
  stale-version defense. processPipelineNodeConn and
  processTxPipelineNodeConn now inject and consume the same HIMPORT
  bookkeeping commands as the standalone paths; failed SETs of
  registered fieldsets re-queue through the pipeline's attempt budget,
  which re-prepares lazily on the next attempt.

- A transport failure inside the standalone pipeline's HIMPORT retry
  round trip signalled canRetry after the first round trip had been
  fully consumed, so the outer loop re-executed the whole batch and
  double-applied non-idempotent commands whose results were already
  delivered. The retry now reports the error without requesting a
  batch retry; the connection is still discarded.

- The handshake Conn handed to OnConnect (and the re-auth Conn) had a
  private HIMPORT registry whose versions restart at 1: an OnConnect
  prepare could mark a pooled connection at a version number colliding
  with the client registry's, silently skipping the replay of a
  different fieldset definition. Both now share the client's registry.

- A fan-out PREPARE rejected by one master withdrew the registration
  without a tombstone, leaving unaddressable fieldsets alive on the
  masters where the fan-out had already succeeded. The withdrawal now
  tombstones, so those sessions are cleaned on their next HIMPORT use.

- NewRing stored the ring-wide registry in the caller-owned
  RingOptions, so two rings built from one options struct shared and
  clobbered each other's registry. NewRing now copies the options.

- Rewrite the stale pre-cluster godoc on HImportPrepare that claimed
  ClusterClient/Ring do not fan out, and document the best-effort
  fan-out failure semantics on the cluster/ring methods.

- newConn now takes the HIMPORT registry as an explicit parameter
  instead of allocating a throwaway one that every call site
  overwrote.

* fix(himport): skip set re-queue when the batch retry already carries it

* fix(himport): scope re-issue failures, unmark while holding the conn

Three review findings:

- A transport error inside the pipeline set re-issue round trip was
  returned to generalProcessPipeline, which stamped it onto every
  command with a nil error - including commands whose results were
  already delivered by the fully-consumed first round trip. The error
  now stays on the retried SETs; the batch neither retries nor fails
  wholesale, and the connection, which may hold unread replies, is
  marked for removal on release (MarkCloseOnPut). Regression-tested by
  dropping the connection on the replayed PREPARE and asserting the
  delivered plain SET keeps its OK result.

- The single-command recovery unmarked the prepared flag from
  process(), after withConn had already released the connection,
  mutating flags on a connection another goroutine may own. The
  invalidation now happens inside _process while the connection is
  still held; himportRecover became himportShouldRetrySet, a pure
  retry decision.

- A connection's discard-all epoch could in principle be overwritten
  backwards by a mark carrying an older registry snapshot. The epoch
  is now forward-only on marks; a stale snapshot can no longer regress
  a session wiped at a newer epoch.

The working tree also mirrors the master-merge changes to _process
(assertUnstableCommand removal) so the merge below resolves cleanly.

* fix(himport): survive mass session loss, honor close-on-put in sticky pools

Deep-review findings from three independent passes (fresh-eyes diff,
behavioral simulation against a session-faithful mock, regression
safety), plus one final-review nit:

- Mass session loss (failover, cross-region switch, reset storms)
  could beat the single-command retry-once budget: the retry landed on
  a different connection whose session was wiped by the same event but
  whose prepared flag still looked current, so the PREPARE was not
  injected and "no such fieldset" surfaced for a managed fieldset
  (deterministic repro: 2-3 surfaced errors per 1200 SETs in a reset
  storm). A "no such fieldset" failure now bumps the fieldset's
  registry version (fields kept, guarded against concurrent
  re-registrations, once per batch), staling every connection's mark
  so the retry re-prepares wherever it lands.

- StickyConnPool.Put ignored MarkCloseOnPut, so a Conn/Tx whose
  HIMPORT re-issue round trip failed on a transport error got the same
  connection back with the re-issued replies still unread - the next
  command read a leftover reply as its own (live repro: HImportDiscard
  parsing "OK" as an integer; a status-typed command would accept the
  wrong reply silently). Sticky Put now records the mark as a bad-conn
  error, Get refuses the connection, and the parent pool removes it on
  unwind.

- The standalone pipeline and tx-pipeline paths stamped no per-command
  errors when the injected-reply read died on a transport error: the
  outer retry loop stamps only on its exit branch, not when the
  attempt budget runs out mid-loop, so Exec surfaced the error while
  every command still reported Err() == nil. Both paths now stamp the
  batch like a write failure, matching the cluster pipeline; the
  regression test drives it through the mock (connection dropped on
  the injected PREPARE, MaxRetries -1).

- The "no such fieldset" check after every single-command reply ran
  before any type check; its errors.As chain allocates, costing every
  non-HIMPORT command 2 heap allocations (+32 B, ~21 ns, +12.5%
  allocs/op for a simple SET). The HImportSet type assertion now runs
  first: ~1 ns and zero allocations for everything else.

Doc hardening: cross-node PREPARE+SET pipelining limitation on
ClusterClient, tombstone growth bound, the recovery attempt granted
under MaxRetries -1, and the ASKING re-issue comment corrected.

* docs(himport): mark the HIMPORT API as experimental

The server command family ships behind the hinted-hash-templates
feature of Redis 8.10 pre-releases and its surface may still move;
the typed client API (methods, HImport*Cmd types, constructors)
follows it. Same wording and placement as the vectorset commands.

---------

Co-authored-by: ofekshenawa <104765379+ofekshenawa@users.noreply.github.com>
2026-07-29 14:52:32 +03:00
Nedyalko Dyakov 3dd9675caf fix(proto): peek push notification name without demanding 36 bytes (#3936)
PeekPushNotificationName started with bufio.Peek(36), which blocks until
36 bytes are buffered. A complete subscribe confirmation for a channel
name of six or fewer characters is shorter than that, and on an
otherwise idle connection the peek waited forever, hanging
PubSub.Receive (regression from #3842).

Parse from the bytes already buffered instead, and when the frame prefix
is valid but incomplete block for exactly one more byte before
re-parsing. An incomplete prefix means the server is still committed to
sending the rest of the frame, so that byte always arrives.

Fixes #3935
2026-07-29 13:43:15 +03:00
d11bf62381 chore(tests): run the Redis Enterprise suite against an external cluster (#3905)
* test: run the Redis Enterprise suite against an external cluster

Read the target database connection (host, port, username, password, TLS)
from REDIS_ENDPOINTS_CONFIG_PATH when RE_CLUSTER=true, and apply it to the
clients the suite builds (redisOptions, the per-suite setupRedisClient
closures, the RediSearch constructions, and the universal-client spec).
RE_DB_NAME selects a named database; when the env var is unset behaviour is
unchanged (localhost:6379, no auth).

Label specs that cannot meaningfully run against a remote, authenticated,
managed Redis Enterprise deployment NonRedisEnterprise, each with an
explanatory comment (client-internal/local-only, OSS-vs-Enterprise
behaviour, or impractical over network latency).

* test: skip the local-TLS tests when running against Redis Enterprise

TestTLSStandalone and TestTLSRedissURL are plain Go tests, so the
NonRedisEnterprise Ginkgo label filter cannot exclude them, and they
t.Fatalf when the docker-compose TLS server (localhost:6666) is absent.
Skip them explicitly on RE_CLUSTER=true runs; local runs keep failing
loudly when the TLS server is expected but missing.

* test: harden Redis Enterprise endpoint resolution

Fall through to the endpoint URLs when a raw endpoint carries no DNS
name instead of producing a ":port" address that dials localhost, and
share the relaxed timeout values between the Options and
UniversalOptions overlays so the two cannot drift.

* test: run the Auth spec against password-protected servers

A failed AUTH is reported as "ERR AUTH ..." by a passwordless OSS
server but as "WRONGPASS ..." by a password-protected one (e.g. Redis
Enterprise), so accept either and drop the NonRedisEnterprise label.

The two failing AUTH commands now go through separate pipelines: on a
password-protected Redis 8.x a second failed AUTH in the same pipeline
is throttled by the server's brute-force protection and never answered,
which stalls the read until the client times out.

* fix(test): bracket IPv6 endpoints with net.JoinHostPort

url.Hostname() strips the brackets from an IPv6 literal, so joining
host and port with "%s:%d" produced an undialable "::1:6379".
JoinHostPort restores the bracketed form; add a regression test
pinning the bracketed Addr.

---------

Co-authored-by: kiryazovi-redis <ivaylo.kiryazov@redis.com>
Co-authored-by: ofekshenawa <104765379+ofekshenawa@users.noreply.github.com>
2026-07-29 13:36:52 +03:00
Nedyalko Dyakovandofekshenawa 8ba2e2a20f feat(search): add COLLECT reducer for FT.AGGREGATE (#3886)
* feat(search): add COLLECT reducer builder + response decode for FT.AGGREGATE

Add FTAggregateCollect options, NewCollectReducer constructor, and
AggregateBuilder.Collect that render the REDUCE COLLECT clause (FIELDS/
DISTINCT/SORTBY/LIMIT) with automatic @-prefixing and arg-count. Add
AggregateRow.Collect to decode the sparse RESP2/RESP3 entry array into a
uniform CollectColumn. Integration-tested against Redis 8.8. Additive:
no existing type or serializer changed.

Requires Redis 8.8+ with search-enable-unstable-features.

* fix(search): validate COLLECT args, harden integration test

- reject empty field names and ASC+DESC combinations client-side,
  matching the FT.AGGREGATE pipeline SORTBY error wording
- gate the integration test on the live server version via INFO;
  the float REDIS_VERSION check parses 8.10 as 8.1 and rots
- restore search-enable-unstable-features, drop the index, and Del
  only the test's own keys instead of FlushDB; remove broken itoa

* docs(example): add FT.AGGREGATE COLLECT reducer example

Runnable example for the COLLECT reducer, mirroring the
search-aggregate-steps layout:

- top-2 products per category via NewCollectReducer (explicit FIELDS
  with SORTBY rating DESC and LIMIT), next to a plain COUNT reducer
- whole documents per brand via AggregateBuilder.Collect with LOAD *
  and FIELDS *
- replies decoded with AggregateRow.Collect (RESP2/RESP3 agnostic)

Verified live against redislabs/client-libs-test:8.8.0. Notes in code
and README: on current unstable builds, SORTBY combined with FIELDS *
drops the sort field from the entries and does not sort - use an
explicit FIELDS list when sorting.

* chore(git): remove binary

---------

Co-authored-by: ofekshenawa <104765379+ofekshenawa@users.noreply.github.com>
2026-07-29 13:20:27 +03:00
Nedyalko Dyakovandofekshenawa ce8d3b053f feat(timeseries): add TS.QUERYLABELS support (#3926)
* feat(timeseries): add TS.QUERYLABELS support (Redis 8.10)

- TSQueryLabels lists label names, TSQueryLabelValues the values of
  one label, across series matching optional TS.QUERYINDEX-style
  filters
- the FILTER token is emitted only when expressions are given (the
  server rejects a bare FILTER); expressions pass through verbatim
  and the VALUES label is matched byte-exactly
- replies are unordered, server-deduplicated string sets under both
  RESP2 and RESP3; empty replies are success, not errors
- wire-format unit tests plus a RESP2/RESP3 integration test that
  gates on the live server supporting the command
- runnable example under example/ts-querylabels

* fix(tests): use skip for tests

* test(timeseries): version-gate TS.QUERYLABELS

Replace the standalone probe test (command probing plus the
REDIS_QUERYLABELS_TEST_ADDR env var) with Ginkgo specs in the main
suite, gated on SkipBeforeRedisVersion("8.10"). The plain go test also
called the Ginkgo-based SkipBeforeRedisVersion helper, which panics
outside the suite when it actually skips; moving the specs into the
suite fixes that. Covers both command forms under RESP2 and RESP3.

* chore(deps): bump grpc to v1.82.1 in otel examples

govulncheck fails every PR on GO-2026-6061 in google.golang.org/grpc
v1.80.0 (indirect via the OTLP exporters); v1.82.1 carries the fix.

---------

Co-authored-by: ofekshenawa <104765379+ofekshenawa@users.noreply.github.com>
2026-07-28 19:40:04 +03:00
Nedyalko Dyakovandofekshenawa 279615082b feat(search): add FT.ALIASLIST support (#3925)
* feat(search): add FT.ALIASLIST support (Redis 8.10)

- FTAliasList(ctx, index) returns the aliases of an index as an
  unordered, server-deduplicated collection; an alias-free index
  yields an empty result, not an error
- server errors (SEARCH_INDEX_NOT_FOUND, NOPERM) propagate unchanged;
  the argument must be an index name, aliases are not resolved
- wire-format unit test plus a RESP2/RESP3 integration test that gates
  on the live server supporting the command (the REDIS_VERSION float
  cannot tell 8.10 from 8.1)
- runnable example under example/ft-aliaslist

* fix(search): route FT.ALIASLIST as keyless in cluster

The static FT policy table covers aliasadd/aliasupdate/aliasdel but
not aliaslist, so the default cluster resolver returned nil and a cold
command-info cache fell back to treating the index name as a key.
Register it keyless and read-only like the other alias commands.

* test(search): version-gate FT.ALIASLIST specs

Replace the standalone probe test (unknown-command sniffing plus the
REDIS_ALIASLIST_TEST_ADDR env var) with Ginkgo specs in the main suite,
gated on SkipBeforeRedisVersion("8.10"), which compares major.minor as
integers and so distinguishes 8.10 from 8.1. The specs cover RESP2 and
RESP3 and follow the suite conventions (FlushDB per spec, :6379).

* chore(deps): bump grpc to v1.82.1 in otel examples

govulncheck fails every PR on GO-2026-6061 in google.golang.org/grpc
v1.80.0 (indirect via the OTLP exporters); v1.82.1 carries the fix.

* fix(search): route policy-table keyless commands on cold cache

ClusterClient.process computes the slot before routeAndRun consults
the policy table, and with a cold command-info cache
cmdFirstKeyPosWithInfo fell back to treating argument 1 as a key — so
FT.ALIASLIST (and the other keyless FT commands) hashed the index name
into a slot for the first calls of a process. Consult the static
policy table there: entries with default request routing and a keyless
response policy resolve to keyless. Key-carrying (ft.suglen) and
specially-routed (ft.cursor) commands keep the old fallback.

---------

Co-authored-by: ofekshenawa <104765379+ofekshenawa@users.noreply.github.com>
2026-07-28 19:14:28 +03:00
Nedyalko Dyakov 5c82f57a47 chore(options): align default config values with cross-SDK proposal (#3918)
* feat: align default config values with cross-SDK proposal

  Read/Write timeout 3s -> 5s, retry backoff 8ms/512ms -> 10ms/1s,
  cluster state reload 10s -> 60s, TCP keep-alive 5min blanket ->
  30s idle / 5s interval / 3 probes via net.KeepAliveConfig.

* test(options): pin new defaults, share keep-alive config

- extract defaultKeepAliveConfig and use it from both default dialers
  so options.go and sentinel.go cannot drift apart
- pin the 10ms/1s retry backoff defaults across Options,
  ClusterOptions and RingOptions, plus the -1 escape hatch, the 60s
  ClusterStateReloadInterval default, and the keep-alive values
- drop the go1.7 build tag from options_test.go: since Go 1.21 such a
  tag downgrades the file's language version, which rejects
  net.KeepAliveConfig
2026-07-23 12:56:10 +03:00
Nedyalko Dyakovandofekshenawa 76322e0bac chore(ai): add claude skills (#3855)
* chore(ai): add claude skills

* add readme for claude configs

* chore(ai): add /check-ci slash command

* chore(ai): share repo guidance via AGENTS.md

Move the layout/commands/architecture/conventions guidance into AGENTS.md so non-Claude agents (Codex, Cursor, Aider, ...) read the same source. CLAUDE.md now imports it via @AGENTS.md and keeps only Claude-specific notes (skills auto-trigger, slash commands). Commands are inlined rather than delegated to skills, since other tools cannot read skills.

* chore(ai): add prepare-release skill

Playbook to stage a release locally (semver pick, PR gathering, RELEASE-NOTES.md per template, scripts/release.sh bump, scripts/tag.sh dry-run) without ever tagging, pushing, or committing. List it in AGENTS.md; drop CLAUDE.md's duplicate skill enumeration so names live only in AGENTS.md.

* update add command skill.

* chore(ai): grant allowed-tools to skills

add-command: WebFetch (fetch spec + docs). prepare-release: Bash(git describe:*), Bash(gh pr list:*).

* chore(ai): update settings.json

* docs(ai): fix test commands and stale references

- ginkgo focus example used ./..., which fails every non-ginkgo
  package with 'flag provided but not defined'
- TestPoolNew does not exist; point at TestConnStateMachine
- use real CI check names in /check-ci and lead with test-redis-ce
- correct keyless-command example, backoff wording, file pointers;
  swap drifting line refs for symbol anchors
- normalize settings.json permission style; drop personal caveman
  block from .gitignore and the /loop reference

---------

Co-authored-by: ofekshenawa <104765379+ofekshenawa@users.noreply.github.com>
2026-07-22 17:02:29 +03:00
Nedyalko Dyakov 5c4977368b fix(redisotel): avoid data race on shared attrs during MinIdleConns warmup (#3881)
attribute.NewSet sorts and de-duplicates its input slice in place. In
poolStatsAttrs we spread conf.attrs directly into NewSet, so the shared
conf.attrs backing array is mutated on every call.

That same array is aliased by every metricsHook.attrs (metricsHook is
added with attrs: conf.attrs) and read concurrently in the Dial/Process
hooks via append(..., mh.attrs...). With a ClusterClient or Ring, conf is
shared across all nodes, so one node's registerClient -> poolStatsAttrs
sorts the array while another node's background MinIdleConns dial reads
it -- the data race reported in #3880.

Copy conf.attrs before handing it to NewSet so the shared array is never
mutated at runtime. This is lock-free and removes the unnecessary shared
mutation rather than serializing around it.

Fixes #3880
2026-07-13 18:59:07 +03:00
Nedyalko DyakovandCopilot Autofix powered by AI 663bfb384f fix(maintnotif): tight race condition windown on handoff (#3872)
* fix(maintnotif): tight race condition windown on handoff

* chore(tests): add pool hook test

* address pr comment

* chore(tests): add tests for race condition

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-07-01 15:36:01 +03:00
Nedyalko Dyakov 909c611a50 drop test (#3863) 2026-06-24 21:29:30 +03:00
Nedyalko DyakovandCopilot Autofix powered by AI 15f526015a feat(search): add SHARD_K_RATIO to FT.HYBRID KNN clause (Redis 8.8+) (#3841)
* feat(search): add SHARD_K_RATIO to FT.HYBRID KNN clause (Redis 8.8+)

Redis 8.8 added the SHARD_K_RATIO argument to the FT.HYBRID KNN clause
so callers can request fewer candidates per shard in cluster mode,
trading recall for latency. Valid range is 0.1 - 1.0; has no effect on
standalone Redis.

Expose it as a typed float64 field on FTHybridVectorExpression. The
zero value means "unset" and falls back to the server default of 1.0
(no per-shard reduction), which matches the way every other numeric
option in FTHybridVectorExpression behaves and keeps the struct literal
syntax clean for callers that do not need to tune the ratio.

The token is emitted after the KNN method block and before FILTER on
the VSIM clause, matching the documented syntax:

    VSIM @field $vec KNN 2 K 10 SHARD_K_RATIO 0.6 FILTER ...

Users could already pass SHARD_K_RATIO through MethodParams, but a
typed field documents the new option, keeps the value out of an
interface{} slice, and matches the shape of the other typed KNN args.

Unit tests verify the token ordering (KNN < SHARD_K_RATIO < FILTER),
the value type, and that the token is omitted when ShardKRatio is 0.

See: https://redis.io/docs/latest/develop/whats-new/8-8/

* add additional guard

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-06-22 10:49:36 +03:00
Nedyalko Dyakov 1551837b70 chore(release): 9.21.0 (#3857)
* bump version to 9.21.0
* add release notes
2026-06-22 10:31:42 +03:00
Nedyalko Dyakov 74d9bb03f8 feat(command): add zero-copy GetToBuffer and SetFromBuffer (#3834)
* feat(command): add zero-copy GetToBuffer and SetFromBuffer

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

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

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

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

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

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

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

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

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

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

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

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

Three related cleanups to ReadStringInto and the cmd it backs:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

No behaviour change.
2026-06-11 16:20:38 +03:00
Nedyalko Dyakov a13416bf1c chore(release): 9.20.1 (#3847)
* bump version to 9.20.1

* Add release notes
2026-06-11 12:07:28 +03:00
Nedyalko Dyakov 10dc44f424 fix(push): fix peeking when push name is truncated (#3842)
* fix(push): Fix peeking when push name is truncated

* fix(push): fix peeking malformated push

* protect agains potential panic
2026-06-11 10:09:03 +03:00
Nedyalko DyakovandCopilot Autofix powered by AI e1a2d68b27 fix(ft.hybrid): Always generate vector param names if they are not provided by the user (#3844)
* feat(search): always pass FT.HYBRID vector data via PARAMS

Redis no longer supports inline vector blobs in FT.HYBRID. Always pass
vector data through the PARAMS mechanism, generating a unique parameter
name when VectorParamName is not provided. Update the related comments
and drop the now-obsolete 8.6+ skip guards in the integration tests.

* chore(search): add tests for generating vector param names

* doc the limitations

* use local params

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* fix build

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-06-11 09:07:01 +03:00
Nedyalko Dyakov 7d05dd3b7c chore(release): v9.20.0 (#3832)
* chore(release): add release notes

* Update readme and release notes.

Deprecate UnstableResp3 everywhere

* chore(release): bump version v9.20.0
2026-05-28 10:31:45 +03:00
Nedyalko Dyakovandofekshenawa 9756882712 fix(test): make waitForSentinelClusterStable robust to disconnected r… (#3830)
* fix(test): make waitForSentinelClusterStable robust to disconnected replicas

CI on master sporadically fails the universal_test.go spec
"should connect to failover servers on slaves when readonly Options is ok"
with `Expected master to equal slave`. The flake reproduces only when the
random spec order puts the sentinel-failover Describe block before the
universal_test spec.

Root cause is in waitForSentinelClusterStable's post-conditions, not in
production code:

  1. It only checks `len(replicas) >= 2`. Production
     (parseReplicaAddrs in sentinel.go) filters out replicas flagged
     `s_down`, `o_down`, or `disconnected`. Post-failover, replicas
     often linger as `disconnected` for several seconds after Sentinel
     agrees on the master/replica counts, so the stabilization check
     can pass while production sees zero usable replicas.

  2. It then waits a fixed 10s. With no link between that sleep and
     the actual precondition (a FailoverClient(ReadOnly:true) finding
     a routable replica), the test gambles that 10s is enough on every
     CI host. When it isn't, RandomReplicaAddr silently returns the
     master and the spec asserts on the wrong role.

Two changes:

  - Add countUsableReplicas, mirroring parseReplicaAddrs's filter, and
    require each sentinel to report at least 2 usable (non-down,
    non-disconnected) replicas with consistent counts across sentinels.

  - Replace the 10s sleep with a bounded Eventually that opens the same
    kind of client the failing spec uses (UniversalClient + MasterName +
    ReadOnly:true) and confirms ROLE returns "slave". This is the
    deterministic equivalent of waiting "long enough" — it returns as
    soon as the actual precondition holds, and bounds at 30s if the
    cluster never stabilizes.

No production code changes; the flake is entirely in test harness
post-conditions. The new probe uses only public API and existing
package-level fixtures (sentinelName, sentinelAddrs).

* fix(test): harden sentinel ReadOnly spec against silent master fallback

CI on master sporadically fails the universal_test.go spec
"should connect to failover servers on slaves when readonly Options is ok"
with `Expected master to equal slave`. Two random-spec-ordering bug
paths combine to produce this:

  1. sentinel_test.go has three failover-causing specs across three
     Describe blocks ("Sentinel", "NewFailoverClusterClient", and
     "Sentinel Failover with Conns"). Only the third runs Ordered/Serial
     with an AfterAll that calls waitForSentinelClusterStable. The
     first two can land right before the universal_test spec under any
     random spec ordering and leave the cluster mid-recovery.

  2. FailoverClient(ReadOnly:true) routes via RandomReplicaAddr, which
     silently falls back to the master when Sentinel reports zero
     usable replicas. parseReplicaAddrs in sentinel.go filters out
     replicas flagged s_down, o_down, or disconnected — and freshly
     promoted/demoted replicas often linger as "disconnected" for
     several seconds after Sentinel agrees on master/replica counts.

Two test-only changes (no production code touched):

  a) waitForSentinelClusterStable: previously only checked
     len(replicas) >= 2, which can pass while every replica is
     filtered out by production code. Now mirrors parseReplicaAddrs's
     filter via countUsableReplicas and requires each sentinel to
     report >= 2 usable replicas with consistent counts across
     sentinels. The fixed 10s sleep is replaced with a bounded
     Eventually that opens the same UniversalClient(ReadOnly:true)
     the failing spec uses and confirms ROLE returns "slave" — the
     deterministic equivalent of "wait long enough".

  b) universal_test.go spec: defense in depth. Since the failover
     specs in (1) don't all share an AfterAll, the spec itself
     retries client creation + ROLE in a bounded Eventually. Each
     iteration closes the old client and creates a new one so
     RandomReplicaAddr is re-evaluated against current Sentinel
     state. Bounded at 30s so a genuinely broken setup still fails.

The (a) fix keeps the post-failover state clean for any downstream
test; the (b) fix keeps this specific spec correct regardless of
ordering. Together they make the spec deterministic across all 1414!
possible random spec orderings.

* fix(test): make ReadOnly SET assertion atomic with the ROLE probe

Previous attempt fixed the ROLE check ("Expected master to equal slave")
by retrying client creation inside Eventually until ROLE returned
"slave". CI surfaced a second failure mode in the same spec:

  [FAILED] Expected an error to have occurred. Got: <nil>: nil
  universal_test.go:110

The spec's intent was: ReadOnly client lands on a replica, and a write
against that replica returns READONLY. The new failure means the write
succeeded — there's a second path back to the master:

  SET on replica → READONLY error
   → shouldRetry returns true for READONLY (error.go)
   → conn discarded, retried via masterReplicaDialer
   → RandomReplicaAddr called again, may silently fall back to master
     when no usable replica is reported (same root cause as the original
     flake — Sentinel sees 2 replicas but both are "disconnected")
   → SET hits the master, succeeds, err is nil
   → Expect(err).To(HaveOccurred()) fails

Two changes to make the spec atomic and bypass-proof:

  - Set MaxRetries: -1 on the test client. A READONLY reply now
    surfaces as the first/only SET error instead of triggering a
    retry-dial that may pick the master. This isolates what the test
    is actually asserting: a single round-trip to whatever node the
    dialer picked.

  - Move the SET assertion inside the Eventually closure together
    with the ROLE check. If either step lands on the master (because
    RandomReplicaAddr fell back at dial time), the closure returns an
    error, Eventually closes the client and retries with a fresh one
    so the dialer re-queries Sentinel. The full assertion is therefore
    atomic — both ROLE and SET observe the same dial.

Error messages from the closure name the precise failure mode so a
genuinely broken setup (timeout after 30s) reports something more
useful than "expected slave, got master".

No production code changes; the retry-on-READONLY behaviour is correct
for normal failover scenarios and stays untouched.

---------

Co-authored-by: ofekshenawa <104765379+ofekshenawa@users.noreply.github.com>
2026-05-27 18:01:01 +03:00
Nedyalko Dyakov d8407df30a fix(pubsub): include shard channels in newConn routing list (#3829)
In ClusterClient.SSubscribe, the per-PubSub newConn closure picks the
target node by hash slot of channels[0]. PubSub.conn() was only passing
c.channels (regular SUBSCRIBE) into that closure — sharded SSubscribe
channels stored in c.schannels were silently dropped.

For an SSubscribe-only PubSub (the common ClusterClient.SSubscribe case)
this meant the channel list was empty on reconnect, so the closure fell
through to nodes.Random(). The reconnected SSUBSCRIBE went to the wrong
shard, the MOVED reply was never read by the write-only resubscribe
path, and the PubSub looked healthy (Ping succeeds, Receive returns)
while delivering no messages. On an N-shard cluster the failure mode hit
(N-1)/N reconnects.

Add c.schannels to the channel list built in PubSub.conn(). c.patterns
is intentionally NOT added: patterns are not slot-addressable, and
including them would pin PSubscribe-only PubSubs to a single
pattern-hash node and lose the existing random-node behaviour.

Adds TestPubSubConn_PassesPersistedChannelsToNewConn covering five cases
(subscribe-only reconnect, ssubscribe-only reconnect, initial
ssubscribe, mixed, and ordering of newChannels) via a stub newConn that
captures the channel list. The cluster routing layer itself is not
under test — the regression is whether the channel list reaches the
closure intact.

Fixes #3806
2026-05-27 16:27:48 +03:00
Nedyalko Dyakov 6af9bdc84b fix(cluster): fall back to origin port when CLUSTER SLOTS reports port 0 (#3828)
In TLS-only clusters started with `--port 0 --tls-port <port>`, CLUSTER
SLOTS reports the regular port as 0. newClusterState used to pass that
value verbatim into clusterNode.Client.opt.Addr, which the PubSub path
dials directly with no MOVED/seed fallback, producing
`dial tcp <ip>:0: connection refused`.

Add a replaceZeroPort helper symmetric to replaceLoopbackHost: when a
slot node's port is "0", substitute the origin endpoint's port — by
definition reachable, since it is the port we used to fetch the slot
map. NodeAddress is left untouched so maintenance-notification matching
continues to see the server-reported value.

Adds TestNewClusterState_PortZero covering the bug scenario plus the
non-zero passthrough and the loopback+:0 composition.

Fixes #3726
2026-05-27 13:51:50 +03:00
Nedyalko Dyakov fa5aa8c4d2 chore(doc): Update README and CI image. (#3822)
* chore(doc): Update README and CI image.

* Use latest image
2026-05-27 13:10:38 +03:00
Nedyalko Dyakovandofekshenawa 14a9a85363 feat(search): parser resp3 responses for search (#3741)
* parse resp3 responses

* backwards compatibility

* doctests and format

* fix parsing

* remove unused functions

* debug test

* debug test x2 peek directly

* debug test

* remove debug and skip re build

* fix typo

* add tests and fix parsers for resp3

* add comment for the unreliable Total in RESP3 for aggregate

* fix build

* remove shadowing rawVal

* fix strange redis search float,int,string behaviour handling

* mark UnstableResp3 as Deprecated

---------

Co-authored-by: ofekshenawa <104765379+ofekshenawa@users.noreply.github.com>
2026-05-21 14:18:35 +03:00
Nedyalko Dyakov c5f08a5847 feat(command): add support for INCREX (#3816)
* feat(command): add increx

* add one additional test

* update increx

* use custom 8.8 image with new increx

* address pr comments

* always use by in float
2026-05-21 14:08:15 +03:00
Nedyalko Dyakov b88fd9b372 chore(extra): Add LICENSE file to extra packages (#3817)
This way the LICENSE will be shipped with those extra packages
as well.
2026-05-19 21:34:23 +03:00
Nedyalko Dyakov 5305816c42 fix(pool): CloseConn to trigger hooks if available #3812 (#3818) 2026-05-19 21:33:57 +03:00
Nedyalko Dyakov ad7fabb336 fix(test): flaky tests (#3815) 2026-05-18 15:01:58 +03:00
Nedyalko Dyakov 3dfa4b663a feat(json): Add JSON.SET fpha support (#3797)
* add json.set fpha support

* fix json set test
2026-05-08 13:01:18 +03:00
Nedyalko Dyakov 85bca80945 fix(pool): Fix Close. Do healthchecks only on idle conns (#3805) 2026-05-08 12:22:31 +03:00
Nedyalko Dyakov e7e9866e54 chore(release): v9.19.0 (#3796)
- Add release notes
- Add release checklist
- Fix release script to update deps
2026-04-28 14:16:39 +03:00
Nedyalko DyakovandElena Kolevska 22b26f4b3e feat(ft.aggregate): Add Steps for query building (#3782)
* ft.aggregate with steps

* ignore deprecated fields when steps are present

* add example app

* address pr comments

---------

Co-authored-by: Elena Kolevska <elena-kolevska@users.noreply.github.com>
2026-04-25 00:21:16 +03:00
Nedyalko DyakovandCopilot 44e8b7309c fix(sch): auto hostname type detection (#3789)
* fix hostname detection

* Update maintnotifications/config.go

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update maintnotifications/config.go

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* fix log level check

* fix ip when tls is off

* handle empty address

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-23 21:38:25 +03:00
Nedyalko Dyakov ad21622ce8 fix(hello): do not send maintnotifications handshake when hello fails (#3788) 2026-04-23 18:06:02 +03:00
Nedyalko Dyakov 00a551b3b4 fix(credentials): leak in wrappedOnClose (#3785)
* fix leak in wrappedOnClose

* address linter warning
2026-04-22 16:45:16 +03:00
Nedyalko Dyakov 462843a275 chore(deps): Update otel sdk (#3770)
* update otel deps

* update otel deps to 1.43

* go mod tidy

* bump ci to use go 1.25
2026-04-09 16:31:22 +03:00
Nedyalko Dyakov 8a4fed48a9 fix(setnx): use set with nx instead setnx (#3723) 2026-03-11 13:20:52 +02:00
Nedyalko Dyakov dcf53a6190 chore(example): remove throughput binary (#3733) 2026-03-11 13:20:27 +02:00
Nedyalko Dyakov 3b155e9443 chore(go): update go version to 1.24 (#3714)
* chore(go): update go version to 1.24

* update readmes and ci workflows

* update dockerfile for proxy-fi-server

* address changed test behaviour

* address changed test behaviour
2026-02-20 10:00:33 +02:00
Nedyalko Dyakov 90faf06923 chore(release): update versions in deps (#3712) 2026-02-16 16:41:48 +02:00
Nedyalko Dyakov bf8e8e3b16 chore(release): v9.18.0 (#3711)
* chore(release): add release notes

* chore(release): update version
2026-02-16 16:19:35 +02:00
b53f2b0c9d feat(sch): MaintNotifications for ClusterClient (#3643)
* [maintnotif] Cluster specific handlers (#3613)

* maint notification handlers for cluster messages

* unrelax all conns

* trigger ci on feature branches

* feat(maintnotif): lazy cluster topology reload (#3614)

* lazy cluster topology reload

* fix discrepancies between options structs

* Update osscluster_lazy_reload_test.go

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update osscluster.go

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* feat(e2e): mock maintnotif e2e tests (#3639)

* lazy cluster topology reload

* fix discrepancies between options structs

* Update osscluster_lazy_reload_test.go

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update osscluster.go

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* wip fault with mock proxy

* make lint happy

* fix linter issues

* faster tests with mocks

* linter once again

* add complex node test

* add ci e2e

* use correct redis container

* e2e fix

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* fix(retry): Add retry mechanism for NOREPLICAS error (#3647)

* fix(queuedNewConn): protect against nil context (#3649)

* fix(maintnotif): fix smigrated parser and add cluster state reload interval option (#3663)

* fix SMIGRATED parsing

* fix smigrated parser

* add ClusterStateReloadInterval to ClusterOptions

* fix tests

* set default cluster reload interval to 10s

* chore(lint): format

* feat(smigrated): new format & remember original host:port (#3697)

* lazy cluster topology reload

* fix discrepancies between options structs

* Update osscluster_lazy_reload_test.go

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update osscluster.go

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* wip fault with mock proxy

* make lint happy

* fix linter issues

* faster tests with mocks

* linter once again

* add complex node test

* add ci e2e

* use correct redis container

* e2e fix

* additional e2e tests

* fix data race

* fix random shard picker

* fix e2e tests

* fix for empty endpoint

* fix case when semaphore is full, but still need to check idle

* scenario tests

* create database from config

* wip

* feat(client): store original addrs for later use

* fix(notif): change smigrated notification

* fix(lint): fix linter

* fix(smigrated): use array

* fix(e2e): wip

* Update options.go

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update redis.go

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* fix(notif): if the conn has no original addr, trigger reload with first target

* chore(e2e): wip cluster e2e

* chore(e2e): fix nil pointer from e2e tests

* chore(e2e): fix tests and reports

* chore(e2e): proper logging in e2e

* chore(e2e): add pubsub in the tests as well

* chore(e2e): mockproxy fixes

* chore(e2e): mockproxy fixes

* chore(e2e): mockproxy fixes

* chore(e2e): mockproxy fixes v3

* stop background routines

* fix(e2e): tests

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* chore(docs): add example app (#3651)

* chore(lint): fmt

* chore(docs): improve docs

* chore(docs): update features.md

* chore(github): remove example

* chore(maintnotif): rename option and address pr comments

* fix(e2e): command runner should use client timeout

* chore(e2e): refactor tests

* fix(e2e): set default timeout to 90m

* fix(e2e): skip tests if proxy cannot be started

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: ofekshenawa <104765379+ofekshenawa@users.noreply.github.com>
Co-authored-by: Elena Kolevska <elena-kolevska@users.noreply.github.com>
2026-02-13 13:25:07 +02:00
Nedyalko DyakovandElena Kolevska f25343d281 chore(tests): Add comprehensive TLS tests and example (#3681)
* chore(tests): add tls tests

* fix: Use ShardPicker for keyless commands instead of random slot

- cmdSlot now returns -1 for keyless commands instead of RandomSlot()
- This allows cmdNodeWithShardPicker to use the configured ShardPicker
  (default: RoundRobinPicker) for keyless command routing
- Updated mapCmdsByNode to handle slot == -1 by using ShardPicker
- Updated test to verify round robin distribution works correctly

* chore(image): update clients-test-lib image

* chore(tests): enable tls user auth test

* chore(docker): use proper envs for test image

* chore(image): use latest build

* chore(image): use latest build

* fix(tests): use the correct root cert

* feat(tls): add example app

* chore(docker): address pr comments

* chore(tests): address pr comments

* chore(doc): add README.md in the example app

* fix(tests): do not skip tests

* fix(tests): skip only because of redis version

* Extracts default image name in a var (including osscluster-tls)

Signed-off-by: Elena Kolevska <elena@kolevska.com>

* fix(tlstests): remove redundent test

---------

Signed-off-by: Elena Kolevska <elena@kolevska.com>
Co-authored-by: Elena Kolevska <elena@kolevska.com>
2026-02-12 15:26:00 +02:00
b0cb6c4330 chore(ci): use 8.6-pre (#3685)
* chore(ci): use 8.6-pre

* chore(ci): add 8.6 in ci matrix

* fix(xinfostream): added new fields in the response

* fix(doctests): fix expected output for XInfoStream

* fix(xinfostreamfull): same as xinfostream

* fix(doctests): fix expected output for XInfoStream

* Support hybrid search params vectors

* Support xinfo stream change in tests

* Revert "Support xinfo stream change in tests"

This reverts commit fde9315b50.

* Support xinfo stream change in tests

* Fix stream doctest to support the changes in 8.6

---------

Co-authored-by: ofekshenawa <ofek.shenawa@redis.com>
Co-authored-by: ofekshenawa <104765379+ofekshenawa@users.noreply.github.com>
2026-01-26 15:09:52 +02:00
Nedyalko Dyakov 06c7fe36cf fix(xadd,xtrim): when approx is false, should have = (#3684) 2026-01-21 16:39:08 +02:00
Nedyalko Dyakov 0c344e2f69 chore(cmd): add deprecation warnings (#3673) 2026-01-19 19:47:21 +02:00
Nedyalko Dyakov 452eb3d15f fix(queuedNewConn): protect against nil context (#3649) 2025-12-16 15:06:35 +02:00
Nedyalko Dyakov e4965ea90b chore(release) v9.18.0-beta.2 (#3644)
* add release notes

* update version number

* update date
2025-12-09 15:50:10 +02:00
Nedyalko DyakovandccoVeille 674c7b8695 chore(go): update go version to 1.21 (#3640)
* chore(go): update go version to 1.21

* chore(aggregators): make aggregators work with 1.21

* fix doctests

* address copilot comments

* use atomic bool for logic and/or aggregators

* Update .github/workflows/build.yml

Co-authored-by: ccoVeille <3875889+ccoVeille@users.noreply.github.com>

* use stable/oldstable, 1.23 and 1.21

* fix versions in README

* add oldstable in wordlist

---------

Co-authored-by: ccoVeille <3875889+ccoVeille@users.noreply.github.com>
2025-12-05 12:27:55 +02:00
Nedyalko Dyakov 981f5db9a2 chore(release): 9.18.0-beta.1 (#3628)
* bump version to 9.18.0-beta.1

* add release notes
2025-12-02 15:08:54 +02:00
Nedyalko Dyakov 68d8c59557 chore(release): v9.17.1 (#3617)
* add release notes

* update version to 9.17.1
2025-11-26 12:20:20 +02:00
Nedyalko Dyakov 63908223ca fix(time): remove cached time optimization (#3611) 2025-11-24 15:18:34 +02:00
Nedyalko Dyakov 1bb9e0d130 chore(scripts: fix help call in tag.sh (#3606) 2025-11-21 11:52:56 +02:00
Nedyalko Dyakov 5b0b228a37 chore(release): v9.17.0 (#3604)
* update release notes

* update readme

* update version number

* cleanup release notes

* fix checkMinIdle
2025-11-19 19:53:02 +02:00