This is a minor release. It contains everything from 9.23.0-beta.1, so these notes cover the full 9.22.0 → 9.23.0 upgrade. The release adds:
- An experimental **full-duplex mode** for the automatic pipeliner, now with `Pipeline()` on the full-duplex connection and several engines on one client.
- Refresh-on-invalidate and miss coalescing for client-side caching, and a faster cache read path.
- Better distribution of cluster reads under latency-based routing.
-`Client.EphemeralConn`, an opt-in limit on queued autopipeline commands, and many stability fixes.
⚠️ Changes to check when you upgrade from 9.22.0:
- **Go 1.26 is now the minimum Go version** ([#4060](https://github.com/redis/go-redis/pull/4060)). The `go` directive of the root module and of the submodules moved from `1.24` to `1.26`, as part of the dependency bumps for `govulncheck` findings. Projects that build with Go 1.24 or 1.25 must upgrade the toolchain.
- **Each `Client` has a dedicated pipeline connection pool** ([#4002](https://github.com/redis/go-redis/pull/4002)). This includes failover clients and cluster node clients. `Pipeline`, `TxPipeline`, and autopipeline operations use this pool, so they do not compete with regular commands for main-pool connections. The pool is for burst capacity only: it dials only when necessary, an unused pool holds zero connections, and if the pool is full an operation uses the main pool. Set `PipelinePoolSize: -1` to get the previous single-pool behavior.
- **`AutoPipelineOptions.MaxBatchBytes` has a default of 128 KiB** (before, there was no limit). The default prevents a write/reply deadlock; it is not a throughput control. The buffers of the pipeline pool also have a default of 128 KiB.
⚠️ Changes to experimental APIs since 9.23.0-beta.1 (no effect if you upgrade from 9.22.0):
- **`AutoPipelineOptions.FullDuplexFastSubmit` is removed** ([#4018](https://github.com/redis/go-redis/pull/4018)). The submit channel is replaced by a queue that takes a full wave of commands in one lock, so the separate fast path is not necessary. Remove the field from your options.
- **`Options.ClientSideCacheRefreshRecencyWindow` is removed** ([#4034](https://github.com/redis/go-redis/pull/4034)). Refresh-on-invalidate now reads again every cached entry of an invalidated key and does not look at how recently the entry was read. On a write-heavy keyspace this means refresh traffic across the full resident cache. Remove the field from your options.
- **`LocalCache.LRUClock()` is removed** ([#4034](https://github.com/redis/go-redis/pull/4034)). It returned the recency token that the removed recency window used, and has no replacement. Remove calls to it.
- **`ClientSideCacheInvalidationBatchWindow` served stale values in beta.1** ([#4033](https://github.com/redis/go-redis/pull/4033)). Under load the batcher applied only ~15% of invalidations, and the cache served invalidated values with a ~100% hit rate. 9.23.0 applies all of them. If you used this option on beta.1, upgrade.
## 🚀 Highlights
### Full-Duplex Auto-Pipelining (Experimental)
Set `AutoPipelineOptions.FullDuplex` to enable the full-duplex mode of the automatic pipeliner. The default mode sends one batch per round trip. In full-duplex mode the engine holds one pipeline-pool connection, and a writer goroutine and a reader goroutine move the ordered command stream in both directions at the same time. On a high-latency link each command completes in approximately one round-trip time (RTT) on a single connection. On a 50 ms WAN profile: ~389k ops/s at 52 ms p50, against ~207k ops/s at 116 ms on the half-duplex ordered path.
The mode works on both faces of a standalone `Client` (`AutoPipeline()` and `AsyncAutoPipeline()`), and natively on a `ClusterClient` when routing goes to masters only. On a cluster, one child engine per master sends each command to the node that owns its slot, and follows `MOVED` / `ASK` redirects and retryable replies (`LOADING`, `READONLY`, ...) through the usual cluster redirect path. With replica routing (`ReadOnly`, `RouteByLatency`, or `RouteRandomly`), the autopipeliner uses the half-duplex shard flushers, which obey the shard picker. `Config().FullDuplex` reports the mode in effect.
Options:
-`FullDuplexWindow` — the maximum number of commands in flight (backpressure).
-`FullDuplexIdleTimeout` and `FullDuplexMaxHold` — when the engine returns the held connection to the pool. The pool hooks then do re-authentication and maintenance-notification handoffs.
-`MaxFlushDelay` — now also used in full-duplex mode ([#4014](https://github.com/redis/go-redis/pull/4014)). The writer waits only when enough commands are in flight, so low-concurrency callers keep the 1×RTT behavior. With `MaxFlushDelay=250µs` at 1024 callers: +22% ops/s and −26% p99. The default is 0, so this is opt-in.
-`NumShards` — on a standalone `Client`, the number of full-duplex engines ([#4022](https://github.com/redis/go-redis/pull/4022)). See below.
**`Pipeline()` on the full-duplex connection** ([#4021](https://github.com/redis/go-redis/pull/4021)). Standalone `Client` only. Before, `ap.Pipeline()` used a pooled connection for each `Exec`. It now submits the batch to the engine as one contiguous run (`FDPipelined`), so replies come back in submit order, as on a dedicated connection. At 256–1024 callers with 10 commands per `Exec`: +49% to +52% throughput on one socket instead of ~150. Errors, retries, hooks, and metrics behave like an ordinary pipeline. A batch that cannot use the stream (a blocking command, a per-command read timeout, a diverted command, or a batch larger than the queue) falls back to the ordinary pipeline. `TxPipeline` stays on a pooled connection, because `MULTI`/`EXEC` needs connection affinity. On a `ClusterClient`, `ap.Pipeline()` is still the ordinary cluster pipeline. A batch that the queue cannot admit now reserves its slots and waits in a first-come line, so single commands cannot starve a long pipeline ([#4057](https://github.com/redis/go-redis/pull/4057), merged as part of [#4021](https://github.com/redis/go-redis/pull/4021)) by [@vlady-kotsev](https://github.com/vlady-kotsev).
**Several engines on one client** ([#4022](https://github.com/redis/go-redis/pull/4022)). With `FullDuplex`, `NumShards > 1` now means N engines on one client: one pool, one hook chain, N held connections. Commands route by a hash of their first key, so commands on the same key stay on one connection and keep their order without `Unordered`. Keyless commands are distributed round-robin. An `FDPipelined` batch stays on one engine. The pipeline pool must hold at least `NumShards` connections for each full-duplex autopipeliner. More engines help only under high concurrency: with 10-command pipelines, 8 engines were 30% slower than 1 at 8 callers, equal at about 128 callers, and 2.2× faster at 1024 callers.
The full-duplex path supports blocking commands, `Options.Limiter` (one admission per written batch), per-command and pipeline hooks, OTel metrics, retry budgets (a `NoRetry` command is never sent again after it is on the wire), and seamless maintenance handoffs. Limits of the stream model:
- **A hook can observe a command, but cannot stop it.** A `ProcessHook` that returns without calling `next` does not cancel the command on the full-duplex path; the command is already queued on the held connection. Run policy or kill-switch hooks on a plain client or on the half-duplex autopipeliner.
- **Diverted commands are not ordered with the stream.** The engine diverts blocking commands, connection-hostile commands, and managed `HIMPORT` commands. On the async face, wait for the result of a diverted command before you submit a command that depends on it.
- **Single commands on the full-duplex stream do not use the client-side cache.** If CSC is enabled, a cacheable command on the stream does not read or write the cache.
- **Duration metrics are recorded per reply.** A command that fails before it reaches the reader (lease failure, limiter denial, retry exhaustion, close) calls the error callback but records no operation-duration sample.
```go
rdb:=redis.NewClient(&redis.Options{
Addr:"localhost:6379",
AutoPipelineOptions:&redis.AutoPipelineOptions{
FullDuplex:true,// stream commands on one held connection, ~1 RTT each
},
})
deferrdb.Close()
// Deferred face: calls return immediately; result accessors block until executed.
// Or the blocking face — a drop-in Cmdable where each call blocks like a
// plain client while concurrent callers share the full-duplex pipe:
// ap, err := rdb.AutoPipeline()
// val, err := ap.Get(ctx, "key").Result()
```
Runnable examples: [`example/autopipeline`](example/autopipeline) (all faces), [`example/autopipeline-fullduplex`](example/autopipeline-fullduplex) (blocking face, `Pipeline()` and `FDPipelined`), and [`example/autopipeline-fullduplex-async`](example/autopipeline-fullduplex-async) (async face, `Submit`, `NumShards`) ([#4061](https://github.com/redis/go-redis/pull/4061)).
**Experimental:** the auto-pipelining APIs can change in a minor release.
([#4002](https://github.com/redis/go-redis/pull/4002), [#4014](https://github.com/redis/go-redis/pull/4014), [#4018](https://github.com/redis/go-redis/pull/4018), [#4021](https://github.com/redis/go-redis/pull/4021), [#4022](https://github.com/redis/go-redis/pull/4022)) by [@ndyakov](https://github.com/ndyakov)
### Client-Side Caching: Refresh-on-Invalidate, Miss Coalescing, Faster Reads
Two new functions for the experimental shared-tracking client-side cache. They have no effect unless CSC is enabled ([#3989](https://github.com/redis/go-redis/pull/3989)) by [@ndyakov](https://github.com/ndyakov):
- **Refresh-on-invalidate** (`Options.ClientSideCacheRefreshOnInvalidate`): when an invalidation push arrives, the client reads every cached entry of that key again in the background, so the next reader does not pay the miss. Each invalidation of a cached key costs one background read. `ClientSideCacheInvalidationBatchWindow` collects invalidation-driven deletes into background batches; without it, the connection reader applies each delete inline.
- **Miss coalescing** (`Options.ClientSideCacheCoalesceMisses`): concurrent cache misses are pipelined on one tracked connection. Each miss keeps its own per-key command (no rewrite to `MGET`), so it is safe on a cluster. Each reply is written to the cache with the connection's tracking generation, so the server can invalidate it.
Both options need the built-in cache (`ClientSideCacheConfig`, or `ClientSideCache` set to a `*LocalCache`). With a custom `Cache` implementation they are ignored.
The cache read path is also faster ([#4034](https://github.com/redis/go-redis/pull/4034), [#4035](https://github.com/redis/go-redis/pull/4035), [#4036](https://github.com/redis/go-redis/pull/4036), [#4037](https://github.com/redis/go-redis/pull/4037)) by [@ndyakov](https://github.com/ndyakov). A hit no longer writes a timestamp to the entry; a second-chance bit marks reads, and is set only under eviction pressure. The cache key is built in pooled scratch memory, the key list is built only on a miss, and nil/status/bulk-string hits are decoded without allocation. Against 9.23.0-beta.1 on a warm cache: +18% to +57% reads/s from 4 to 1024 callers, p50 from 2.6–5.4 µs to 1.0–1.5 µs, with identical hit rates and 100% of invalidations applied.
### Better Distribution for Latency-Based Cluster Read Routing
`RouteByLatency` selects the node with the strictly minimum latency. The measurement has noise (the mean of ten pings, refreshed at most every 10 s), so all clients can select the same node from a set of nodes with almost equal latency. In one production system, GET rates across five replicas in one availability zone had a 590× spread.
The new `ClusterOptions.RouteByLatencyTolerance` widens the selection to every node within the tolerance of the fastest node, and distributes reads across them round-robin with the `ShardPicker`. A node in a different availability zone stays outside a sensible tolerance, so zone locality is kept. The default is zero (strict minimum). The option is also on `FailoverOptions`, for clients from `NewFailoverClusterClient`; the plain `NewFailoverClient` does not support latency routing. ([#3973](https://github.com/redis/go-redis/pull/3973)) by [@jozenstar](https://github.com/jozenstar)
The same work fixed a routing defect: the client recorded the nearest healthy node only when that node was also the fastest overall. A node that fails fast (for example, a refused connection) hid every healthy node, and reads went to the failing node. The client now records the healthy minimum separately. ([#3994](https://github.com/redis/go-redis/pull/3994)) by [@jozenstar](https://github.com/jozenstar)
### `EphemeralConn` for Session-Scoped Work
`Client.Conn()` returns its connection to the parent pool on `Close`. If you ran `AUTH` or `SELECT` on it, later commands on the pooled client run as that ACL user and in that DB. The new `Client.EphemeralConn()` returns the same sticky connection, but `Close` removes it from the pool instead. The cost is one dial per `EphemeralConn`. `Client.Conn()` is unchanged. ([#4039](https://github.com/redis/go-redis/pull/4039)) by [@saddamr3e](https://github.com/saddamr3e)
## ✨ New Features
- **Full-duplex auto-pipelining**: `AutoPipelineOptions.FullDuplex`, with `FullDuplexWindow` / `FullDuplexIdleTimeout` / `FullDuplexMaxHold`, on standalone and cluster clients ([#4002](https://github.com/redis/go-redis/pull/4002)) by [@ndyakov](https://github.com/ndyakov)
- **`Pipeline()` on the full-duplex connection**: `ap.Pipeline()` and `FDPipelined` submit a batch to the engine as one contiguous run, on a standalone `Client` ([#4021](https://github.com/redis/go-redis/pull/4021)) by [@ndyakov](https://github.com/ndyakov)
- **Several full-duplex engines on one client**: `NumShards > 1` with `FullDuplex` on a standalone `Client`, with key-hash routing ([#4022](https://github.com/redis/go-redis/pull/4022)) by [@ndyakov](https://github.com/ndyakov)
- **`AutoPipelineOptions.MaxQueuedCommands`**: an opt-in hard limit on accepted, not-yet-completed autopipeline commands. At the limit a command fails immediately with `ErrAutoPipelineQueueFull`, so memory does not grow without limit when the server is slow. With `FullDuplex`, the ordered stream is bounded by `FullDuplexWindow` instead (a full window blocks the submitter, it does not reject), and `MaxQueuedCommands` limits only the commands that run outside the stream (blocking, connection-hostile, `Do`). The default `0` means no limit ([#4070](https://github.com/redis/go-redis/pull/4070)) by [@ndyakov](https://github.com/ndyakov)
- **Dedicated pipeline pool by default**: `PipelinePoolSize` has a default of `DefaultPipelinePoolSize` (10) on each client. If the pool is full, an operation uses the main pool. Set `-1` to disable the pool ([#4002](https://github.com/redis/go-redis/pull/4002), [#3959](https://github.com/redis/go-redis/pull/3959)) by [@ndyakov](https://github.com/ndyakov)
- **CSC refresh-on-invalidate and miss coalescing**: `Options.ClientSideCacheRefreshOnInvalidate` (with `ClientSideCacheInvalidationBatchWindow`) and `Options.ClientSideCacheCoalesceMisses` ([#3989](https://github.com/redis/go-redis/pull/3989)) by [@ndyakov](https://github.com/ndyakov)
- **`Client.EphemeralConn`**: a sticky connection that is removed from the pool on `Close`, for session-scoped `AUTH` / `SELECT` work ([#4039](https://github.com/redis/go-redis/pull/4039)) by [@saddamr3e](https://github.com/saddamr3e)
- **`RouteByLatencyTolerance`**: distributes reads across nodes with almost equal latency, on cluster clients and on `NewFailoverClusterClient` ([#3973](https://github.com/redis/go-redis/pull/3973)) by [@jozenstar](https://github.com/jozenstar)
- **`AutoPipeliner.WaitClosed`**: blocks until the drain of accepted commands completes, and returns the drain result. Use it in a wrapper that must not close shared pools during a flush ([#3998](https://github.com/redis/go-redis/pull/3998)) by [@ndyakov](https://github.com/ndyakov)
- **`redisotel-native``WithRecordNilErrors`**: opt in to count `redis.Nil` replies as client errors (see the fix below) ([#4025](https://github.com/redis/go-redis/pull/4025)) by [@lazerg](https://github.com/lazerg)
- **`CMSInfo.CellSize`**: the cell-size field of `CMS.INFO` in Redis 8.12 ([#4010](https://github.com/redis/go-redis/pull/4010)) by [@elena-kolevska](https://github.com/elena-kolevska)
## 🐛 Bug Fixes
- **CSC invalidation batching**: `ClientSideCacheInvalidationBatchWindow` applied only ~15% of invalidations under load, so the cache served stale values with a ~100% hit rate. Each batch now takes each shard lock once, and 100% of invalidations are applied ([#4033](https://github.com/redis/go-redis/pull/4033)) by [@ndyakov](https://github.com/ndyakov)
- **Pool waiter wake-ups**: `Put` did not wake a worker that waited for a connection to become `IDLE`, so a streaming-credentials re-auth could stall until `PoolTimeout` and then close the connection ([#4028](https://github.com/redis/go-redis/pull/4028)) by [@avitenzer](https://github.com/avitenzer). Two related waiter-queue races are fixed: a failed transition left a dead waiter queued, which later moved a connection to `UNUSABLE` for nobody ([#4072](https://github.com/redis/go-redis/pull/4072)) by [@ndyakov](https://github.com/ndyakov)
- **Command key positions**: 16 typed commands (`BitOp*`, `MIGRATE`, `OBJECT ENCODING/FREQ/IDLETIME/REFCOUNT`, `XINFO STREAM/GROUPS/CONSUMERS`, `LMPOP`, `BLMPOP`, `SINTERCARD`, `ZINTERCARD`, `ZMPOP`, `BZMPOP`) routed by the wrong argument. On a `Ring` they went to the wrong shard; on a cluster they cost an extra `MOVED` round trip. A raw `FCALL` / `FCALL_RO` hashed the function name. All now route by their key ([#4048](https://github.com/redis/go-redis/pull/4048)) by [@ndyakov](https://github.com/ndyakov). Raw `XGROUP`, `ZUNION` / `ZINTER` / `ZDIFF`, `SDIFFCARD` / `SUNIONCARD`, `TS.NRANGE` / `TS.NREVRANGE`, and `HIMPORT SET` forms are also fixed ([#4022](https://github.com/redis/go-redis/pull/4022))
- **Millisecond expirations**: `PExpireAt`, `HPExpireAt`, and `HPExpireAtWithArgs` overflowed for dates outside the nanosecond range (for example, year 2270), so Redis deleted the key or rejected the command ([#4026](https://github.com/redis/go-redis/pull/4026)) by [@jakezwang](https://github.com/jakezwang). `SetArgs.ExpireAt` now sends `PXAT` when the deadline has millisecond precision; before, `EXAT` dropped the milliseconds ([#4053](https://github.com/redis/go-redis/pull/4053)) by [@LindseyZ1205](https://github.com/LindseyZ1205)
- **Cluster `-1` timeouts**: a `-1` read/write timeout on `ClusterOptions` gave node clients the 5 s default instead of no timeout (fixes [#4049](https://github.com/redis/go-redis/issues/4049)) ([#4050](https://github.com/redis/go-redis/pull/4050)) by [@lazerg](https://github.com/lazerg)
- **OTel `redis.Nil` as error**: a cache miss (`redis.Nil`) incremented `redis.client.errors` and was tagged as a server error on `db.client.operation.duration`. Nil replies are now classified `NIL` and not counted, unless `WithRecordNilErrors(true)` is set (fixes [#4024](https://github.com/redis/go-redis/issues/4024)) ([#4025](https://github.com/redis/go-redis/pull/4025)) by [@lazerg](https://github.com/lazerg)
- **Credential redaction**: `SENTINEL SET ... auth-pass` is now redacted in command rendering (used by `redisotel` / `rediscensus` span attributes), and a `MIGRATE` password with the literal value `auth` / `auth2` is redacted at the correct position ([#4011](https://github.com/redis/go-redis/pull/4011)) by [@saddamr3e](https://github.com/saddamr3e)
- **Min/max aggregator panic**: a non-numeric shard reply made `AggMinAggregator` / `AggMaxAggregator` panic instead of returning an error from `Result()` ([#4056](https://github.com/redis/go-redis/pull/4056)) by [@Suselz](https://github.com/Suselz)
- **`ParseURL``skip_verify`**: an invalid value (for example, `skip_verify=yes`) was silently treated as `false`. `ParseURL` and `ParseFailoverURL` now return an error, like for the other boolean options ([#4064](https://github.com/redis/go-redis/pull/4064)) by [@orinnz](https://github.com/orinnz)
- **`uintptr` arguments**: `uintptr` and `*uintptr` are now encoded like the other unsigned integers; a nil `*uintptr` is encoded as `0` (fixes [#3100](https://github.com/redis/go-redis/issues/3100)) ([#4054](https://github.com/redis/go-redis/pull/4054)) by [@yamaankhan20](https://github.com/yamaankhan20)
- **Cluster read routing**: the client records the nearest healthy node separately from the overall minimum, so a node that fails fast does not hide the healthy nodes ([#3994](https://github.com/redis/go-redis/pull/3994)) by [@jozenstar](https://github.com/jozenstar)
- **Probabilistic `*.INFO` forward compatibility**: the `BF.INFO` / `CF.INFO` / `CMS.INFO` / `TOPK.INFO` / `TDIGEST.INFO` parsers skip unknown fields instead of returning an error. Redis 8.12 adds `cell size` to `CMS.INFO` ([#4010](https://github.com/redis/go-redis/pull/4010)) by [@elena-kolevska](https://github.com/elena-kolevska)
- **`NewClient` panic leak**: a panic during construction (for example, a maintnotifications failure in `ModeEnabled`) does not leak the connection pools that already exist, and a typed-nil pool cannot hide the initial panic ([#4003](https://github.com/redis/go-redis/pull/4003)) by [@ndyakov](https://github.com/ndyakov). The same guards apply to `NewFailoverClient` ([#4002](https://github.com/redis/go-redis/pull/4002))
- **File-descriptor leak on rejected connections**: `Conn.Close` does the socket teardown and the unsubscribe/CSC callbacks when the connection is already `CLOSED`. Before, init and auth failures leaked open descriptors (fixes [#3982](https://github.com/redis/go-redis/issues/3982)) ([#3985](https://github.com/redis/go-redis/pull/3985)) by [@ndyakov](https://github.com/ndyakov)
- **Global logger races**: atomics protect the global `Logger` and `LogLevel`, and call-site attribution is correct again ([#3988](https://github.com/redis/go-redis/pull/3988)) by [@saddamr3e](https://github.com/saddamr3e)
- **`Conn.onClose` data race**: the close hooks that init installs (`onClose` and `onCscClose`) are atomic against a concurrent `Close` ([#3966](https://github.com/redis/go-redis/pull/3966)) by [@saddamr3e](https://github.com/saddamr3e)
- **Reply-parser hardening**: the reply parsers accept zero-length entry arrays ([#3995](https://github.com/redis/go-redis/pull/3995)), and `FTHybridCmd` reads the full RESP3 map reply without desynchronizing the connection ([#3956](https://github.com/redis/go-redis/pull/3956)) by [@saddamr3e](https://github.com/saddamr3e)
- **`CLIENT INFO` forward compatibility**: the parser skips unknown client-flag characters instead of failing the reply ([#3977](https://github.com/redis/go-redis/pull/3977)) by [@ndyakov](https://github.com/ndyakov)
- **`GEOSEARCH` duplicate args**: the command no longer sends duplicate arguments ([#3955](https://github.com/redis/go-redis/pull/3955)) by [@mehmettokgoz](https://github.com/mehmettokgoz)
- **`MSetEX` cluster routing**: the constructor sets the first-key position, so typed calls go to the correct slot ([#3984](https://github.com/redis/go-redis/pull/3984)) by [@shivamrustagi](https://github.com/shivamrustagi)
- **Maintenance notifications**: no endpoint DNS detection when the mode is disabled ([#3969](https://github.com/redis/go-redis/pull/3969)) by [@Phalanyx](https://github.com/Phalanyx)
- **Autopipeliner `Close`**: a concurrent `Close` does not block (no re-entrant deadlock), and `WaitClosed` returns the drain result ([#3998](https://github.com/redis/go-redis/pull/3998)) by [@ndyakov](https://github.com/ndyakov)
- **Buffered-push log noise**: the buffered-push-data notice in `isHealthyConn` is logged only at debug level, so CSC invalidations do not fill the log ([#3948](https://github.com/redis/go-redis/pull/3948)) by [@ndyakov](https://github.com/ndyakov)
- **Sentinel teardown order**: close hooks run in LIFO order, so an autopipeliner drain completes before Sentinel discovery stops, and a closed failover client cannot recreate its Sentinel resources from a late dial ([#4002](https://github.com/redis/go-redis/pull/4002)) by [@ndyakov](https://github.com/ndyakov)
- **Pipeline desync containment**: if a pre-write push-notification drain fails or a command encoder panics, the `Pipeline` / `TxPipeline` path removes the connection instead of returning a desynchronized connection to the pool ([#4002](https://github.com/redis/go-redis/pull/4002)) by [@ndyakov](https://github.com/ndyakov)
## ⚡ Performance
- **CSC read path**: second-chance read bit, pooled cache-key scratch, key list built only on a miss, allocation-free decode of simple hits. +18% to +57% reads/s against 9.23.0-beta.1 on a warm cache ([#4034](https://github.com/redis/go-redis/pull/4034), [#4035](https://github.com/redis/go-redis/pull/4035), [#4036](https://github.com/redis/go-redis/pull/4036), [#4037](https://github.com/redis/go-redis/pull/4037)) by [@ndyakov](https://github.com/ndyakov)
- **Full-duplex reads and submit queue**: replies already in the buffer are read as one group (one read deadline per group, not per reply), and a mutex+slice queue replaces the submit channel, so the writer takes a whole wave in one lock ([#4018](https://github.com/redis/go-redis/pull/4018)) by [@ndyakov](https://github.com/ndyakov)
- **Full-duplex `MaxFlushDelay`**: opt-in write coalescing under load. With 250 µs at 1024–2048 callers: +22% to +25% ops/s and −26% to −29% p99; 30% less CPU at 256 callers ([#4014](https://github.com/redis/go-redis/pull/4014)) by [@ndyakov](https://github.com/ndyakov)
- **Full-duplex allocations**: a ring buffer holds the in-flight queue, and the blocking face uses a pool of batches. 770 B/op → 353 B/op at 2048 concurrent callers ([#3970](https://github.com/redis/go-redis/pull/3970), part of [#4002](https://github.com/redis/go-redis/pull/4002)) by [@ndyakov](https://github.com/ndyakov)
- **Zero-copy scan**: `Scan` gets zero-copy semantics, and the RESP reader skips unnecessary data conversions ([#3972](https://github.com/redis/go-redis/pull/3972)) by [@vlady-kotsev](https://github.com/vlady-kotsev)
- **Autopipeline straggler hold**: the engine limits the hold on queued commands when the pipeline pool has a free connection. Uncached p95 on a 50 ms link: 111 ms → 65 ms; real-WAN uncached p99: 314 ms → 177 ms ([#3962](https://github.com/redis/go-redis/pull/3962)) by [@ndyakov](https://github.com/ndyakov)
## 🧪 Testing & Infrastructure
- **Go 1.26 and `govulncheck` dependency bumps**: the minimum Go version is 1.26, CI defaults to 1.26, and a new `go-versions` job builds the test matrix from `go.mod` plus `oldstable` / `stable` ([#4060](https://github.com/redis/go-redis/pull/4060)) by [@vlady-kotsev](https://github.com/vlady-kotsev)
- **Fast skip gates**: tests do a TCP probe of each address before the `Ping` gate, which removes ~1.6 min of dial-retry waits when the full stack is not running ([#4001](https://github.com/redis/go-redis/pull/4001)) by [@ndyakov](https://github.com/ndyakov)
- **Redis Enterprise coverage**: the autopipeline suites connect to the RE database and use the suite DB ([#3976](https://github.com/redis/go-redis/pull/3976), [#3975](https://github.com/redis/go-redis/pull/3975)), timing assertions scale to the measured RTT ([#3978](https://github.com/redis/go-redis/pull/3978)), and the `CLIENT INFO` tracking-flag assertion is skipped behind the RE proxy ([#3981](https://github.com/redis/go-redis/pull/3981)) by [@ndyakov](https://github.com/ndyakov)
- **Docs**: `UniversalOptions` documents `ReadTimeout` / `WriteTimeout` / `ContextTimeoutEnabled` ([#4047](https://github.com/redis/go-redis/pull/4047)) by [@lazerg](https://github.com/lazerg); the README describes `DisableIdentity` as client identification (`CLIENT SETINFO`), not identity verification ([#4059](https://github.com/redis/go-redis/pull/4059)) by [@FanWu-ai](https://github.com/FanWu-ai)
- **Security policy**: send vulnerability reports to the Redis VDP ([#3949](https://github.com/redis/go-redis/pull/3949)) by [@ndyakov](https://github.com/ndyakov)
## 👥 Contributors
We thank all the contributors who worked on this release!
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.