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,readwriteandcluster_slotsbecome internal, andquitis deleted.
The four types that served them become internal too:HelloOptions,HelloResult,
LegacyClusterShardResultandLegacyClusterNodeResult. -
Five deprecated string commands are removed:
getset,psetex,setex,
setnxandsubstr. -
A
nilreply read as a scalar is now an error, not0/""/'\0'. Declare
the response as anOptionto accept the absence. The rule reaches inside a reply, so
hmgetreads asVec<Option<String>>, and three public fields becomeOption<String>:
TsInfoResult::source_key,FunctionInfo::descriptionand
XPendingResult::{smallest_id, greatest_id}. Collections,Valueandboolare exempt. -
Seven commands now route on a key they only named before. A cross-slot call to
sdiffstore,sinterstore,zdiffstore,zinterstore,zunionstore,
sort_and_storeorlcswas refused by the server withCROSSSLOT, and is now
refused locally withClientError::MismatchedKeySlots. -
The queue memory budget now covers what is in flight. A command was charged
toBackpressureConfig::max_queued_bytesuntil 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::MapholdsVec<(Value, Value)>instead ofHashMap<Value, Value>,
andValueno longer implementsHash. Code that built a map with
HashMap::from([…])or read it withget/contains_keyon the inner type must
change;Value::getreplaces the lookup. -
Value::SimpleStringequals theValue::BulkStringcarrying the same bytes.
A comparison that relied on the two being distinct now answerstrue. -
ErrorKind::Timeoutcarries aTimeoutKind.matches!(e.kind(), ErrorKind::Timeout)becomesErrorKind::Timeout(_), and the two deadlines can
now be told apart by name. -
ClientError::Unexpectedis removed, replaced by the seven variants that
say which condition occurred:MalformedFrame,InconsistentRespTape,
NotACollection,MissingTransactionReply,IncompatibleShardReplies,
NotAUnitVariantandMissingMapValue. -
ClientError::InvalidChannelis removed. No code path produced it: it stood for
a client holding no send channel, which nothing can observe. -
Pipeline::queue/forgetandTransaction::queue/forgetbecome
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::keytakes 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-builtcmd("DEL").key(my_vec)now fails with
ClientError::InvalidKeyArity. The counted forms are unchanged and may still declare
zero keys, asEVALdoes. -
resp::Responseis deleted. Replace it withserde::de::DeserializeOwnedin a
whereclause. The trait waspub trait Response {}with a blanket impl for every
Deserializetype, so theR: Responsebound on 232 command signatures constrained
nothing whileIntoFuturere-requiredDeserializeOwnedbehind it. The bound now
says what it always meant. -
Client::closereturnsCloseOutcomeinstead of(). A connection is shared
by every clone of a client, so aclosethat finds a clone alive shuts nothing down
and used to report that asOk(()).CloseOutcome::ClosedandStillSharednow
tell the two apart.ExclusiveClient::closefollows. -
RedisError::descriptionis a method, not a field, and the bytes are kept. A server
error reply is bytes and can echo a key, whichString::from_utf8_lossyused to mangle
on the way in.description()answers aCow<str>with the same lossy reading,
description_bytes()the exact bytes.kindstays a public field. -
ClientError::InvalidTagis 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_rawhands a reply back as RESP bytes. A proxy or a protocol bridge
had to read throughValue, 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 aresp::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_errortells them apart. -
rustis::preludeholds every command trait. A command lives on a trait, so a program
calling several families collected oneuseper family. The prelude re-exports all 28,
the two batch traits, the four executors and the pub/sub types.Resultstays out: a
glob import of it shadows the standard prelude's and leavesResult<T, E>naming
nothing. A test readssrc/commands/mod.rsand fails on a family left out. -
A pub/sub message reads as text or as a Rust type.
PubSubMessage::channel_str()
andpattern_str()answer a&str, failing withErrorKind::Utf8on 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 asJson<T>;Tmay borrow, so&strallocates nothing. -
Every tuning knob is now addressable in a URL.
buffers,backpressureandlimits
take one query parameter per field, named after it —buffers.read_capacity,
limits.max_bulk_length.reconnectionnames the policy andreconnection.delayand
its siblings shape it; a field the policy does not carry is rejected, not dropped.
Displaywrites 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-masterand rediscovers the master when a Sentinel announces one, and
polls the fleet everySentinelConfig::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 anilreply is read as a type that
cannot hold an absence. The message names the target type and points atOption. -
A key argument that is not a single key fails the command, with
ClientError::InvalidKeyAritynaming the command and the argument count. Arguments are
impl Serialize, so the compiler cannot count what a value produces: aNonekey 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::statsreturns aClientStatssnapshot: 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_byteswith no way to see whether it was hit. -
Client::config,Client::is_connectedandClient::server_versionreport
what a client is connected to. A readiness probe needed aPING, and branching on
the server version needed a secondHELLO.server_versionisNoneon a
cluster, whose nodes have versions of their own.ExclusiveClienthas all four. -
ConfigisSerializeandDeserialize, so a service maps a TOML/YAML/JSON
section onto it.buffers,backpressure,limitsandreconnectionhad no URI
spelling and were reachable from Rust only. Missing fields take their defaults;
credentials_provider,tls_configandServerConfig::Customcarry Rust code and
are skipped. -
TlsConfig::new(rustls) wraps arustls::ClientConfigbuilt 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,
scriptingandclient_side_caching.wakeup_cost_probeandcache_stampede_probe
now need thebenchfeature, like the nine other profiling harnesses. -
Config::interceptortakes aCommandInterceptor, 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_storetakes aCacheStore, so the client-side cache can be backed by a
store shared between clients, or one with an eviction policy of its own.Cacheis
generic over it and defaults toMokaStore, soCachealone still means what it did.
An entry is an opaqueCachedValue: handing the bytes out would pin a recycled network
buffer, so a store cannot persist an entry. -
ReconnectionConfig::Customtakes aReconnectionPolicy, 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 anyFn(u32) -> Option<Duration>,
so a closure is enough. The three built-in shapes are unchanged. -
prepare_commandis 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 ofclient.send(cmd("MYGET")…). Every built-in command trait is written this
way; only the helper was private. -
Client::is_terminatedreports 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_intervalreloads the cluster topology on a
timer, 60 seconds by default (?topology_refresh_interval=,0to 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. -
Valueaccessors:as_str,as_bytes,as_i64,as_f64,as_bool,
as_array,as_map,as_error,is_nullandget. 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.
Ris
discarded when a command enters a batch — the tuple onexecutedecides 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 therespmodule page. -
Value::Mapkeeps the reply. Its entries are in the order the server sent
them, and a field the server repeats appears twice. AHashMaplost both, made
Display/Debugnondeterministic, and was the sole reasonValuecarried a
hand-writtenHashoverf64and nested maps. -
Valuecompares payloads, not variants.SimpleStringandBulkStringcarry
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!, notwarn!, and names its command.
A caller that gives up on its reply — acommand_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
Clientis clonable, so one
connection carries the commands of every clone:HELLOchanged the protocol version the
deserializers depend on,READONLYthe read modeClusterConfig::read_preference
depends on, andASKINGis correct only immediately before the command it redirects.
The client now sends them itself.CLUSTER SLOTScallers usecluster_shards. -
quitis deleted. Redis deprecated it in 7.2.0. On a multiplexed client it
closed the connection of every clone. UseClient::close. -
ClientError::InvalidChannelis removed. It reported a client whose send channel
was gone, a state aClientcannot be in:closetakes the client by value, so the
handle that gives the channel up is unreachable afterwards. A send that finds the
network task gone reportsClientError::DisconnectedFromServer. -
The deprecated string commands are removed. Use
set_get_with_optionsfor
getset,set_with_optionswithSetExpiration::ExorPxforsetexand
psetex,set_with_optionswithSetCondition::NXforsetnx, andgetrangefor
substr.COMMAND DOCSreports 21 deprecated commands; the crate implemented these
five andquit. No module command reports a deprecation.
Fixed
-
A cluster reconnection rediscovers the topology from the nodes it holds, not only from
the configured seeds.reconnectdialledClusterConfig::nodesalone, 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_passwordandwait_between_failuresare read only by
a sentinel URI,read_preferenceandtopology_refresh_intervalonly by a cluster one,
anddbonly 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 asString
rebuilt the text from thatf64: a score of1e+20came back as
100000000000000000000on a hit and1e+20on a miss,nanasNaN. 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
Displaywrote"{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
SUBSCRIBEno 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 withClientError::UnexpectedSubscriptionConfirmation,
non-deterministically. A confirmation is now matched by name. -
A channel-less
UNSUBSCRIBEnow 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, andlcsdid 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.INFOon a series that is not a
compaction target answered""for its source key,FUNCTION LISTdid the same for
a function with no description,FT.CONFIG GETfor an option carrying no value, and
XPENDINGon 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::geton 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_commandsno 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_timeoutbounds the handshake, not only the dial. A server that accepted the
socket and never answeredHELLOleftClient::connectwaiting forever: the dial
succeeded in microseconds, so the only deadline in the path had already been met. The
budget now covers both, raisingErrorKind::Timeout(TimeoutKind::Connect). -
An internal failure names the condition it hit.
ClientError::Unexpectedreported a
dozen distinguishable conditions asUnexpected 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 nowMalformedFrame, which the framing list does carry. -
Enabling both TLS backends reports one error.
rustlsandnative-tlseach
define aTlsConfigand anError::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 benchno longer costs three minutes to measure nothing. Every benchmark
is a criterion target withharness = false, but the lib test target was still built
under[profile.bench]on every invocation, to report0 measured.bench = false
in a[lib]section removes the build: 3m02s becomes 0.13s. The README now names the
benchfeature the targets require. -
Reconnection jitter no longer vanishes when the backoff saturates. The delay was
clamped tomax_delayafter the jitter was added, so every client of a fleet woke
at exactlymax_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 ismax_delay + jitter. -
The pool health check no longer parks on a silent server.
is_validpinged with
no deadline of its own, andcommand_timeoutdefaults 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 bycommand_timeout, or by
connect_timeoutwhen 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 bySENTINEL 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 grewmessages_to_receivewithout limit, which is the hole in the
documented "bound memory withBackpressureConfig" 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 aftermax_messages_per_wave. Measured on
rustis_long_pipeline: no change outside the run-to-run drift.
Documentation
-
CloseOutcome::StillSharedsays 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 readingStillSharedmay
be racing the one that closes.Client::closenow states the rule for any mix ofclose
andDrop. -
A shedding budget states the memory it does not bound. A single message larger
thanmax_pubsub_bytesormax_push_bytesis delivered rather than made
undeliverable, so the memory actually held is the budget plus one message, itself
bounded byRespLimits::max_bulk_length— 512 MiB by default. Both fields now say
so, since sizing a container is what the knobs are for. -
ReconnectionConfigstates 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 leavemax_attemptsat0. -
The README says which profile the benchmark numbers hold under.
[profile.bench]
setslto = "fat"andcodegen-units = 1, neither of which a downstream--release
build gets, and the in-tree comparisons againstfredandredis-rsare measured
under it. -
CONTRIBUTING.mdnames thefuzzingfeature and how to run the targets. It was
discoverable only from aCargo.tomlcomment. -
The
resppage says why command arguments areimpl Serializeand not a trait of the
crate's own. The orphan rule allows animplonly in the crate defining the trait or
the type, so nobody could implement arustismarker trait foruuid::Uuid— which
already implementsSerialize. Such a trait could not be honest either: it answers for a
type, while the argument count is a property of the value. -
selectandauthwarn that the connection is shared. Every clone of a
Clientshares one connection, so these commands apply to all clones. A new
Connection-scoped commandssection in theclientmodule lists the nine commands
that configure the connection, and points toConfig::databaseand the credentials
fields. -
retry_on_errorsays why it defaults tofalse. Replaying a command the server
may already have applied makes delivery at-least-once, so it stays opt-in. The
default does not makemax_command_attemptsinert: that budget bounds cluster
ASK/MOVEDredirections whatever the flag says.set_jittergained the rule for
sizing jitter against the delay it spreads.
Internal
-
The shutdown race is tested on
closetoo, and with more than two handles. Which
handle ends the connection is decided byArc::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, andArc::try_unwrap. -
The state a client's clones share holds no sentinel. The field was
Arc<Option<ClientShared>>, theOptionthere only soclosecould swap its reference
out beforeArc::into_inner. AClienthas noDrop, soclosetakes theArcout of
the client it already owns. Both readers lose aNonebranch, andcloseloses the
allocation of the sentinel it swapped in. -
A batch hands its replies back unnamed. Every batch paired a
Bytesname 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 SKIPis 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
Commandno longer callsClone::clone.
request_policy()andresponse_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 intoresp, standing beside the real API with nothing marking them apart.
They are nowresp::bench_support, whose page states that it is a development instrument
with no stability guarantee.docs.rsomits thebenchfeature andsemver-checksruns
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-slotmgetunchanged 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_tripmeasures
the handshake and the per-command record layer against a plain connection;
cluster_routingmeasures a routed command, and a cross-slotmgetat 2, 10 and 100
keys, against a plain connection to the same node. -
The cluster retry reasons are no longer a public type.
RetryReasonnamed the ASK,
MOVED and TRYAGAIN redirections in the crate root, andErrorKind::Retrycarried a
SmallVecof 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 opaqueRetryReasons. -
The benchmark and web-example crates are dev dependencies.
criterion,
fred,redis,axum,actix-webandpprofwere optional dependencies so a
Cargo feature could gate them, which made two competing drivers read as
dependencies of this crate on crates.io.benchandweb-examplescarry no
dependency now;required-featuresstill keeps their targets out of a build. -
The two connection modules are split into nine.
network_handlerand
cluster_connectionhad 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 oneimplover 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,PipelineandTransactioneach carried a hand-written block of
emptyimpls, 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 insrc/tests/, wherepub(crate)is in scope, so nothing exercised the
published surface by path.tests/public_api.rsqueues 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, plustests/public_api.rs—
and nothing named them. Theserver-testsfeature, on by default, carries the
server-bound half, so./run_tests.sh --hermeticruns 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 10bench-gated examples or the 4web-examplesones:--all-targets
covers only what the named features enable. The feature matrix gainsfuzzing, and
compiles the test tree with warnings denied — every job that built the suite named
tokio-rustls.publish.ymlchecks the docs.rs set and the native-tls backend.