# Changelog
All notable changes to this project are documented here. The format is based on
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
Versions up to and including `0.19.3` are documented in the
[GitHub releases](https://github.com/dahomey-technologies/rustis/releases).
## [0.25.0] - 2026-08-24
### BREAKING CHANGES
The upgrade checklist. Each item is stated in the section it belongs to below.
- **Six connection-driving commands leave the public traits**: `hello`, `asking`,
`readonly`, `readwrite` and `cluster_slots` become internal, and `quit` is deleted.
The four types that served them become internal too: `HelloOptions`, `HelloResult`,
`LegacyClusterShardResult` and `LegacyClusterNodeResult`.
- **Five deprecated string commands are removed**: `getset`, `psetex`, `setex`,
`setnx` and `substr`.
- **A `nil` reply read as a scalar is now an error, not `0` / `""` / `'\0'`.** Declare
the response as an `Option` to accept the absence. The rule reaches inside a reply, so
`hmget` reads as `Vec<Option<String>>`, and three public fields become `Option<String>`:
`TsInfoResult::source_key`, `FunctionInfo::description` and
`XPendingResult::{smallest_id, greatest_id}`. Collections, `Value` and `bool` are exempt.
- **Seven commands now route on a key they only named before.** A cross-slot call to
`sdiffstore`, `sinterstore`, `zdiffstore`, `zinterstore`, `zunionstore`,
`sort_and_store` or `lcs` was refused by the server with `CROSSSLOT`, and is now
refused locally with `ClientError::MismatchedKeySlots`.
- **The queue memory budget now covers what is in flight.** A command was charged
to `BackpressureConfig::max_queued_bytes` until it was written, and is now charged
until its reply arrives. A configuration sized against the send queue alone may
shed commands it used to accept.
- **`Value::Map` holds `Vec<(Value, Value)>` instead of `HashMap<Value, Value>`,
and `Value` no longer implements `Hash`.** Code that built a map with
`HashMap::from([…])` or read it with `get`/`contains_key` on the inner type must
change; `Value::get` replaces the lookup.
- **`Value::SimpleString` equals the `Value::BulkString` carrying the same bytes.**
A comparison that relied on the two being distinct now answers `true`.
- **`ErrorKind::Timeout` carries a `TimeoutKind`.** `matches!(e.kind(),
ErrorKind::Timeout)` becomes `ErrorKind::Timeout(_)`, and the two deadlines can
now be told apart by name.
- **`ClientError::Unexpected` is removed**, replaced by the seven variants that
say which condition occurred: `MalformedFrame`, `InconsistentRespTape`,
`NotACollection`, `MissingTransactionReply`, `IncompatibleShardReplies`,
`NotAUnitVariant` and `MissingMapValue`.
- **`ClientError::InvalidChannel` is removed.** No code path produced it: it stood for
a client holding no send channel, which nothing can observe.
- **`Pipeline::queue`/`forget` and `Transaction::queue`/`forget` become
`queue_command`/`forget_command`.** They took a generic command; the batch trait
methods of the same name take a prepared one. Two calls that read alike and are
not the same call now differ by name.
- **`CommandBuilder::key` takes exactly one key**, and a collection goes through the new
`CommandBuilder::keys`. Built-in commands are unaffected — the 24 multi-key ones were
moved — but a hand-built `cmd("DEL").key(my_vec)` now fails with
`ClientError::InvalidKeyArity`. The counted forms are unchanged and may still declare
zero keys, as `EVAL` does.
- **`resp::Response` is deleted.** Replace it with `serde::de::DeserializeOwned` in a
`where` clause. The trait was `pub trait Response {}` with a blanket impl for every
`Deserialize` type, so the `R: Response` bound on 232 command signatures constrained
nothing while `IntoFuture` re-required `DeserializeOwned` behind it. The bound now
says what it always meant.
- **`Client::close` returns `CloseOutcome` instead of `()`.** A connection is shared
by every clone of a client, so a `close` that finds a clone alive shuts nothing down
and used to report that as `Ok(())`. `CloseOutcome::Closed` and `StillShared` now
tell the two apart. `ExclusiveClient::close` follows.
- **`RedisError::description` is a method, not a field, and the bytes are kept.** A server
error reply is bytes and can echo a key, which `String::from_utf8_lossy` used to mangle
on the way in. `description()` answers a `Cow<str>` with the same lossy reading,
`description_bytes()` the exact bytes. `kind` stays a public field.
- **`ClientError::InvalidTag` is removed.** No code path could produce it.
`cargo semver-checks` against `0.24.0` reports 9 failing major checks, among them 11
removed trait methods, 4 removed structs, the `resp::Response` trait, the
`ClientError::InvalidTag` variant and the `RedisError::description` field.
### Added
- **`Client::send_raw` hands a reply back as RESP bytes.** A proxy or a protocol bridge
had to read through `Value`, which drops what it cannot spell back: a server's
rendering of a float, a verbatim string's tag, an error's exact wording. `send_raw`
answers a `resp::RawResponse` — the frame as received, or RESP3 for a reply the client
built itself. A Redis error is a reply here, not a failure; `is_error` tells them apart.
- **`rustis::prelude` holds every command trait.** A command lives on a trait, so a program
calling several families collected one `use` per family. The prelude re-exports all 28,
the two batch traits, the four executors and the pub/sub types. `Result` stays out: a
glob import of it shadows the standard prelude's and leaves `Result<T, E>` naming
nothing. A test reads `src/commands/mod.rs` and fails on a family left out.
- **A pub/sub message reads as text or as a Rust type.** `PubSubMessage::channel_str()`
and `pattern_str()` answer a `&str`, failing with `ErrorKind::Utf8` on a binary name
rather than replacing what they cannot decode. `payload_as::<T>()` runs the payload
through the same serde machinery as a bulk string reply, so a published number reads as
a number and a document as `Json<T>`; `T` may borrow, so `&str` allocates nothing.
- **Every tuning knob is now addressable in a URL.** `buffers`, `backpressure` and `limits`
take one query parameter per field, named after it — `buffers.read_capacity`,
`limits.max_bulk_length`. `reconnection` names the policy and `reconnection.delay` and
its siblings shape it; a field the policy does not carry is rejected, not dropped.
`Display` writes them all back, so a config round-trips through its URL.
- **A Sentinel failover is now noticed before a command fails.** The client subscribes
to `+switch-master` and rediscovers the master when a Sentinel announces one, and
polls the fleet every `SentinelConfig::master_check_interval` (default 10 s) to cover
the announcements published while that subscription is itself redialling. A rediscovery
either path already made leaves the other with nothing to do.
- **`ClientError::UnexpectedNil`**, raised when a `nil` reply is read as a type that
cannot hold an absence. The message names the target type and points at `Option`.
- **A key argument that is not a single key fails the command**, with
`ClientError::InvalidKeyArity` naming the command and the argument count. Arguments are
`impl Serialize`, so the compiler cannot count what a value produces: a `None` key used
to reach the server a key short and, in Cluster mode, with no hash slot — which routes
it to a random node. Any type serializing to one argument is still a valid key.
- **`Client::stats`** returns a `ClientStats` snapshot: queued commands and bytes,
the bytes high-water mark, shed commands and reconnections. The numbers existed
as `#[cfg(test)]` hooks, so an operator was told to size
`BackpressureConfig::max_queued_bytes` with no way to see whether it was hit.
- **`Client::config`, `Client::is_connected` and `Client::server_version`** report
what a client is connected to. A readiness probe needed a `PING`, and branching on
the server version needed a second `HELLO`. `server_version` is `None` on a
cluster, whose nodes have versions of their own. `ExclusiveClient` has all four.
- **`Config` is `Serialize` and `Deserialize`**, so a service maps a TOML/YAML/JSON
section onto it. `buffers`, `backpressure`, `limits` and `reconnection` had no URI
spelling and were reachable from Rust only. Missing fields take their defaults;
`credentials_provider`, `tls_config` and `ServerConfig::Custom` carry Rust code and
are skipped.
- **`TlsConfig::new`** (rustls) wraps a `rustls::ClientConfig` built elsewhere. The type
is `#[non_exhaustive]` and had no constructor, so a private CA, a client certificate
or a pinned issuer could not be supplied from outside the crate at all.
- **Seven examples**: `cluster`, `sentinel`, `tls`, `transaction`, `pipelining`,
`scripting` and `client_side_caching`. `wakeup_cost_probe` and `cache_stampede_probe`
now need the `bench` feature, like the nine other profiling harnesses.
- **`Config::interceptor`** takes a `CommandInterceptor`, called on every command the
client sends and on every command that resolves, with its elapsed time and its
error. Per-command metrics, a request identifier or an audit trail had no hook at
all. It may rewrite the command before it goes out.
- **`Cache::with_store`** takes a `CacheStore`, so the client-side cache can be backed by a
store shared between clients, or one with an eviction policy of its own. `Cache` is
generic over it and defaults to `MokaStore`, so `Cache` alone still means what it did.
An entry is an opaque `CachedValue`: handing the bytes out would pin a recycled network
buffer, so a store cannot persist an entry.
- **`ReconnectionConfig::Custom`** takes a `ReconnectionPolicy`, so a delay can depend
on more than the attempt number: a circuit breaker, an external health signal, a
backoff coordinated across a pool. Implemented for any `Fn(u32) -> Option<Duration>`,
so a closure is enough. The three built-in shapes are unchanged.
- **`prepare_command` is public**, with the extension pattern on the crate's front
page. A missing command is added in the crate's own idiom, `client.myget("key").await`,
instead of `client.send(cmd("MYGET")…)`. Every built-in command trait is written this
way; only the helper was private.
- **`Client::is_terminated`** reports a client whose network task has ended — a
non-zero reconnection budget exhausted, or the last handle dropped. The state was
invisible: the process stays alive and serving traffic it can never answer. A
liveness probe reads this; the only recovery is a new client. `ExclusiveClient`
has it too.
- **`ClusterConfig::topology_refresh_interval`** reloads the cluster topology on a
timer, 60 seconds by default (`?topology_refresh_interval=`, `0` to disable). A
redirection was the only thing that corrected the local slot map, so a resharding
touching no slot this client uses was never noticed, and a node added to the
cluster was never connected to.
- **`Value` accessors**: `as_str`, `as_bytes`, `as_i64`, `as_f64`, `as_bool`,
`as_array`, `as_map`, `as_error`, `is_null` and `get`. The object model held one
method, `into`, so reading a reply whose shape the caller does not model meant
pattern matching or a detour through serde.
### Changed
- **The send queue counts its commands incrementally.** Deciding whether to emit one
`debug!` line folded the whole queue on every send wave, in shipped builds, whether
or not anything was listening. The total is now maintained alongside the byte total
it sits next to.
- **The response type on a queued command is documented as ignored.** `R` is
discarded when a command enters a batch — the tuple on `execute` decides the
decoding — and the crate's own examples wrote it two different ways. They now all
write `::<()>`.
- **The raw-bytes limitation is stated on the front page.** `client.set("key", b"val")`
compiles and fails at runtime; the explanation lived only on the `resp` module page.
- **`Value::Map` keeps the reply.** Its entries are in the order the server sent
them, and a field the server repeats appears twice. A `HashMap` lost both, made
`Display`/`Debug` nondeterministic, and was the sole reason `Value` carried a
hand-written `Hash` over `f64` and nested maps.
- **`Value` compares payloads, not variants.** `SimpleString` and `BulkString` carry
the same thing and the deserializers read them identically, so which one a reply
arrives in is a server-version detail. Comparing on the variant made caller code
fail on a server upgrade.
- **A reply nobody awaits is logged at `debug!`, not `warn!`, and names its command.**
A caller that gives up on its reply — a `command_timeout`, a dropped future — is the
documented contract, not a fault, and a service with deadlines flooded its logs
exactly when Redis was slow. Giving up on reconnection moved the other way, to
`error!`: that client will never answer again.
### Removed
- **The connection-driving commands are internal.** A `Client` is clonable, so one
connection carries the commands of every clone: `HELLO` changed the protocol version the
deserializers depend on, `READONLY` the read mode `ClusterConfig::read_preference`
depends on, and `ASKING` is correct only immediately before the command it redirects.
The client now sends them itself. `CLUSTER SLOTS` callers use `cluster_shards`.
- **`quit` is deleted.** Redis deprecated it in 7.2.0. On a multiplexed client it
closed the connection of every clone. Use `Client::close`.
- **`ClientError::InvalidChannel` is removed.** It reported a client whose send channel
was gone, a state a `Client` cannot be in: `close` takes the client by value, so the
handle that gives the channel up is unreachable afterwards. A send that finds the
network task gone reports `ClientError::DisconnectedFromServer`.
- **The deprecated string commands are removed.** Use `set_get_with_options` for
`getset`, `set_with_options` with `SetExpiration::Ex` or `Px` for `setex` and
`psetex`, `set_with_options` with `SetCondition::NX` for `setnx`, and `getrange` for
`substr`. `COMMAND DOCS` reports 21 deprecated commands; the crate implemented these
five and `quit`. No module command reports a deprecation.
### Fixed
- **A cluster reconnection rediscovers the topology from the nodes it holds, not only from
the configured seeds.** `reconnect` dialled `ClusterConfig::nodes` alone, so a cluster
whose seeds are one control-plane endpoint stayed down for as long as that endpoint did,
every attempt repeating the same too-small dial while nodes that had answered sat untried.
It now dials the held nodes first, as the two other discovery paths already did.
- **A query parameter written on the wrong scheme now names the URI it belongs to.**
`sentinel_username`, `sentinel_password` and `wait_between_failures` are read only by
a sentinel URI, `read_preference` and `topology_refresh_interval` only by a cluster one,
and `db` only by a unix socket one. On any other scheme they were reported as unknown,
sending the caller after a typo that is not there. The error now names the owning URI.
- **A cached RESP3 double read as a string now spells the value the way the server did.**
The client-side cache decodes a double when it compacts an entry, and a read as `String`
rebuilt the text from that `f64`: a score of `1e+20` came back as
`100000000000000000000` on a hit and `1e+20` on a miss, `nan` as `NaN`. A compacted
double now keeps the reply's own bytes, and the read borrows them (56 ns saved).
- **A rendered server error no longer carries a stray space.** `RedisError`'s
`Display` wrote `"{kind} {description}"` unconditionally, so an error whose kind rustis
does not recognise came out with a leading space, and a redirection, whose detail is all
in the kind, with a trailing one. The separator is now written only between two non-empty
halves.
- **A cluster `SUBSCRIBE` no longer fails when its channels span several nodes.** The
command is split per node, but the confirmations were matched by rank against the
order the caller named the channels — and the nodes answer in their own order, so a
legitimate call failed with `ClientError::UnexpectedSubscriptionConfirmation`,
non-deterministically. A confirmation is now matched by name.
- **A channel-less `UNSUBSCRIBE` now reaches every node of a cluster.** It names nothing
to hash, so unlike its argument-carrying form it was served by a single node: it
cancelled that node's share of the connection's subscriptions and silently left the
rest. It is now sent to every master, and the caller waits for all the confirmations
rather than for the first.
- **Seven commands did not route on a key they name.** Six store commands added their
destination as a plain argument, and `lcs` did the same with its second key, so the
key took no part in slot computation: the command routed on its remaining keys
alone, and the local cross-slot check could not see the unmarked one.
- **Four replies reported an absence as a value.** `TS.INFO` on a series that is not a
compaction target answered `""` for its source key, `FUNCTION LIST` did the same for
a function with no description, `FT.CONFIG GET` for an option carrying no value, and
`XPENDING` on an empty group for its smallest and greatest ids. All four now answer
`None`.
- **A client-side cache key that serialized to several arguments filed the entry
under the first of them.** `Cache::get` on a struct key kept one entry for every
key sharing a first field, each read returning another key's value. Only a key
serializing to no argument was refused; both counts now are.
- **`ClientStats::queued_commands` no longer over-reports after a reconnection.** The
replay rebuilt the byte total before re-queuing the messages it kept, and left the
command total standing, so each replayed command was counted twice and the excess
stayed for the life of the connection. Both totals now belong to one type that
zeroes them with the queues it empties.
- **`connect_timeout` bounds the handshake, not only the dial.** A server that accepted the
socket and never answered `HELLO` left `Client::connect` waiting forever: the dial
succeeded in microseconds, so the only deadline in the path had already been met. The
budget now covers both, raising `ErrorKind::Timeout(TimeoutKind::Connect)`.
- **An internal failure names the condition it hit.** `ClientError::Unexpected` reported a
dozen distinguishable conditions as `Unexpected error`. Worse, the frame parser raised it
and the framing list did not carry it, so a failure leaving the reader at an unknown
offset was dispatched to a single caller with the stream possibly desynchronised. The
parser's two sites are now `MalformedFrame`, which the framing list does carry.
- **Enabling both TLS backends reports one error.** `rustls` and `native-tls` each
define a `TlsConfig` and an `Error::Tls`, with different fields, so the union defined
both names twice and produced 61 errors and no usable message. Feature unification
reaches this configuration without anyone asking for it. A guard now names the pair,
and each of the five rejected feature configurations reports exactly one cause.
- **`cargo bench` no longer costs three minutes to measure nothing.** Every benchmark
is a criterion target with `harness = false`, but the lib test target was still built
under `[profile.bench]` on every invocation, to report `0 measured`. `bench = false`
in a `[lib]` section removes the build: 3m02s becomes 0.13s. The README now names the
`bench` feature the targets require.
- **Reconnection jitter no longer vanishes when the backoff saturates.** The delay was
clamped to `max_delay` *after* the jitter was added, so every client of a fleet woke
at exactly `max_delay` — re-synchronising the herd precisely when the outage is
longest. The clamp now applies to the delay and the jitter is added to the result, so
the effective ceiling is `max_delay + jitter`.
- **The pool health check no longer parks on a silent server.** `is_valid` pinged with
no deadline of its own, and `command_timeout` defaults to none, so a server that
accepts the socket and never answers held the check — and every caller waiting for a
connection — for good. The ping is bounded by `command_timeout`, or by
`connect_timeout` when that is unset.
- **A Sentinel connection learns the fleet from the fleet.** The instance list was
frozen at whatever the configuration named, against the client spec, so replacing
every named Sentinel left the client with nothing reachable. A confirmed master is
now followed by `SENTINEL SENTINELS`, which adds the unknown instances and moves the
one that answered to the front.
- **The memory budget bounds the replies still awaited.** The charge was released the
moment a command was written, but writing it frees nothing — the memory is held
until the reply arrives. A connection that accepted every byte and answered none
therefore grew `messages_to_receive` without limit, which is the hole in the
documented "bound memory with `BackpressureConfig`" story.
- **Neither direction of the network loop drains without bound.** The two share one
task, so a caller flooding the channel delayed every reply and a firehose of
replies delayed every send; one send wave was measured taking 2001 messages. Both
waves now hand control back after `max_messages_per_wave`. Measured on
`rustis_long_pipeline`: no change outside the run-to-run drift.
### Documentation
- **`CloseOutcome::StillShared` says what it does not promise.** It read as "a clone still
holds the connection, which stays up", which does not hold for handles given up at the
same time: the shutdown goes to whichever goes last, so a call reading `StillShared` may
be racing the one that closes. `Client::close` now states the rule for any mix of `close`
and `Drop`.
- **A shedding budget states the memory it does not bound.** A single message larger
than `max_pubsub_bytes` or `max_push_bytes` is delivered rather than made
undeliverable, so the memory actually held is the budget plus one message, itself
bounded by `RespLimits::max_bulk_length` — 512 MiB by default. Both fields now say
so, since sizing a container is what the knobs are for.
- **`ReconnectionConfig` states that a cluster reconnection is all-or-nothing.** One
attempt must reach a seed that answers *and* connect every master in the topology it
returns; a single unreachable master sends the client back to the delay rather than
retrying that node. A cluster client therefore spends more attempts than a standalone
one on the same partial outage, which is a reason to leave `max_attempts` at `0`.
- **The README says which profile the benchmark numbers hold under.** `[profile.bench]`
sets `lto = "fat"` and `codegen-units = 1`, neither of which a downstream `--release`
build gets, and the in-tree comparisons against `fred` and `redis-rs` are measured
under it.
- **`CONTRIBUTING.md` names the `fuzzing` feature and how to run the targets.** It was
discoverable only from a `Cargo.toml` comment.
- **The `resp` page says why command arguments are `impl Serialize` and not a trait of the
crate's own.** The orphan rule allows an `impl` only in the crate defining the trait or
the type, so nobody could implement a `rustis` marker trait for `uuid::Uuid` — which
already implements `Serialize`. Such a trait could not be honest either: it answers for a
type, while the argument count is a property of the value.
- **`select` and `auth` warn that the connection is shared.** Every clone of a
`Client` shares one connection, so these commands apply to all clones. A new
`Connection-scoped commands` section in the `client` module lists the nine commands
that configure the connection, and points to `Config::database` and the credentials
fields.
- **`retry_on_error` says why it defaults to `false`.** Replaying a command the server
may already have applied makes delivery at-least-once, so it stays opt-in. The
default does not make `max_command_attempts` inert: that budget bounds cluster
`ASK`/`MOVED` redirections whatever the flag says. `set_jitter` gained the rule for
sizing jitter against the delay it spreads.
### Internal
- **The shutdown race is tested on `close` too, and with more than two handles.** Which
handle ends the connection is decided by `Arc::into_inner`, an invariant a comment argued
and one test covered for two concurrent drops. Eight handles now close at once over a
thousand rounds, and a second test mixes drops with closes. The first fails on both
designs this replaced: a reference-count check, and `Arc::try_unwrap`.
- **The state a client's clones share holds no sentinel.** The field was
`Arc<Option<ClientShared>>`, the `Option` there only so `close` could swap its reference
out before `Arc::into_inner`. A `Client` has no `Drop`, so `close` takes the `Arc` out of
the client it already owns. Both readers lose a `None` branch, and `close` loses the
allocation of the sentinel it swapped in.
- **A batch hands its replies back unnamed.** Every batch paired a `Bytes` name onto every
reply, which the pipeline unzipped apart again and dropped: a name is read only when a
reply fails, and only when exactly one command is awaited. The pipeline now takes that
name from the awaited-command flags, and the transaction takes the list itself. Worth
~57 µs of caller CPU per thousand commands — three vectors and 200 KiB of moves.
- **A held `CLIENT REPLY SKIP` is borrowed while it is routed, not cloned.** The five
cluster routing paths read it through `.cloned()`, because the reply mode and the node
topology looked like one borrow of the connection; they are separate fields, so the read
is a disjoint borrow. The clone measured 27 ns on the shared network task, once per
command the caller silences.
- **Reading a cluster tip off a `Command` no longer calls `Clone::clone`.**
`request_policy()` and `response_policy()` returned their fieldless enums through
`.clone()`; both accessors now copy. Not a measurable win — a release build already
compiled the clone away — but a fieldless tip that reads as if it allocates costs a
reader more than it costs the machine.
- **The `bench`-gated RESP entry points are behind a named module.** They were glob
re-exported into `resp`, standing beside the real API with nothing marking them apart.
They are now `resp::bench_support`, whose page states that it is a development instrument
with no stability guarantee. `docs.rs` omits the `bench` feature and `semver-checks` runs
on the explicit ones, so the module is documented and checked nowhere.
- **A command routed to a single shard no longer builds a key list.** It built two, one on
the sub-request and one on the request, and nothing read either: a key list is read only
to line one node's replies up against another's. The removal is not measurable on
`cluster_routing` (a 100-key single-slot `mget` unchanged at ~118 µs, p = 0.29); it ships
because the work was dead.
- **TLS and cluster routing have benchmarks.** The 16 existing targets covered neither, so
two headline features had no figure to weigh a change against. `tls_round_trip` measures
the handshake and the per-command record layer against a plain connection;
`cluster_routing` measures a routed command, and a cross-slot `mget` at 2, 10 and 100
keys, against a plain connection to the same node.
- **The cluster retry reasons are no longer a public type.** `RetryReason` named the ASK,
MOVED and TRYAGAIN redirections in the crate root, and `ErrorKind::Retry` carried a
`SmallVec` of them. Both were `#[doc(hidden)]`, so neither was in the documented
contract, yet a caller could read a redirect target out of an error no command is
answered with. It is now crate-internal, behind an opaque `RetryReasons`.
- **The benchmark and web-example crates are dev dependencies.** `criterion`,
`fred`, `redis`, `axum`, `actix-web` and `pprof` were optional dependencies so a
Cargo feature could gate them, which made two competing drivers read as
dependencies of this crate on crates.io. `bench` and `web-examples` carry no
dependency now; `required-features` still keeps their targets out of a build.
- **The two connection modules are split into nine.** `network_handler` and
`cluster_connection` had reached 1987 and 2477 lines, each holding the router, the
reply mode, the retry rule, the subscription table, the topology and the in-flight
queue in one `impl` over shared fields. Those move out, leaving 1460 and 1387 lines,
and each new type owns the invariant it used to share.
- **The command families are declared once instead of four times.** `Client`,
`ExclusiveClient`, `Pipeline` and `Transaction` each carried a hand-written block of
empty `impl`s, and nothing checked the four against each other. A family added to
the client and forgotten in a batch executor compiled, then failed at the call site.
The 22 data families now live in one list. The implemented sets are unchanged.
- **A `tests/` directory compiles the crate as a downstream consumer.** The whole
suite lived in `src/tests/`, where `pub(crate)` is in scope, so nothing exercised the
published surface by path. `tests/public_api.rs` queues a command from each family
into a pipeline and a transaction, which fails when a batch impl list falls behind.
- **The test suite selects the half that needs no server.** 493 tests reach neither a
Redis nor the network — 491 of the 1185 in the library, plus `tests/public_api.rs` —
and nothing named them. The `server-tests` feature, on by default, carries the
server-bound half, so `./run_tests.sh --hermetic` runs the rest in about a second
with no Docker. The 19 modules that held both kinds are split.
- **CI builds the targets and feature sets it skipped.** No job built the 18 benchmark
targets, the 10 `bench`-gated examples or the 4 `web-examples` ones: `--all-targets`
covers only what the named features enable. The feature matrix gains `fuzzing`, and
compiles the test tree with warnings denied — every job that built the suite named
`tokio-rustls`. `publish.yml` checks the docs.rs set and the native-tls backend.
## [0.24.0] - 2026-08-12
### BREAKING CHANGES
The upgrade checklist. Each item is stated fully, with the reason it moved, in the
section it belongs to below.
- **A text reply that is not entirely an integer is now an error.** Code reading a
field into an integer target got the numeric prefix of whatever the field held
-- `1.75` as `1`, `12abc` as `12` -- and now gets `CannotParseInteger`. Nothing
changes for a value that is an integer; what changes is that a value that is not
one stops being silently narrowed into one.
### Added
- **`i128` and `u128` serialize as command arguments.** A struct field of either
type could be read out of a reply but not written into a command: the argument
serializer had no 128-bit arm, so serde fell back to its default, which fails.
`hset` on a struct holding a `u128` returned an error naming a type the
deserializer accepts, and the round trip a caller expects to be symmetric was
not. All three writers -- the serializer, the argument counter, and the
fast-path builder -- now format them through `itoa` like every other width.
### Fixed
- **A text reply read as an integer is read whole or rejected.** The wire
deserializer parsed integers with `atoi::atoi`, which stops at the first byte
that is not a digit and returns what it read: `HGET` on a field holding `1.75`
answered `1` for a `u32` target, `12abc` answered `12`, and `0x10` answered `0`
-- a value the server never sent, indistinguishable from one it did. The crate
rejects exactly this elsewhere, deliberately: `double_to_int` refuses to
truncate a RESP3 double, and the `ValueDeserializer` parses through
`str::parse`, so the two deserializers disagreed on every such reply and the
path a caller took decided the answer. Integers now come from `int_from_text`,
which requires every byte to be consumed and the last one to be a digit, so a
leading remainder (` 12`), a trailing one (`12abc`) and a lone sign (`-`) are
all `CannotParseInteger`. An explicit `+` stays accepted, as RESP3 allows on an
integer reply, and overflow was already rejected. The same rule now covers the
digits behind a `:`, where a malformed integer frame was read as its prefix too.
- **The test suite builds on Windows.** `keep_alive_and_no_delay_are_applied`
read the keep-alive time back with `socket2::SockRef::tcp_keepalive_time`,
which Windows has no equivalent for and socket2 therefore compiles only on the
platforms that expose a getter. The whole `lib test` target failed to build
with `E0599`, so no test ran at all -- `cargo test` on Windows was unusable,
and every job in CI runs on `ubuntu-latest`, which is why nothing reported it.
The assertion is now guarded by `#[cfg(not(windows))]`; the value is still set
on Windows, and still asserted everywhere it can be read.
### Added
- **A `struct` maps onto a hash, and the tests say so.** `hset` takes any
`Serialize` and `hgetall` returns any `Deserialize`, so a struct round-trips
through a hash in two calls -- the argument serializer flattens it into
field/value pairs, taking the field names from the struct's own, and the
deserializer reads the reply back as a map. Nothing in the suite covered that
path, which is the reason to write a hash from a struct at all, and nothing
covered the details a caller trips on either: `rename`/`rename_all` decide the
field names on the wire, an unknown field in the hash is skipped rather than
fatal, a nested struct needs `#[serde(flatten)]` or a field of its own, and an
`Option` field must carry `skip_serializing_if` -- a `None` serializes to no
argument at all, leaving its field name paired with the next field's value.
`hset_hgetall_struct_of_primitives` pins the wire text a hash actually holds
(`1` for a `bool`, `1.75` for an `f32`), since another client reads that text,
and `a_bulk_string_reads_into_every_primitive` pins the other direction: every
integer width, `f32`/`f64` including `inf` and exponent notation, `bool`,
`char`, `String`, `Option`, plus the eleven values that are rejected -- out of
range, empty, or not that type -- because a hash field is a bulk string
whatever it holds, and that single wire form has to reach every target.
## [0.23.0] - 2026-08-07
### BREAKING CHANGES
The upgrade checklist. Each item is stated fully, with the reason it moved, in the
section it belongs to below.
- **Blocking commands and `WATCH` are implemented for `ExclusiveClient` alone.**
`Client::connect(cfg).await?` becomes `ExclusiveClient::connect(cfg).await?`, or
`Client::connect(cfg).await?.into_exclusive()?`, wherever a blocking command or
`watch`/`unwatch` is called. Nothing else moves.
- **`Error` is a struct rather than an enum.** Its variants live in `ErrorKind`, so
a `match` on `Error::Timeout` becomes one on `e.kind()` -- or on `e.into_kind()`
to match by value -- against `ErrorKind::Timeout`.
- **`PubSubMessage` is read through accessors.** `message.channel` becomes
`message.channel()` and `String::from_utf8(message.payload)` becomes
`std::str::from_utf8(message.payload())`. The public fields and the `Deserialize`
impl are gone.
- **`rustis::Future<'_, T>` is now `rustis::client::CommandFuture<'_, T>`.** Only
code naming the type is affected; awaiting a command is unchanged.
- **An empty collection reply decodes as `Some` of an empty collection.** Code using
`Option<Vec<T>>` over an `LRANGE`, `SMEMBERS` or `ZRANGE` as an emptiness test must
switch to `Vec<T>` and `.is_empty()`. Only a nil is `None` now.
- **`ts_get` returns `TsGetResult` instead of `Option<(u64, f64)>`.** It derefs to
that option, so `*sample` keeps the existing patterns compiling; `.into()` converts
it.
- **A textual reply read as a `bool` is an error outside `OK`/`1`/`true`/`0`/`false`**
where it used to be `false`, and a bulk string `OK` is now `true`.
- **A one-element array read as an integer from a `resp::Value` now requires that
element to be an integer**, as reading it off the wire already did.
### Added
- **The transport is open: Unix sockets, and a caller-supplied stream.**
`ServerConfig::UnixSocket { path }` reaches a server listening on a Unix domain
socket, spelled `unix:///var/run/redis.sock` in a URI — the socket path being the
whole URI path, the database is a `db` query parameter there rather than the last
path segment, and `keep_alive` / `no_delay`, which describe a TCP socket, are not
applied. `ServerConfig::Custom` takes a `TransportFactory`, which hands the client
any `AsyncRead` + `AsyncWrite` pair to speak RESP over: a `tokio::io::duplex` pipe
driven by a server of your own, a tunnel, a TLS stack configured elsewhere. The
trait is implemented for any closure returning a future, and is asked for a stream
at every dial rather than handed one once, so a reconnection gets a fresh stream —
the same reason `CredentialsProvider` is consulted at every handshake. Neither has
a `Debug` or `Display` that reveals anything about the factory behind it.
- **`Error` classifies itself.** `is_connection_error()`, `is_timeout()`,
`is_server_error()` and `is_retryable()` answer the questions every caller asks
of a failure: whose fault is it, and is it worth trying again. `ErrorKind` and
`ClientError` are `#[non_exhaustive]`, so this classification could not be
written outside the crate — a downstream `match` must end in a catch-all arm and
therefore silently misclassifies every variant added afterwards.
`is_connection_error()` covers the RESP framing failures too, since a stream the
parser lost track of costs the connection. `is_retryable()` reports a transient
failure, not a command that certainly did not run: its documentation says so,
because a timeout or a lost connection can follow a write the server applied.
### Changed
- **The connection-holding commands live on their own client type.** A `Client` is
clonable, and every clone multiplexes over one connection; blocking commands and
`WATCH` are incompatible with that — the first holds the connection until it
returns, the second attaches state to the connection rather than to the handle
that asked for it. Both were nonetheless implemented on every `Client`, so cloning
is what turned a legal program illegal, and the failure was a stalled shared
connection at run time. `BlockingCommands` and `TransactionCommands` are now
implemented for `ExclusiveClient` alone — a client that is **not** `Clone` — so the
mistake is a compile error. `ExclusiveClient::connect` opens a connection of its
own, `Client::into_exclusive` converts an existing handle and returns
`ClientError::NotExclusive` when another handle on the connection is alive (streams
and transactions opened from the client hold one too), and
`ExclusiveClient::into_multiplexed` goes back. It carries every other command
family, and `PooledClientManager::Connection` is an `ExclusiveClient`: a borrowed
connection is exclusive until it is given back, so the two families are legitimate
there. **Migration**: `Client::connect(cfg).await?` becomes
`ExclusiveClient::connect(cfg).await?`, or
`Client::connect(cfg).await?.into_exclusive()?`, wherever a blocking command or
`watch`/`unwatch` is called. Nothing else moves — `MULTI`/`EXEC` through
`Client::create_transaction`, pub/sub, and the ~600 other commands are unchanged on
a multiplexed client.
- **A pub/sub message is one block, read through accessors.** `PubSubMessage` held
three public `Vec<u8>` fields, allocating two per delivered message (three for a
`pmessage`). Its segments now share one exactly-sized block read through
`pattern()`, `channel()` and `payload()`, built from the push frame without serde;
the fields and the `Deserialize` impl are gone. They stay owned rather than
borrowed: the read buffer is a 64 KiB block the network task recycles, which a
retained view would pin. `ClientError::UnexpectedPubSubMessage` replaces the serde
error for a push that is not a `message`, `smessage` or `pmessage`. **Migration**:
`message.channel` becomes `message.channel()`;
`String::from_utf8(message.payload)` becomes
`std::str::from_utf8(message.payload())`.
Worth less than it looks: `benches/pub_sub_decode` puts delivery at ~220–340 ns per
message, dominated by the parse, so this buys 2–5 % up to 512-byte payloads and
nothing measurable at 4 KiB. A 64-byte inline buffer removing *every* allocation
was measured at 8–12 % **slower** and rejected.
- **An awaited command no longer allocates.** `client.get("key").await` — the form
every example, the README and all built-in command methods use — went through
`Box::pin(async move { … })`, so the documented path cost one heap allocation plus
one virtual call per command while the generic `client.send(…)` cost neither, in a
crate whose first philosophy point is low allocations. `IntoFuture for
PreparedCommand<'_, &Client, R>` now resolves to `client::CommandFuture`, a
hand-written state machine that lives in the caller's frame; building the future
and dropping it still sends nothing, and `command_timeout` still applies. Awaiting
is unchanged, but naming the old type is not: an `IntoFuture` associated type
spelled `rustis::Future<'_, T>` becomes `rustis::client::CommandFuture<'_, T>`.
Construction measures ~69 ns against ~80 ns boxed, with `client.send(…)` unmoved
at ~55 ns in the same rounds (`benches/into_future.rs`).
- **Errors name the command they belong to.** `Error` is now a struct rather than an
enum: its variants moved to `ErrorKind`, reachable through `Error::kind()` and
`Error::into_kind()`, and it carries the command alongside them, reachable through
`Error::command()`. A client multiplexes hundreds of commands over one connection,
so `Err(Error::Timeout)` named nothing the application could act on, a shed command
did not say what had been shed, and a cross-slot refusal did not say which command
was refused. The command is attached wherever the client fails a command on its
behalf — a `command_timeout`, a full send queue, a lost connection, a deferred
serialization error, a mismatched-slot routing refusal — and is absent for the
errors raised outside any command, a connection timeout in particular. `Display`
appends it: `The I/O operation's timeout expired (while executing BLMPOP)`. Calling
code matching on `Error::Timeout` matches on `e.kind()` against
`ErrorKind::Timeout`, or on `e.into_kind()` to match by value.
- **An empty collection no longer decodes as `None`.** `Option<T>` treated an empty
RESP array as a nil, so `Option<Vec<T>>` over an `LRANGE`, `SMEMBERS` or `ZRANGE`
could never observe an empty vector: "the collection is empty" and "the key does
not exist" collapsed into the same `None`. Only a nil — `*-1` in RESP2, `_` in
RESP3 — is `None` now; an empty array, map or set yields `Some` of an empty
collection. Blocking commands are unaffected: `BLPOP`, `BRPOP`, `BLMPOP`,
`BZMPOP`, `BZPOPMIN`/`BZPOPMAX` and `ZMPOP` reply nil on timeout, not an empty
array. Calling code that used `Option<Vec<T>>` as an emptiness test must switch
to `Vec<T>` and `.is_empty()`.
- **`ts_get` returns `TsGetResult` instead of `Option<(u64, f64)>`.** The time
series module reports an empty series as an empty array rather than a nil, so
that command was the one relying on the conflation above. `TsGetResult` reads
that shape itself and derefs to `Option<(u64, f64)>`, so `assert_eq!(None,
*sample)` and `if let Some((ts, value)) = *sample` keep working; `.into()`
converts it to the plain option.
### Fixed
- **Dropping a `PubSubStream` releases its subscriptions.** `Drop` named the
channels it was cancelling as a bare `&[u8]`, which serde renders as a sequence
of integers rather than as one bulk string: the client asked the server to
unsubscribe from `49 49` -- the ASCII codes of the channel `11` -- so it left the
real channel subscribed, and the wrong command being legal in itself, the server
answered it without complaining. The fire-and-forget failure was assigned to
`_result` and never logged, so nothing surfaced either. `close()` was unaffected,
passing the `Bytes` itself, which is why the documented equivalence between the
two -- "`drop` will achieve the same process but silently in background" -- did
not hold. Every later `subscribe` on that channel was then refused with
`AlreadySubscribed` for the life of the connection, which a long-polling handler
reaches on every cancelled HTTP request: its stream is dropped, never closed. The
names now go through `RefBulkString`, the error is logged, and
`dropping_a_stream_releases_its_subscriptions` covers the drop-then-resubscribe
cycle the existing `AlreadySubscribed` test did not.
- **A cluster subscription is cancellable.** `SUBSCRIBE`, `PSUBSCRIBE`,
`UNSUBSCRIBE` and `PUNSUBSCRIBE` name no key, so the cluster connection served
each of them on a node drawn at random: the unsubscription almost never reached
the node holding the subscription, which kept publishing on the channel for the
life of the connection — including through `PubSubStream::close()`. Each channel
or pattern is now hashed like a key to pick its node, so a subscription and its
cancellation always meet, and a command naming channels of different shards is
split per node. A channel-less `UNSUBSCRIBE` still goes to a single node, since
it names nothing to hash.
- **A subscription whose subscriber is gone is cleaned up.** When a pub/sub message
could not be handed to its subscriber because the receiving half had been dropped,
the client logged a warning and kept the subscription: the server went on
publishing to a channel nobody could receive on, one warning per message, for the
life of the connection. That state needs no bug to reach — a `command_timeout`
cutting `subscribe()` short after the server accepted it is enough, and so is
leaking the stream. The subscription is now removed and an `UNSUBSCRIBE`
(`PUNSUBSCRIBE`, `SUNSUBSCRIBE`) is sent, so the failed delivery is reported once
and the server stops publishing. On a cluster the `SUNSUBSCRIBE` is routed by the
shard channel's hash slot, so it reaches the node actually holding the
subscription.
- **The `bench` feature compiles again.** `resp::bench_support` still built `Err`
from an `ErrorKind` rather than an `Error`, so `--features bench` failed with five
errors. No CI job compiles that feature, which is why the `Error` restructuring
missed it.
- **A textual reply read as a `bool` follows one rule.** Asking for a `bool`
directly — `client.send(cmd).await?` typed as `bool` — and asking for a `Value`
and converting it afterwards — `value.into::<bool>()?` — gave different answers
for the same reply: a simple string other than `OK`, and a bulk string other than
`0`/`false`/`1`/`true`, were `false` the first way and a `CannotParseBoolean`
error the second. The reply's encoding mattered too, `+OK` being `true` where
`$2\r\nOK\r\n` was not, so a server switching between RESP2 and RESP3 could flip
the result. One rule now covers the reply's text whichever way it is read and
whichever encoding carries it: `OK`, `1` and `true` are `true`, `0` and `false`
are `false`, anything else is `CannotParseBoolean`. **Behaviour change**: text
outside that list used to be `false` when the `bool` was asked for directly and
is now an error — the server never said `false`, and the error names the problem
where the `false` hid it — and a bulk string `OK` is now `true`, as the simple
string `OK` already was. Integers, doubles, RESP booleans and nil are unchanged.
- **`Value` equality is total on doubles.** `Value` asserts `Eq` and is hashed as a
`Value::Map` key, yet doubles were compared with `==`, under which a NaN is not
even equal to itself. `,nan` is a legal RESP double — T-Digest and TimeSeries
return it for an empty sketch or an empty bucket — so a `nan` key inserted in a
map could never be looked up again, and two identical replies containing one
compared unequal as `Array`, `Set` or `Push`. Doubles are now compared and hashed
on a canonical bit pattern: all NaNs are equal to each other, and `-0.0` equals
`0.0` as before. The only observable change is `Value::Double(f64::NAN)` now
equalling itself.
- **The two deserializers agree on their coercions.** A reply read as a `Value` and
the same reply read straight off the wire went through two different `Deserializer`
implementations, which disagreed: `Value` rejected an integer or a boolean that the
wire path renders as text, so `client.incr(k)` typed as a `String` succeeded while
`Value::into::<String>()` on the same reply failed; a one-element array unwrapped to
an integer for every width on the wire but only for `i64`/`u64` from a `Value`; and
`i128`/`u128` were unimplemented on the `Value` side. The `Value` deserializer now
applies the wire path's rules: numbers and booleans are readable as text through
both `deserialize_str` and `deserialize_string`, the one-element-array unwrapping
covers the twelve integer widths, and `i128`/`u128` are supported. That unwrapping
now also requires the element to be an integer, as the wire path already did.
### Removed
- **`examples/loop.rs`.** It was not an example of anything — a CPU load for
profiling, ten thousand `SET`/`GET` round trips in a loop — and it was the only
such probe not gated behind the `bench` feature, so it built under the default
features and shipped in the published `.crate` tarball. The remaining profiling
probes (`pprof_*`, `head_to_head`, `strace_workload`, …) stay behind `bench`.
## [0.22.0] - 2026-08-01
### Added
- **`ClusterConfig::read_preference` routes read-only commands to the replicas.**
A cluster client sent every keyed command to the shard's master, replicas being
connected only to serve the broadcast policies, so a read-heavy deployment could
not spread its reads at all. `ReadPreference::PreferReplica` — spelled
`?read_preference=prefer_replica` in a URI — sends the commands the server reports
as `readonly` to the replicas of their shard, in round-robin, and puts those
connections in `READONLY` mode. Writes, blocking commands, transactions and
redirections stay on the master, as does everything when a shard has no reachable
replica. The default, `ReadPreference::Master`, keeps the previous behaviour: a
replica lags behind its master, so reading from one trades consistency for
throughput and has to be asked for.
- **`Config::credentials_provider` authenticates with credentials resolved at every
handshake.** `Config::password` is fixed once and for all, so a client of a managed
Redis whose password is a short-lived token (ElastiCache IAM, Memorystore IAM, Entra
ID, Vault) reconnects for one token lifetime and then fails authentication for good.
A `CredentialsProvider` — implemented for any closure returning
`Result<Credentials>` — is asked again on each reconnection. It takes precedence over
`username`/`password` and has no URI representation. `SentinelConfig::credentials_provider`
does the same for the Sentinel instances themselves; the two are independent, a
Sentinel being a different server with its own ACLs.
### Changed
- **The manifest declares `license = "MIT"` instead of `license-file`.** With
`license-file`, `cargo metadata` reports `license: null`, so every tool that
keys on the SPDX identifier — license scanners, dependency audits, policy
checks — sees the crate as carrying no license. `LICENSE` is the verbatim MIT
text and is still packaged, so the terms are unchanged.
- **A double no longer decodes as an integer unless the conversion is exact.**
Every integer width converted a `Double` reply with a saturating cast, so a
`ZSCORE` of `3.9` read as `i64` gave `3`, `1e300` gave `i64::MAX` and `NaN`
gave `0` — a plausible number for a value the server never sent. A double now
deserializes to an integer only when it is finite, has no fractional part and
fits the target; anything else fails with `ClientError::CannotParseInteger`,
as an out-of-range integer reply already did. Read such a reply as `f64` to
keep the fractional value. A negative reply read as `u128` no longer wraps
either. Both deserializers — RESP and `resp::Value` — are covered.
- **An array of more than one element no longer decodes as a single integer.**
Every integer width (`i8`…`i128`, `u8`…`u128`) unwrapped an array reply to its
first element and discarded the rest, so a mistyped response shape produced a
plausible number instead of an error. Only a one-element array still unwraps;
a longer one fails with `ClientError::CannotParseInteger`.
- **A connection URI now rejects a query parameter it does not understand.** An
unknown key (`?commandtimeout=5000`, `?reconnection=constant`) and a value that
does not parse (`?command_timeout=5s`) were both dropped silently, leaving the
default in place — a mistyped `command_timeout` meant no timeout at all. Both
now fail with `ClientError::InvalidUri`, whose message names the parameter.
Code relying on a URI with an unknown parameter being accepted must drop it.
The documented `reconnection` parameter never existed and has been removed from
the list; `max_command_attempts`, which is parsed, has been added to it.
- **`keep_alive` now defaults to 30 seconds** instead of `None`. Paired with the
default `command_timeout` of 0 (no timeout), the previous default left a
half-open connection — one silently dropped by a NAT, a firewall or a load
balancer — detected by nothing: no timeout, no keepalive, no socket error, so
every awaiting caller parked forever and `on_reconnect` never fired. Set
`keep_alive` to `None`, or `keep_alive=0` in a URL, to restore the old
behaviour.
- **Dependencies updated to their latest releases**, including two major bumps:
`rand` 0.9 → 0.10 and `atoi` 2.0 → 3.1. Both are internal; the public API is
unchanged. `serial_test` stays on 3.x — its 4.0 requires Rust 1.93, above the
crate's 1.88 MSRV, which is itself unchanged.
### Fixed
- **A Sentinel client rediscovers its master when the one it holds is demoted.**
A failover turns the master into a replica without closing the connections it
already serves, so nothing in the transport says the topology moved: the client
kept writing to a replica, reading stale data and collecting `READONLY` on every
write, for as long as the socket held — which is forever with the default absence
of `command_timeout` and TCP keepalive. A `READONLY` received on a Sentinel
connection now triggers a reconnection, and a Sentinel reconnection polls the
sentinels for the master again and accepts a node only once `ROLE` confirms it.
The caller who issued the refused write still receives the `READONLY` itself; the
commands in flight follow the usual reconnection rules. Standalone and cluster
connections are unchanged — neither has a master to look up.
- **`TRYAGAIN` and `CLUSTERDOWN` are now retried instead of reaching the
caller.** Both were parsed and never consulted, so a routine resharding — a
multi-key command whose keys straddle a slot in migration — or a failover
produced an application-visible error the driver is meant to absorb. A cluster
command answered `TRYAGAIN` is now replayed after 25 ms, and one answered
`CLUSTERDOWN` after 250 ms and a topology reload, through the same retry path
as `ASK`/`MOVED` and under the same `max_command_attempts` cap.
- **Dropping a command future does not cancel the command, and this is now
documented.** The message is already queued when the future is awaited, so it
is sent and executed by the server; only the reply is discarded. Every
`tokio::time::timeout`, `select!`, aborted task and `command_timeout` therefore
leaves a non-idempotent command applied, which nothing said. A new
`Cancellation and timeouts` section in the `client` module states the contract
and what to do about it; `Client::send` and `Config::command_timeout` point at
it.
- **The silent `nil` coercion is now documented as a trap instead of a convenience.**
A `nil` reply decodes as the neutral value of the response type — `0` for every
integer width, `0.0` for floats, `false` for `bool`, `""` for `String` — so a
missing counter reads as zero with no error. The behaviour is unchanged, but the
`resp` module, `StringCommands::get` and `Client::send` now state it and point at
`Option<R>`, which is honoured before any conversion and yields `None` on `nil`.
The `get` example presented the `String` case as a convenience.
- **The generic-command API documentation no longer teaches a Cluster misroute.**
The `MSET`/`MGET` examples in the crate documentation and in `Client::send` added
their keys with `arg`, which does not mark an argument as a key: the command
carried no slot and was sent to a random node. They now use `key` and same-slot
hash tags, and the rule is stated in the `cmd`, `arg` and `key` documentation.
- **`keep_alive` and `no_delay` are now applied to TLS connections.** Both were set
only on the plain TCP path: a TLS connection ran with Nagle's algorithm enabled
(up to 40 ms added to a small command) and without TCP keepalive, so a half-open
socket was detected by nothing. Both paths now share a single socket-setup step
applied to the `TcpStream` before the TLS handshake.
- **Vector-set commands are usable in pipelines and transactions again.**
`VectorSetCommands` was implemented for `&Pipeline` and `&Transaction` instead of
`&mut`, so `.queue()` and `.forget()` did not resolve on any of them and the whole
family was unreachable in batch mode.
- **Building without any runtime feature now fails with a message that says so.**
`default-features = false` without `tokio-runtime` left every function of the
runtime layer without a body, and the user got nine `cannot find … in this scope`
errors pointing at internal items. A `compile_error!` now names the missing
feature and how to enable it.
- **Enabling `rustls` or `native-tls` on its own is now a clear compile error.** The
backend-only features gate the TLS configuration types, while the connection code
reading them lives behind `tokio-rustls` / `tokio-native-tls`. Enabled alone they
produced six `cannot find … in this scope` errors on internal stream types. A
`compile_error!` now names the runtime feature to enable instead.
- **CI builds the feature combinations that were unbuildable.** No job compiled the
crate without a runtime feature, which is why that break went unnoticed. The
feature matrix gains `pool`, `json`, `client-cache` and the two TLS runtimes each
on their own, and a new job asserts that the four rejected configurations — no
runtime, both TLS runtimes, and each backend-only feature alone — fail with their
own `compile_error!` rather than with a cascade of internal errors.
- **The `actix_long_polling_pubsub` and `axum_long_polling_pubsub` examples compile
again.** They passed `lpop`'s count as a `usize` where the signature takes a `u32`.
## [0.21.0] - 2026-07-30
### BREAKING CHANGES
- **`FtHybridVectorQuery::Knn` has a third field, `shard_k_ratio`.** The variant's
fields are public and `#[non_exhaustive]` on the enum does not cover them, so an
existing `Knn { k, ef_runtime }` literal needs `shard_k_ratio: None` added. That
is the whole break; behaviour is unchanged when it is `None`.
### Added
- **`BackpressureConfig` on `Config` bounds the client's memory.** `max_queued_bytes`
(16 MiB) caps the send queue, `max_pubsub_bytes` (8 MiB) each subscription,
`max_push_bytes` (8 MiB) each push sink. Over budget, a stream drops its **oldest**
messages; `0` restores the previous unbounded behaviour.
- **`dropped_messages()` on `PubSubStream`, `MonitorStream` and
`ClientTrackingInvalidationStream`**, reporting what a budget shed.
- **`ClientError::SendQueueFull`**, returned when a command is offered to a send queue
that is over budget. Commands already accepted are never shed.
### Changed
- **`max_command_attempts` defaults to `5` instead of unlimited**, so a command that
keeps failing ends with `ClientError::MaxCommandAttemptsReached`. `0` still means
unlimited.
- **`ReconnectionConfig::max_attempts` documents that a non-zero value is a one-way
door**: reaching the limit ends the network task for good, so it is not recommended
for long-running services. Behaviour and the `0` default are unchanged.
### Fixed
- **A cache entry whose invalidation was lost is no longer served stale.** The cache
(feature `client-cache`) now flushes when invalidations are dropped, and a fetch in
flight across a flush no longer re-inserts its stale value — which also fixes the
pre-existing flush on reconnection.
- **`FT.HYBRID`'s `KNN SHARD_K_RATIO` is now reachable**, through
`FtHybridVectorQuery::Knn::shard_k_ratio`. It is a cluster-only knob — it scales
the candidate count each shard returns — and Redis 8.8 accepts it, where 8.6
rejected it as an unknown argument. It counts towards the `KNN` clause count:
`KNN 6 K 2 EF_RUNTIME 30 SHARD_K_RATIO 0.5`. See the breaking-changes section.
- **A struct no longer fails to decode when the server changes its field list.**
Commands that still answer a flat array under RESP3 (`XINFO`, `BF.INFO`,
`FT.INFO`, `XAUTOCLAIM`…) are decoded by guessing whether the array holds
field/value pairs or positional values, and that guess used to key on the struct's
field count — so one field added by a newer `redis-server` broke it. An array is
now read as pairs when its length is even and its first element names a field,
positionally otherwise, with both deserializers sharing the one rule. Added fields
and appended elements are ignored; a field the server stops sending gives a serde
error naming it. One consequence: an even-length positional array whose first
element happens to equal a field name is now read as pairs.
- **Structs decode from cluster-aggregated and cache-rebuilt replies**, which are
synthesized rather than parsed off the wire and previously failed with
`CannotParseStruct`.
## [0.20.0] - 2026-07-28
This release closes a large correctness and performance pass over the RESP
layer, the network task, the cluster client and the client-side cache. It
contains breaking changes; read that section before upgrading.
### BREAKING CHANGES
- **`ClientTrackingOptions::redirect` is removed.** Redirection sends client-side
caching invalidations to *another* connection. It exists for RESP2, which cannot
deliver an invalidation on a connection that is also answering commands, so the
target subscribes to `__redis__:invalidate` and reads them as pub/sub messages.
rustis always negotiates **RESP3**, where invalidations arrive as push frames on
the very connection that enabled tracking — which is what
`Client::create_client_tracking_invalidation_stream` and `Cache` consume. Setting
a redirection therefore sent them somewhere else and **silently starved both**:
they stayed alive, reported no error, and never fired again. It also could not
work on a cluster client at all, a client id being a per-node counter.
If you were relying on it, you were not receiving invalidations. The reasoning is
documented on `ClientTrackingOptions`.
- **The `async-std-runtime` and `async-std-native-tls` features are removed.**
async-std is no longer maintained; its authors point users to `smol`. Tokio is
now the only supported runtime, and the `async-std` and `async-native-tls`
dependencies are gone from the tree. Enabling either feature is now a Cargo
error, so the break is loud rather than silent. Nothing changes for the default
`tokio-runtime` build.
Support for another runtime is not ruled out: the runtime-facing surface is a
handful of primitives in one module (connect, spawn, sleep, timeout, join
handle). If you need a non-tokio runtime, open an issue.
- **`json_set` takes a `JsonSetOptions` instead of a bare `SetCondition`.**
`JSON.SET` gained the `FPHA` argument in 8.8, so the last parameter had to
become extensible. Calls passing `None` are unaffected; a call passing a
condition becomes
`json_set(k, p, v, JsonSetOptions::default().condition(SetCondition::NX))`,
or keeps working as it is through `From<SetCondition> for JsonSetOptions`.
- **`XPendingMessageResult::elapsed_millis` is an `i64`, not a `u64`.** The
server can answer `-1`, so the old type could not represent every reply. See
the fixes section.
- **Many public types are now `#[non_exhaustive]`, so that adding a variant or a
field to them stops being a breaking change.** This is a one-time cost taken
here, in a release that already breaks, rather than on each future addition.
What changes for you:
- Matching one of the affected enums now requires a `_ => …` arm.
- Constructing one of the affected structs with a struct literal (including
`Config { host, ..Default::default() }`) is no longer possible from outside
the crate; use `Default::default()` and assign the fields, or the type's
constructor.
The types covered are the ones whose shape is dictated by something other than
our own design: the error types (`Error`, `ClientError`, `RedisError`,
`RedisErrorKind`, `RetryReason`), the configuration types (`Config`,
`SentinelConfig`, `ClusterConfig`, `TlsConfig`, `ServerConfig`,
`ReconnectionConfig`, `BufferConfig`, `RespLimits`), every command-option enum
that follows Redis's own vocabulary (`SetCondition`, `BitOperation`,
`ExpireOption`, `SortOrder`, `GeoUnit`, `FtLanguage`, `XTrimOperator`, … 66 in
total), and every struct deserialized from a server reply (`ClusterInfo`,
`ClientInfo`, `FtInfoResult`, `MemoryStats`, `SentinelMasterInfo`,
`XStreamInfo`, … 71 in total), where Redis adds fields between versions.
Deliberately **not** covered: `resp::Value` and the enums decoded from a server
reply (`RoleResult`, `ReplicationState`, `RequestPolicy`, `ClusterState`, …).
These describe the protocol and Redis's own closed vocabularies; matching them
exhaustively is a legitimate thing to want, and a compile error on a new
variant is information rather than a nuisance. The builder-style `*Options`
structs are also untouched — their fields are already private, so they were
never literal-constructible and gain nothing.
Six of the 66 round-trip: `FtFieldType`, `FtIndexDataType`, `FtPhoneticMatcher`,
`KeyType`, `TsAggregationType` and `TsDuplicatePolicy` are both written into a
command and read back out of a reply. They are covered, because what a caller
does with them is build an option — and Redis does extend them: this release
alone adds `FtFieldType::Geoshape`, `TsAggregationType::CountNan` and
`CountAll`.
- **`TsAggregationType`'s discriminants moved.** `CountNan` and `CountAll` were
inserted before `First`, so every variant from `First` onwards shifted by two.
Only code casting a variant to an integer (`aggregation as isize`) is affected;
the wire form is the variant's name and is unchanged.
- **`ClientReplyMode` now implements `Copy`.** A non-`move` closure that used to
take it by value now captures it by reference.
- `resp::Command::name()` now returns `&[u8]` instead of `Bytes`. It borrows from
the command instead of bumping a reference count on every call. Callers that
need an owned value can use `Bytes::copy_from_slice(command.name())`.
- `resp::FastPathCommandBuilder::arg` and `::key` are no longer public. They
panicked on any non-primitive argument; they are now private, fallible, and
every fast-path constructor falls back to the generic `cmd(NAME)` builder
rather than panicking. Build commands through `resp::cmd` instead.
- `cache::Cache::zremrangebyscore` was removed. `ZREMRANGEBYSCORE` is a write
command and had no place on the cached read surface; call it on the `Client`.
- `Error::Tls` now wraps `Arc<native_tls::Error>` instead of `native_tls::Error`,
so `Error` stays `Clone` (a TLS failure has to be reported to every in-flight
command). The `From<native_tls::Error>` conversion is gone.
- `Error::OneshotCanceled` now wraps `tokio::sync::oneshot::error::RecvError`
instead of `futures::channel::oneshot::Canceled`, following the result channels
moving to tokio.
- Variants were added to `ClientError` (`CrossSlot`, `InvalidConfig`,
`MaxCommandAttemptsReached`, `MaxNestingDepthExceeded`, `BulkLengthTooLarge`,
`CollectionLengthTooLarge`, `InconsistentRoutingState`, `InvalidCacheKey`,
`UnexpectedSubscriptionConfirmation`), `SetCondition` (`IFNE`, `IFDEQ`) and
`BitOperation` (`Diff`, `Diff1`, `AndOr`, `One`), and public fields to `Config`
(`buffers`, `limits`, `max_command_attempts`, `max_messages_per_wave`),
`SentinelConfig` (`max_discovery_rounds`) and `FtIndexAttribute` (`algorithm`,
`data_type`, `dim`, `distance_metric`). All of these types are
`#[non_exhaustive]` as of this release, so equivalent additions will not break
again.
- `FtFlatVectorFieldAttributes::num_attributes` and
`FtHnswVectorFieldAttributes::num_attributes` were removed, along with the
unused `resp::SmallVecWithCounter`. The two `num_attributes` were
hand-maintained mirrors of the fields their struct serializes, which a new
field would have silently invalidated; the count now comes from the
serialization itself.
- `CommandBuilder::kill_connection_on_write` is no longer public. It is a
failure-injection hook for the crate's own tests and is now gated behind
`cfg(test)`, so it is absent from shipped builds instead of being part of the
API.
- **Five items that were `pub` without being usable are now private.** Each was
reachable in name only: no caller outside the crate could construct the
argument it needed or the type itself.
- `resp::RespDeserializer`, together with `resp::EnumAccess` and
`resp::VariantAccess`. `RespDeserializer::new` takes a `RespView`, which is
crate-private, so the type had no reachable constructor; the other two are
serde plumbing it hands to a visitor, and their names collided with serde's
own `EnumAccess` / `VariantAccess` traits under `use rustis::resp::*`.
Deserialize a reply through `Response` / `PreparedCommand` as before.
- `commands::deserialize_bzop_min_max_result`, a `#[serde(deserialize_with)]`
helper that a glob re-export made public.
- `cache::Cache::from_builder`. Its `builder` parameter is a
`moka::future::CacheBuilder` over the cache's internal representation, a
private type alias, so the method could not be called from outside. Use
`Cache::new`. Configuring the underlying moka cache is not currently
expressible in the public API; if you need it, open an issue.
- `Command`, `CommandBuilder`, `PreparedCommand`, `CommandArgsMut`,
`SortOptions`, `MigrateOptions`, `JsonGetOptions` and `AclDryRunOptions` no
longer implement `UnwindSafe` and `RefUnwindSafe`. `Command` now carries the
deferred serialization error described below, and `Error` is not
`RefUnwindSafe` (it holds an `Arc<std::io::Error>`, which can wrap a boxed
`dyn Error`). This only affects code passing these types through
`std::panic::catch_unwind`.
- **Behavior change** — an empty array now decodes to `Value::Array([])`, an
empty map to `Value::Map({})` and an empty push to `Value::Push([])`, instead
of all three collapsing to `Value::Null`. RESP's empty-versus-nil distinction
is preserved: a nil reply (`_` / `*-1`) still decodes to `Value::Null`. Typed
deserialization (`Vec<T>` → `[]`) is unaffected.
- **Behavior change** — a malformed numeric reply (`:a\r\n`, `,abc\r\n`) now fails
the command that received it instead of tearing down the connection. RESP
framing only needs the terminating `\r\n`, so the stream stays aligned and the
other in-flight commands on the connection are unaffected. A malformed boolean
(`#a\r\n`) still fails the connection: its frame length depends on the payload
being `t` or `f`, so anything else leaves the frame boundary unknown.
- **Behavior change** — a numeric reply deserialized into a `String` now gives
back the bytes the server sent rather than a re-rendering of the decoded value.
A reply of `,12.50` used to come back as `"12.50"` → `f64` → `"12.5"`, and
`,1e21` as twenty-two digits; both are now verbatim. Deserializing into a
numeric type is unaffected.
- **Behavior change** — an integer reply that does not fit the requested type is
now an error instead of a silent truncation. `Integer(300)` deserialized as
`u8` used to yield `44`; it now fails. `i64::MIN` is accepted (it was
previously rejected). This applies to both the RESP deserializer and the
`Value` deserializer, which stay consistent with each other.
- Repairing the commands listed under *Fixed* changed five signatures:
- `ClusterCommands::cluster_info` takes no argument (was `slot`, `count`).
- `ClusterCommands::cluster_getkeysinslot` returns `R: Response` (was `()`).
- `TimeSeriesCommands::ts_decrby` returns `u64` (was `()`).
- `ClusterBumpEpochResult::Bumped` and `Still` now carry the config epoch the
node ends up with (`Bumped(u64)` / `Still(u64)`), and the enum became
`#[non_exhaustive]`. Match arms binding no field need updating; the new
`ClusterBumpEpochResult::epoch()` reads the value whichever the outcome.
- Eight methods lost a generic type parameter — `ClusterCommands::cluster_addslots`,
`cluster_addslotsrange`, `cluster_count_failure_reports`, `cluster_delslots`,
`cluster_delslotsrange`, `cluster_forget`, `SearchCommands::ft_profile_search`
and `SentinelCommands::sentinel_failover`. Only a call site passing the
parameter explicitly (`cluster_forget::<T>(…)`) needs changing.
- **Eight option builders changed shape because they could not express their
command.** All eight are described in the fixes section; the API deltas are:
- `HScanOptions::no_values` was removed, replaced by
`HashCommands::hscan_no_values`, which returns `(u64, R)` and emits `NOVALUES`
itself.
- `SortOptions::store` was removed; use `sort_and_store`, which appends `STORE`
itself.
- `BfInfoResult::expansion_rate` is an `Option<usize>`, not a `usize`.
- `TsInfoResult::key_self_name` is an `Option<String>`, not a `String`.
- `FtSearchResultRow::score` is an `FtScore { value, explanation }`, not an
`f64`.
- `TsRangeOptions::bucket_timestamp` and `TsMRangeOptions::bucket_timestamp`
take the new `TsBucketTimestamp` (`Low` / `High` / `Mid`), not a `u64`.
- `TsRangeOptions::filter_by_ts` and `TsMRangeOptions::filter_by_ts` take
`impl IntoIterator<Item = u64>`, not a single `&str`.
- `FtSearchOptions::inkey` and `FtSearchOptions::infields` lost an
unconstrained generic parameter, which had made them uncallable without a
turbofish naming an unused type — so no working caller can break.
- `RestoreOptions::frequency` takes a `u8`, not an `f64`. It emitted
`FREQUENCY 10.0` where `RESTORE` takes `FREQ 10`, so every call failed; `u8`
is the LFU counter's actual range.
- **`FtSearchResultRow::values` and `extra_attributes` hold an
`FtAttributeValue`, not a `String`.** `FT.AGGREGATE`'s `TOLIST` and
`RANDOM_SAMPLE` reducers return an array of strings per group, which a
`Vec<(String, String)>` could not hold — both failed with
`CannotParseString`.
`FtAttributeValue` is `Text(String) | Array(Vec<String>)`. Read it with
`as_str()` or `as_array()`, which return `Option`. `PartialEq` against `str`,
`&str`, `String` and `[String]` is implemented in both directions, so
`assert_eq!("40", value)` still compiles; code binding the value as a `String`
needs `.as_str()` or a `match`.
- **`JsonRef` is removed; `Json<T>` serializes as well as it deserializes.** The
JSON wrapper is now one name in both directions:
`client.set(key, Json(&value))` and
`let Json(value): Json<T> = client.get(key).await?`. `Json(&value)` borrows
exactly as `JsonRef(&value)` did — `&T` is itself `Serialize` — so the
migration is a rename, with `Json(value)` available when the value can be
moved.
- **A value `serde_json` cannot encode now fails the command instead of being
stored as an empty one.** The wrapper's `Serialize` swallowed the error and
sent a unit argument in the value's place, so `client.set(key, Json(&value))`
returned `Ok(())` having written an absent value under `key`. It now returns
`Error::Client(ClientError::SerdeSerialize(_))` and sends nothing. Code that
treated a JSON argument as infallible has an error to handle.
Reading a nil reply as `Json<T>` also reports a different message — it now
names `Option<Json<T>>`, which is what a possibly-missing key needs, instead
of serde's `invalid type: Option`. Only the text changes.
- **`JsonArrIndexOptions::start` and `stop` are `isize`.** They were `u32` and
`i32`, so a negative `start` — which `JSON.ARRINDEX` reads as an offset from
the end of the array, like every other index in this family — could not be
expressed. Literals still infer; a caller passing a `u32` or `i32` variable
needs a cast.
### Security
- Passwords are no longer written in clear text by `Display for Config`. Both the
main and the Sentinel credentials are masked as `:***@`, so a configuration
logged at startup no longer leaks the password.
- The `native-tls` backends now request TLS 1.2 as their minimum version.
`native-tls`'s own default allowed TLS 1.0.
- The RESP parser now bounds every quantity a server controls, so a hostile or
malfunctioning peer cannot drive the client into a crash or an unbounded
allocation: nesting depth is capped (a deeply nested reply used to overflow the
stack), bulk and collection lengths are capped, and negative bulk, error and
collection lengths are rejected rather than being used as sizes. `-1` remains
the nil form. All limits are configurable through `Config::limits`.
- Decoding and logging server input no longer contain panic paths: the whole
crate now denies the explicit-panic clippy family (`unwrap_used`,
`expect_used`, `panic`, `unreachable`, `todo`, `unimplemented`), with
`indexing_slicing` additionally denied in the `resp` and `network` modules,
enforced in CI. A panic on the network task would take down every in-flight
command along with the reconnection loop.
### Added
- **`client::ClientTrackingInvalidationStream` is now exported.**
`Client::create_client_tracking_invalidation_stream` returns it, but a
`pub(crate)` re-export kept the name out of reach: the value could be used
inline and never stored in a field, named in a signature or boxed.
- **Redis 8.8 support**, established the same way as the 8.6 pass below: the
8.8 server's `COMMAND DOCS` diffed against a throwaway 8.6 server, so the
delta is what the servers themselves disagree on rather than what a release
note mentions.
A new data type, with its own `ArrayCommands` trait: an **array** is
sparse and index-addressed, so unlike a list it has no push or pop — you
write at an index you choose (`arset`, `armset`) or at a cursor the array
carries (`arinsert`, `arring`, moved with `arseek`). Gaps cost nothing, which
is why `arlen` (highest index plus one) and `arcount` (slots that hold a
value) never coincide. All eighteen commands are covered: `arcount`, `ardel`,
`ardelrange`, `arget`, `argetrange`, `argrep`, `arinfo`, `arinsert`,
`arlastitems`, `arlen`, `armget`, `armset`, `arnext`, `arop`, `arring`,
`arscan`, `arseek`, `arset`, with `ArGrep`, `ArGrepPredicate`, `ArOperation`,
`ArInfoOptions`, `ArLastItemsOptions` and `ArrayInfo`.
Two more commands:
- `increx` (+ `IncrExOptions`), a bounded increment that sets the expiration
in the same atomic step. It returns both the new value and the increment
that was actually applied, which is `0` when a bound stopped it. With
`ubound_int` as the cap and `enx` to start the window only once, a window
counter rate limiter no longer needs a Lua script.
- `xnack` (+ `XNackMode`, `XNackOptions`), which releases pending messages
back to the group's PEL without acknowledging them. The entries lose their
owner and their idle time, so another consumer can claim them at once
instead of waiting out `min-idle-time`.
Arguments added to existing commands:
- `ZAggregate::Count` on `zinter`, `zinterstore`, `zunion` and `zunionstore`
- `FtFieldType::Geoshape` (+ `FtGeoShapeCoordSystem`) on `ft_create`
- `JsonSetOptions::fpha` (+ `JsonFpType`) on `json_set`, which declares the
storage type of a floating-point homogeneous array
- **Complete command coverage up to Redis 8.6**, established by diffing the
server's own `COMMAND DOCS` against the crate rather than by reading release
notes, and verified against a Redis 8.6.5 server.
Redis 8.6 itself:
- `HOTKEYS`: `hotkeys_start`, `hotkeys_stop`, `hotkeys_get`, `hotkeys_reset`,
`hotkeys_help`, with `HotKeysMetric`, `HotKeysStartOptions` and
`HotKeysInfo`. `HOTKEYS GET` replies with one entry per node; the fields
tied to a metric are absent — not empty — when that metric was not tracked,
so they are `Option`.
- Stream idempotent production: `xcfgset` (+ `XCfgSetOptions`),
`XAddOptions::idmp` and `XAddOptions::idmp_auto`, and the six IDMP fields on
`XStreamInfo`.
- `TsAggregationType::CountNan` and `CountAll`.
Commands that pre-dated 8.6 and had been missed:
- `xsetid` (+ `XSetIdOptions` for `ENTRIESADDED` / `MAXDELETEDID`)
- `cluster_myshardid`
- `module_loadex` (+ `ModuleLoadexOptions`) and `module_unload`
- `json_merge`
- `bf_card`, `topk_count`
- `vismember`, `vrange`
Arguments that existed on implemented commands but were not reachable:
- `XClaimOptions::last_id` (`LASTID`)
- `JsonGetOptions::format` with `JsonGetFormat::{String, Expand1, Expand}`
- `FtFieldSchema::index_missing` / `index_empty`, and
`FtCreateOptions::index_all` (+ `FtIndexAll`), which takes an explicit
`ENABLE` / `DISABLE` value rather than being a flag
- **Rustis now emits [`tracing`](https://docs.rs/tracing) events and spans**
instead of plain `log` records.
**If you use `log`, nothing changes and you need to do nothing.** The `log`
feature of `tracing` is enabled, so every event also emits a `log` record and
existing `env_logger`-style setups keep receiving the same output.
What you gain by installing a `tracing` subscriber instead: every event from
the network task is wrapped in a `connection` span carrying a `tag` field, so
output from several clients stays attributable; reconnections open a nested
`reconnect` span grouping the in-flight purge, the retries and the
subscription replay; and in cluster mode, events about a specific node carry a
`node` field. Connection identity is no longer duplicated into each message —
it is a structured field a collector can index.
- **A declared minimum supported Rust version: 1.88.** `Cargo.toml` now carries
`rust-version`, and a CI job compiles both runtimes with exactly that
toolchain, so the number is verified rather than asserted. Let chains hold the
floor there; edition 2024 on its own would allow 1.85. Raising the MSRV will be
treated as a breaking change and announced here.
- Redis 8.4 command support: `FT.HYBRID` (including the advanced
post-processing options), `CLUSTER SLOT-STATS`, `CLUSTER MIGRATION`, `DIGEST`
and `DELEX`. Plus the options that were missing from various Redis 8.x
commands, and `XADD`/`XTRIM`'s entries-deletion policy.
- `StreamCommands::xdelex` and `StreamCommands::xackdel` (Redis 8.2), which
delete — and for `XACKDEL` acknowledge — stream entries under an explicit
`StreamEntryDeletionPolicy`, and report per-id whether each entry was removed,
was missing, or was kept because the policy forbade it.
- `resp::CommandBuilder::arg_counted`, which writes a labeled clause followed by
the number of arguments it contains — the shape `SORTBY 2 field ASC` and
`PARAMS 4 n1 v1 n2 v2` require. The count comes from a dry run of the same
serialization, so it cannot drift from what is written.
- `resp::CommandBuilder::arg_with_count_and_step` and
`resp::CommandBuilder::key_with_count_and_step`, which prefix a collection with
the number of `step`-sized groups it holds rather than its raw argument count —
the shapes `HSETEX key FIELDS numfields field value …` and `MSETEX numkeys key
value …` require. The `key_` form additionally marks every `step`-th element as
a routing key for the cluster client. Both derive their count the same way
`arg_counted` does.
- The examples now declare `tokio-runtime` in their `required-features`, so a
feature set that does not build them no longer fails the whole target set.
- `Config` now exposes the constants that were hardcoded, each defaulting to its
previous value: `Config::buffers` (`BufferConfig` — read/write buffer initial
and shrink-back capacities), `Config::limits` (`RespLimits` — maximum nesting
depth, bulk length and collection length), `Config::max_messages_per_wave`,
`Config::max_command_attempts` and `SentinelConfig::max_discovery_rounds`.
`Config::validate()` runs at connection time and rejects a value that would
disable a behavior. `RespLimits::DEFAULT` and `BufferConfig::DEFAULT` give the
same defaults in a `const` context.
- The parser accepts RESP3 attribute (`|`) and big number (`(`) frames.
- The client-side cache now compacts an entry before storing it: the response's
bytes are copied into freshly-sized buffers instead of pinning the larger
recycled network block they were read from. A numeric reply is decoded on the
spot rather than copied, so a cache entry read a thousand times is decoded
once.
- `cargo-fuzz` targets over the RESP read path, and a `fuzz_api` module exposing
the parser entry points they drive.
- `resp::bench_support`, the benchmark counterpart to `fuzz_api`: thin entry
points into the decode-and-deserialize path, isolated from the network, so an
external `benches/*.rs` crate can measure the parser on hand-built RESP
buffers. Gated behind the `bench` feature and compiled out of shipped builds.
### Changed
- Three `if let` guards in the `Value` deserializer were rewritten as plain
matches. They were the only construct in the crate requiring Rust 1.95, so
removing them lowered the compiler floor by seven releases. Behavior is
unchanged.
- Documentation only, no API change:
- `BlockingCommands` and each of its methods now state prominently that a
blocking command monopolizes its connection, that `command_timeout` bounds
only the caller's wait and does not free the connection server-side, and
that these commands belong on a dedicated client. The constraint was
documented before, but only in the client module's *Limitations* paragraph.
- The README gained a *Safety* section explaining `#![forbid(unsafe_code)]` as
a deliberate position — what it costs on a length-delimited protocol, and
what actually guards the hostile-input surface instead (the panic lint
policy, the configurable RESP limits, the fuzz targets).
- The `check_resp2_array` heuristic in the `Value` deserializer is now
documented, including where it is looser than the equivalent rule in the
RESP deserializer.
- The crate-level documentation claimed command coverage "until Redis 8.0";
it is 8.4, as the README already said.
- **RESP collection decoding now uses a flat parse tape.** A collection reply is
parsed once into a sequence of fixed-width nodes (one per element, all nesting
levels) held in a recycled buffer, and reading an element is an O(1) node
lookup instead of re-parsing the collection from the start. This removes the
double-parse that the previous 5-range frame cache fell back to beyond its
fifth element, and makes descending into nested replies O(1) per subtree.
Walking a collection allocates nothing per element, which makes a large reply
about 10 % faster to decode end to end.
- **A reply's value is produced when it is read, not when it is received.** The
parser frames — it finds where a reply ends and indexes a collection's elements
— and the value itself is decoded by whichever task asks for it. A connection's
network task is shared by all of its callers, so the arithmetic of an integer or
a double reply no longer runs there. End-to-end throughput is unchanged; what
changes is where the work happens, not how much of it there is.
- **The streaming decoder now resumes across TCP chunks.** A reply split over
several network reads is parsed incrementally — the partial tape and an
explicit parse stack are carried forward — instead of re-parsing the whole
accumulated buffer on each read. Decoding a large collection delivered in
~16 KB slices is now roughly on par with decoding it from a single slice
(previously about 2.5× slower).
- **The parser no longer builds error values on the success path.** Its
per-element (and per-digit) hot path used `Option::ok_or`, which eagerly
constructs the (large) `Error` enum on every call and drops it again on
success. Switching to `ok_or_else` builds an `Error` only when one is actually
returned, cutting parse-and-deserialize time by roughly 15–30 % across reply
shapes.
- **The network task was optimized for throughput**, cutting per-command
overhead: the message and result channels moved to tokio's `mpsc`/`oneshot`,
`Message` shrank from 2536 to 232 bytes, replies are dispatched straight to the
waiting caller as they decode rather than after the batch completes, the send
wave is capped so reading is never starved by writing, and the TCP stream is
split without a `BiLock`. Measured against `0.19.3` on a single connection to a
local server: **+65 % ops/s at 64 concurrent tasks, +111 % at 256 and +100 % at
1024**, and −15 % wall-time on the latency-bound multiplexer benchmark. Below 32
tasks the workload is round-trip-bound and unchanged.
- Routing work stays on the caller thread and off the network task: key hash
slots are computed lazily by the caller, `ArgLayout` shrank to 12 bytes, and
the cluster client indexes `MGET` reordering and shard-key lookups by hash set
instead of scanning.
- The read buffer is reserved from the announced bulk length, so a large reply no
longer grows the buffer by repeated doubling, and oversized read/write buffers
shrink back to their target instead of staying at their peak for the
connection's lifetime.
### Fixed
- **Connection-state commands now reach every node of a cluster instead of one
random node.** A cluster client is one connection per node, and this state lives
on the connection. Only `CLIENT SETNAME` and `CLIENT SETINFO` declared a routing
policy; `AUTH`, `CLIENT TRACKING`, `CLIENT NO-EVICT`, `CLIENT NO-TOUCH` and
`RESET` carried none, so each landed on an arbitrary shard and left the others
in their previous state.
The worst consequence was **client-side caching on a cluster client: it served
stale values indefinitely.** Tracking armed on one node means the keys held by
every other shard are cached and never invalidated, with no error and no log.
A node that joins the topology later — through a `MOVED`-triggered refresh, or
as a replica connected on demand — is now brought up to the same state before
anything is sent on it, so a resharding no longer opts a shard out silently.
- **`CLIENT REPLY` now works on a cluster client instead of hanging it.** A cluster
connection matches each reply against the sub-request it filed for the node it was
sent to, so a silenced node used to leave that sub-request unresolvable and every
caller queued behind it waited forever.
`ON` and `OFF` are sent to every node — the mode has to be the same everywhere —
and no in-flight bookkeeping is filed while the nodes are silent. This makes the
use cases the Redis documentation names available to cluster clients too:
fire-and-forget bursts, mass loading, constantly streamed cache writes.
`SKIP` silences only the next command, so it is emitted on exactly the nodes that
command is routed to — one for a key-routed command, every touched shard for a
multi-shard one, all of them for a broadcast one. Previously it reached a single
arbitrary node, which shifted the responses of everything that followed.
`SELECT` and `READONLY` are unchanged and now document why: a cluster has one
database, and rustis does not route slot reads to replicas.
- **A reconnection now restores the state the caller had attached to the
connection, not only what the config describes.** Redis keeps a good deal of
state on the connection itself, and a new socket starts without any of it. The
handshake only ever restored `HELLO`, the config credentials, the config
connection name and the config database, so a `SELECT`, an `AUTH`, a
`CLIENT SETNAME`, a `CLIENT SETINFO`, a `CLIENT NO-EVICT` / `NO-TOUCH`, a
`CLIENT TRACKING` issued directly or a `SCRIPT DEBUG` issued at
runtime was silently lost the first time the connection blipped. Two of those
are silent and damaging: the connection came back reading **database 0** and
authenticated as **whoever the config names**, while answering normally.
Each of these is now replayed as the command the caller actually issued, so
option-carrying commands such as `CLIENT TRACKING` come back exactly as they
were sent. A replay the server rejects is logged and dropped rather than
failing the connection, so one bad `AUTH` cannot turn into a permanently dead
client. `READONLY` is deliberately not among them: a cluster reconnection
redials every node, so replaying it would grant the whole cluster a capability
the send path never grants.
- **`CLIENT REPLY OFF` no longer desynchronizes every response after a
reconnection.** The client mirrors the reply mode to know how many responses
each command produces, and that mirror did not follow the socket: a
reconnection taken while the connection was silent left the client expecting
nothing while the server answered everything, shifting every subsequent
response by one — permanently.
- **`CLIENT REPLY SKIP` silences one command again, instead of the connection.**
It was treated as a sticky `OFF` until an explicit `ON`, so `SKIP` followed by
more than one command made the client expect fewer replies than the server
sends. `SKIP` before exactly one command — the only case the suite covered —
behaved correctly and hid this.
- **`RESET` now clears the client's picture of the connection too.** It was
recognised for a single one of its effects (leaving `MONITOR`). The reply mode
stayed wrong afterwards, and a later reconnection restored subscriptions the
caller had explicitly discarded.
- **A pooled client whose network task has ended is dropped from the pool.**
`PooledClientManager::has_broken` returned `false` unconditionally, so a client
that could no longer answer anything stayed in circulation and was handed to
every subsequent borrower. Note that the pool still does **not** reset
connection state between borrows — it hands out a multiplexed client, not a
fresh connection; this is now documented on `PooledClientManager`, and callers
needing a clean slate should issue `RESET` themselves.
- **Eighty-six option builders that nothing called got a test, and seventeen of
them were broken.** Counting `*Options` / `*Schema` / `*Attribute` builder
methods rather than commands shows that a tested command says nothing about its
options: `ft_search` had a test all along and eleven of its options had never
been sent once. Each of the 86 now has one, written from the syntax the server
prints for itself and red before green. Thirteen of the seventeen defects were
options that could not work in any call at all.
Wrong token or wrong argument shape:
- `ClientKillOptions::user` emitted `USERNAME` instead of `USER`, so any use
answered `ERR syntax error`.
- `ClientKillOptions::addr` and `laddr` sent `ADDR ip port` as two arguments
where the server wants a single `ip:port`. Same error.
- `ZAddOptions::change` emitted `CHANGE` instead of `CH`.
- `FtSearchOptions::inkey` and `infields` suppressed their own field name
through `#[serde(rename = "")]`, so the count and values went out with no
`INKEYS` / `INFIELDS` token.
- `FtAggregateOptions::load_all` emitted `LOAD 1 *`; `LOAD *` carries no count,
and the counted form loads a field named `*`, which is to say nothing.
- `FtAggregateOptions::add_scores` was serialized after `LOAD`, and the server
accepts `ADDSCORES` only before it.
Options no signature could express:
- `TsGroupByOptions` had no `Default`, so `ts_mrange` and `ts_mrevrange` forced
a `GROUPBY` the server makes optional — and grouping renames every returned
series. It now defaults to emitting nothing.
- `FtSearchSummarizeOptions` had neither `Default` nor a constructor, so
`FtSearchOptions::summarize` had no argument that could be built.
Replies the declared type could not hold:
- `client_list` returned one phantom `ClientInfo` with `id: 0` on every call:
the reply is newline-terminated and the trailing empty line was parsed as a
client.
- `ts_info(key, false)` always failed with `missing field keySelfName`; the
existing test only ever passed `true`.
- `bf_info_all` always failed on a `NONSCALING` filter — the filters the two
`nonscaling()` options exist to create.
- `FT.SEARCH ... EXPLAINSCORE` always failed with `CannotParseDouble`, so the
option's own output was unreachable.
See `RUSTIS_AUDIT.md` §3.2 for the full table and what the pass says about
reviewing builders by reading them.
- **All 362 option builders in the commands layer are now covered by a test**,
including the ones on schemas, reducers and query clauses (`FtReducer`,
`FtSortBy`, `FtHnswVectorFieldAttributes`, `FtHybridSearch`, `FtHybridVsim`,
`ArGrep`, `GeoSearchFrom`). Two were broken:
- `FtReducer::first_value_by` and `first_value_by_order` never emitted the
`BY` keyword: they sent `FIRST_VALUE 2 @name @age` and
`FIRST_VALUE 3 @name @age DESC`, both rejected with
`Unknown argument @age at position 1 for FIRST_VALUE`. Now
`FIRST_VALUE 3 @name BY @age` and `FIRST_VALUE 4 @name BY @age DESC`.
- `RestoreOptions::frequency` emitted `FREQUENCY 10.0` where `RESTORE` takes
`FREQ 10`, so every call answered `syntax error`. See the breaking-changes
section.
`FT.HYBRID`'s per-clause `YIELD_SCORE_AS` is confirmed working on Redis 8.8,
on both the `SEARCH` and the `VSIM` clause.
- **`ZAggregate` was never introduced by its `AGGREGATE` token.** `zinter`,
`zinterstore`, `zunion` and `zunionstore` appended the bare value, so
`zinter(keys, None, ZAggregate::Sum)` sent `ZINTER 2 k1 k2 SUM` and failed
with `syntax error`. The whole enum was therefore unusable, and no test
passed one — the same blind spot as the two token bugs below. Found while
adding `ZAggregate::Count`.
- **`XPendingMessageResult::elapsed_millis` could not hold the value the server
sends.** It was a `u64`, but `XPENDING` answers `-1` for an entry with no
delivery to measure from — the state `xnack` puts entries in — which failed
to deserialize. It is now an `i64`. See the breaking-changes section.
- **Two option builders emitted a token the server rejects, so the options were
unusable.** Both came from a `rename_all` rule silently producing the wrong
spelling, and neither had a call site anywhere in the crate — which is why no
test caught them:
- `ZRangeOptions::reverse()` emitted `REVERSE` instead of `REV`, so any
`zrange` or `zrangestore` using it failed with `syntax error`.
- `VSimOptions::with_attributes()` emitted `WITHATTRIBUTES` instead of
`WITHATTRIBS`, so any `vsim` requesting attributes failed the same way.
Both now have a live integration test and a wire-form test pinning the exact
token.
- **A cluster client deadlocked after any subscription.** A subscription is
acknowledged by a push frame, which the cluster connection hands straight to
the network task instead of filing it as the answer to the request it sent.
That request stayed at the head of the pending queue forever, and since
replies are reported in order, the first reply coming from any other node
waited behind it — the connection stopped answering entirely. The
acknowledgement now retires the request; an error reply such as `MOVED` is
still filed as a result, so redirections keep working.
- **`spublish` was sent to an arbitrary node.** Its shard channel was passed as
a plain argument rather than as a key, so the cluster client could not route
it and relied on the server's `MOVED` to find the shard — one useless round
trip per call, on the path that then hit the deadlock above. `ssubscribe` and
`sunsubscribe` already routed by slot.
- Eleven broken links in the published documentation, which rendered as dead
text on docs.rs: `resp::Args` (a trait that does not exist — arguments are any
`serde::Serialize`), `Command::arg` (it is `CommandBuilder::arg`),
`Client::send_batch` (removed from the public API, still linked from three
places — use `Pipeline`), `FtAggregateOptions::reduce` (it is on `FtGroupBy`),
`FtSugGetOptions::withpayload` (plural), `TsMGetOptions::selected_labels` (the
method is `selected_label`), `Command::compute_slots` (private), a bare
`Serialize`, and a doubled parenthesis swallowing a URL. `cargo doc` now runs
in CI with warnings denied, so these cannot come back unnoticed.
- **`zadd_incr` never incremented anything.** It omitted the `INCR` keyword, so
it sent a plain `ZADD` and answered the number of elements added — `Some(0.0)`
for an existing member — instead of the member's new score.
- **`vlinks_with_score` never asked for the scores.** It emitted the same
`VLINKS` command as `vlinks`, so it answered bare neighbour names and any
attempt to deserialize the scores failed.
- **`cluster_info` could not succeed.** It sent two arguments `CLUSTER INFO` does
not accept, and `ClusterInfo` was derived as if the reply were a map when the
server answers a text blob of `field:value` lines. The type now parses that
text, tolerating both the counters a server omits and the fields a newer one
adds.
- **`cluster_getkeysinslot` and `ts_decrby` threw their reply away.** Both were
typed `()` where the server does answer something — the names of the keys in
the slot, and the timestamp of the upserted sample.
- **`cluster_bumpepoch` could not succeed.** `CLUSTER BUMPEPOCH` answers a single
line holding both the outcome and the resulting epoch, as in `STILL 84`, while
`ClusterBumpEpochResult` was derived as a plain lowercase enum tag. Every call
failed with `unknown variant`. The type now parses that line and carries the
epoch.
- **Eight command methods were effectively uncallable.** They carried a generic
type parameter that appeared nowhere in their signature, so it could not be
inferred and reaching them required a turbofish naming a type that was never
used: `cluster_addslots`, `cluster_addslotsrange`,
`cluster_count_failure_reports`, `cluster_delslots`, `cluster_delslotsrange`,
`cluster_forget`, `ft_profile_search` and `sentinel_failover`.
- `ClusterInfo`, `ClusterState`, `ClusterBumpEpochResult`, `ClusterLinkInfo` and
`ClusterLinkDirection` now derive `Debug`, and the two enums `PartialEq`, like
the other types decoded from a cluster reply.
- **Concurrent pipelines could return each other's replies.** `pending_responses`
was shared across batches instead of being scoped to one, corrupting results
under concurrent pipelined use.
- **Iterating a collection past its fifth element could yield corrupted values**
(the fallback re-parser produced ranges against the wrong buffer base). The
tape indexes every element uniformly, removing that path by construction.
- **A large reply arriving in many TCP segments was re-parsed from the start on
every segment**, making decode cost quadratic in the number of segments. The
decoder now keeps resume state, so the cost is linear in the reply size.
- Cluster: a redirection now retries only the sub-requests that were redirected
rather than the whole split command; an `ASK` redirection to a node absent from
the topology is followed; a per-shard failure surfaces as an error on that
request instead of reconnecting the entire cluster; requests that can never be
fulfilled are purged from the in-flight set instead of hanging; aggregates over
shards of unequal length are rejected; and a transaction spanning several slots
is refused before being sent rather than failing server-side.
- Reconnection: pub/sub bookkeeping is rebuilt when in-flight messages are
replayed, in-flight unsubscriptions are dropped rather than resubscribed,
non-retryable in-flight messages are purged, protocol decode errors trigger a
reconnect instead of being swallowed, the reconnect delay is capped, and a
per-message retry counter fails a message past `Config::max_command_attempts`
instead of replaying it forever.
- Pub/sub: local subscription tracking is kept until the server confirms the
unsubscribe, an undecodable event no longer terminates the push stream, and a
subscription confirmation that does not match what was requested is surfaced as
an error.
- Client cache: client-side tracking is re-armed and the cache purged on
reconnection; the `MONITOR` parser is quote-aware; an insert racing an
invalidation can no longer store a stale entry; and a zero-argument key
returns an error instead of panicking.
- `command_timeout` now applies to `subscribe` and `monitor`.
- Pipelines: an empty pipeline resolves as an empty batch instead of surfacing an
opaque channel-canceled error, and a single forgotten command has its response
dropped as it would in a multi-command batch.
- Configuration URLs: IPv6 addresses and percent-encoded credentials are parsed
correctly, `MOVED`/`ASK` addresses are split at the last colon (IPv6), and
Sentinel discovery is bounded and resilient.
- Commands: `MSETEX` and `HPERSIST` send their mandatory count argument;
`HSETEX` no longer panics on an odd field/value list; the `CLIENT REPLY SKIP`
typo in command-kind detection is corrected; and a command-builder
serialization error is deferred to send time instead of panicking during the
build.
- **Search: several clauses declared the wrong argument count and were rejected
by the server.** `FT.AGGREGATE`'s `LOAD` and `FT.SEARCH`'s `RETURN` announced
the number of attributes where Redis counts arguments, so renaming an attribute
(`FtAttribute::new("a").r#as("b")`) produced `LOAD 1 a AS b` and failed with
`Unknown argument AS`. `PARAMS` announced the number of pairs instead of twice
that, so any query with more than one parameter failed. `FT.SPELLCHECK`'s
`TERMS` was prefixed with a count the syntax does not take. These counts are
now derived from what is actually written rather than from the collection's
length, so they cannot disagree with it.
- `resp::Value`'s `Boolean` compares by value rather than by discriminant.
- Closing the last `Client` clone closes the connection race-free.
- A collection element that fails to parse mid-iteration now surfaces an error
instead of silently truncating the iteration.
- `Debug` on a response renders the decoded reply rather than the internal tape.
- The deserializer's two string entry points agree. A reply readable as a `String`
is now equally readable where serde asks for a borrowed string — a struct field
name or an enum variant name — instead of the target type deciding whether the
command succeeds.
[0.25.0]: https://github.com/dahomey-technologies/rustis/compare/0.24.0...0.25.0
[0.24.0]: https://github.com/dahomey-technologies/rustis/compare/0.23.0...0.24.0
[0.23.0]: https://github.com/dahomey-technologies/rustis/compare/0.22.0...0.23.0
[0.22.0]: https://github.com/dahomey-technologies/rustis/compare/0.21.0...0.22.0
[0.21.0]: https://github.com/dahomey-technologies/rustis/compare/0.20.0...0.21.0
[0.20.0]: https://github.com/dahomey-technologies/rustis/compare/0.19.3...0.20.0