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
WATCHare implemented forExclusiveClientalone.
Client::connect(cfg).await?becomesExclusiveClient::connect(cfg).await?, or
Client::connect(cfg).await?.into_exclusive()?, wherever a blocking command or
watch/unwatchis called. Nothing else moves. -
Erroris a struct rather than an enum. Its variants live inErrorKind, so
amatchonError::Timeoutbecomes one one.kind()-- or one.into_kind()
to match by value -- againstErrorKind::Timeout. -
PubSubMessageis read through accessors.message.channelbecomes
message.channel()andString::from_utf8(message.payload)becomes
std::str::from_utf8(message.payload()). The public fields and theDeserialize
impl are gone. -
rustis::Future<'_, T>is nowrustis::client::CommandFuture<'_, T>. Only
code naming the type is affected; awaiting a command is unchanged. -
An empty collection reply decodes as
Someof an empty collection. Code using
Option<Vec<T>>over anLRANGE,SMEMBERSorZRANGEas an emptiness test must
switch toVec<T>and.is_empty(). Only a nil isNonenow. -
ts_getreturnsTsGetResultinstead ofOption<(u64, f64)>. It derefs to
that option, so*samplekeeps the existing patterns compiling;.into()converts
it. -
A textual reply read as a
boolis an error outsideOK/1/true/0/false
where it used to befalse, and a bulk stringOKis nowtrue. -
A one-element array read as an integer from a
resp::Valuenow 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, spelledunix:///var/run/redis.sockin a URI — the socket path being the
whole URI path, the database is adbquery parameter there rather than the last
path segment, andkeep_alive/no_delay, which describe a TCP socket, are not
applied.ServerConfig::Customtakes aTransportFactory, which hands the client
anyAsyncRead+AsyncWritepair to speak RESP over: atokio::io::duplexpipe
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 reasonCredentialsProvideris consulted at every handshake. Neither has
aDebugorDisplaythat reveals anything about the factory behind it. -
Errorclassifies itself.is_connection_error(),is_timeout(),
is_server_error()andis_retryable()answer the questions every caller asks
of a failure: whose fault is it, and is it worth trying again.ErrorKindand
ClientErrorare#[non_exhaustive], so this classification could not be
written outside the crate — a downstreammatchmust 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
Clientis
clonable, and every clone multiplexes over one connection; blocking commands and
WATCHare 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 everyClient, so cloning
is what turned a legal program illegal, and the failure was a stalled shared
connection at run time.BlockingCommandsandTransactionCommandsare now
implemented forExclusiveClientalone — a client that is notClone— so the
mistake is a compile error.ExclusiveClient::connectopens a connection of its
own,Client::into_exclusiveconverts an existing handle and returns
ClientError::NotExclusivewhen another handle on the connection is alive (streams
and transactions opened from the client hold one too), and
ExclusiveClient::into_multiplexedgoes back. It carries every other command
family, andPooledClientManager::Connectionis anExclusiveClient: 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/unwatchis called. Nothing else moves —MULTI/EXECthrough
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.
PubSubMessageheld
three publicVec<u8>fields, allocating two per delivered message (three for a
pmessage). Its segments now share one exactly-sized block read through
pattern(),channel()andpayload(), built from the push frame without serde;
the fields and theDeserializeimpl 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::UnexpectedPubSubMessagereplaces the serde
error for a push that is not amessage,smessageorpmessage. Migration:
message.channelbecomesmessage.channel();
String::from_utf8(message.payload)becomes
std::str::from_utf8(message.payload()).Worth less than it looks:
benches/pub_sub_decodeputs 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 genericclient.send(…)cost neither, in a
crate whose first philosophy point is low allocations.IntoFuture for PreparedCommand<'_, &Client, R>now resolves toclient::CommandFuture, a
hand-written state machine that lives in the caller's frame; building the future
and dropping it still sends nothing, andcommand_timeoutstill applies. Awaiting
is unchanged, but naming the old type is not: anIntoFutureassociated type
spelledrustis::Future<'_, T>becomesrustis::client::CommandFuture<'_, T>.
Construction measures ~69 ns against ~80 ns boxed, withclient.send(…)unmoved
at ~55 ns in the same rounds (benches/into_future.rs). -
Errors name the command they belong to.
Erroris now a struct rather than an
enum: its variants moved toErrorKind, reachable throughError::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,
soErr(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 — acommand_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 onError::Timeoutmatches one.kind()against
ErrorKind::Timeout, or one.into_kind()to match by value. -
An empty collection no longer decodes as
None.Option<T>treated an empty
RESP array as a nil, soOption<Vec<T>>over anLRANGE,SMEMBERSorZRANGE
could never observe an empty vector: "the collection is empty" and "the key does
not exist" collapsed into the sameNone. Only a nil —*-1in RESP2,_in
RESP3 — isNonenow; an empty array, map or set yieldsSomeof an empty
collection. Blocking commands are unaffected:BLPOP,BRPOP,BLMPOP,
BZMPOP,BZPOPMIN/BZPOPMAXandZMPOPreply nil on timeout, not an empty
array. Calling code that usedOption<Vec<T>>as an emptiness test must switch
toVec<T>and.is_empty(). -
ts_getreturnsTsGetResultinstead ofOption<(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.TsGetResultreads
that shape itself and derefs toOption<(u64, f64)>, soassert_eq!(None, *sample)andif let Some((ts, value)) = *samplekeep working;.into()
converts it to the plain option.
Fixed
-
Dropping a
PubSubStreamreleases its subscriptions.Dropnamed 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 from49 49-- the ASCII codes of the channel11-- 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
_resultand never logged, so nothing surfaced either.close()was unaffected,
passing theBytesitself, which is why the documented equivalence between the
two -- "dropwill achieve the same process but silently in background" -- did
not hold. Every latersubscribeon that channel was then refused with
AlreadySubscribedfor 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 throughRefBulkString, the error is logged, and
dropping_a_stream_releases_its_subscriptionscovers the drop-then-resubscribe
cycle the existingAlreadySubscribedtest did not. -
A cluster subscription is cancellable.
SUBSCRIBE,PSUBSCRIBE,
UNSUBSCRIBEandPUNSUBSCRIBEname 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 throughPubSubStream::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-lessUNSUBSCRIBEstill 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 — acommand_timeout
cuttingsubscribe()short after the server accepted it is enough, and so is
leaking the stream. The subscription is now removed and anUNSUBSCRIBE
(PUNSUBSCRIBE,SUNSUBSCRIBE) is sent, so the failed delivery is reported once
and the server stops publishing. On a cluster theSUNSUBSCRIBEis routed by the
shard channel's hash slot, so it reaches the node actually holding the
subscription. -
The
benchfeature compiles again.resp::bench_supportstill builtErr
from anErrorKindrather than anError, so--features benchfailed with five
errors. No CI job compiles that feature, which is why theErrorrestructuring
missed it. -
A textual reply read as a
boolfollows one rule. Asking for abool
directly —client.send(cmd).await?typed asbool— and asking for aValue
and converting it afterwards —value.into::<bool>()?— gave different answers
for the same reply: a simple string other thanOK, and a bulk string other than
0/false/1/true, werefalsethe first way and aCannotParseBoolean
error the second. The reply's encoding mattered too,+OKbeingtruewhere
$2\r\nOK\r\nwas 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,1andtruearetrue,0andfalse
arefalse, anything else isCannotParseBoolean. Behaviour change: text
outside that list used to befalsewhen theboolwas asked for directly and
is now an error — the server never saidfalse, and the error names the problem
where thefalsehid it — and a bulk stringOKis nowtrue, as the simple
stringOKalready was. Integers, doubles, RESP booleans and nil are unchanged. -
Valueequality is total on doubles.ValueassertsEqand is hashed as a
Value::Mapkey, yet doubles were compared with==, under which a NaN is not
even equal to itself.,nanis a legal RESP double — T-Digest and TimeSeries
return it for an empty sketch or an empty bucket — so anankey inserted in a
map could never be looked up again, and two identical replies containing one
compared unequal asArray,SetorPush. Doubles are now compared and hashed
on a canonical bit pattern: all NaNs are equal to each other, and-0.0equals
0.0as before. The only observable change isValue::Double(f64::NAN)now
equalling itself. -
The two deserializers agree on their coercions. A reply read as a
Valueand
the same reply read straight off the wire went through two differentDeserializer
implementations, which disagreed:Valuerejected an integer or a boolean that the
wire path renders as text, soclient.incr(k)typed as aStringsucceeded 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 fori64/u64from aValue; and
i128/u128were unimplemented on theValueside. TheValuedeserializer now
applies the wire path's rules: numbers and booleans are readable as text through
bothdeserialize_stranddeserialize_string, the one-element-array unwrapping
covers the twelve integer widths, andi128/u128are 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 thousandSET/GETround trips in a loop — and it was the only
such probe not gated behind thebenchfeature, so it built under the default
features and shipped in the published.cratetarball. The remaining profiling
probes (pprof_*,head_to_head,strace_workload, …) stay behindbench.