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::redirectis 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__:invalidateand 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_streamandCacheconsume. 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 onClientTrackingOptions. -
The
async-std-runtimeandasync-std-native-tlsfeatures are removed.
async-std is no longer maintained; its authors point users tosmol. Tokio is
now the only supported runtime, and theasync-stdandasync-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-runtimebuild.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_settakes aJsonSetOptionsinstead of a bareSetCondition.
JSON.SETgained theFPHAargument in 8.8, so the last parameter had to
become extensible. Calls passingNoneare unaffected; a call passing a
condition becomes
json_set(k, p, v, JsonSetOptions::default().condition(SetCondition::NX)),
or keeps working as it is throughFrom<SetCondition> for JsonSetOptions. -
XPendingMessageResult::elapsed_millisis ani64, not au64. 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; useDefault::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::Valueand 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,TsAggregationTypeandTsDuplicatePolicyare 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 addsFtFieldType::Geoshape,TsAggregationType::CountNanand
CountAll. - Matching one of the affected enums now requires a
-
TsAggregationType's discriminants moved.CountNanandCountAllwere
inserted beforeFirst, so every variant fromFirstonwards 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. -
ClientReplyModenow implementsCopy. A non-moveclosure that used to
take it by value now captures it by reference. -
resp::Command::name()now returns&[u8]instead ofBytes. It borrows from
the command instead of bumping a reference count on every call. Callers that
need an owned value can useBytes::copy_from_slice(command.name()). -
resp::FastPathCommandBuilder::argand::keyare no longer public. They
panicked on any non-primitive argument; they are now private, fallible, and
every fast-path constructor falls back to the genericcmd(NAME)builder
rather than panicking. Build commands throughresp::cmdinstead. -
cache::Cache::zremrangebyscorewas removed.ZREMRANGEBYSCOREis a write
command and had no place on the cached read surface; call it on theClient. -
Error::Tlsnow wrapsArc<native_tls::Error>instead ofnative_tls::Error,
soErrorstaysClone(a TLS failure has to be reported to every in-flight
command). TheFrom<native_tls::Error>conversion is gone. -
Error::OneshotCancelednow wrapstokio::sync::oneshot::error::RecvError
instead offutures::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 toConfig
(buffers,limits,max_command_attempts,max_messages_per_wave),
SentinelConfig(max_discovery_rounds) andFtIndexAttribute(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_attributesand
FtHnswVectorFieldAttributes::num_attributeswere removed, along with the
unusedresp::SmallVecWithCounter. The twonum_attributeswere
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_writeis 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
pubwithout 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 withresp::EnumAccessand
resp::VariantAccess.RespDeserializer::newtakes aRespView, 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
ownEnumAccess/VariantAccesstraits underuse rustis::resp::*.
Deserialize a reply throughResponse/PreparedCommandas before.commands::deserialize_bzop_min_max_result, a#[serde(deserialize_with)]
helper that a glob re-export made public.cache::Cache::from_builder. Itsbuilderparameter is a
moka::future::CacheBuilderover 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,JsonGetOptionsandAclDryRunOptionsno
longer implementUnwindSafeandRefUnwindSafe.Commandnow carries the
deferred serialization error described below, andErroris not
RefUnwindSafe(it holds anArc<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 toValue::Map({})and an empty push toValue::Push([]), instead
of all three collapsing toValue::Null. RESP's empty-versus-nil distinction
is preserved: a nil reply (_/*-1) still decodes toValue::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
beingtorf, so anything else leaves the frame boundary unknown. -
Behavior change — a numeric reply deserialized into a
Stringnow gives
back the bytes the server sent rather than a re-rendering of the decoded value.
A reply of,12.50used to come back as"12.50"→f64→"12.5", and
,1e21as 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
u8used to yield44; it now fails.i64::MINis accepted (it was
previously rejected). This applies to both the RESP deserializer and the
Valuedeserializer, which stay consistent with each other. -
Repairing the commands listed under Fixed changed five signatures:
ClusterCommands::cluster_infotakes no argument (wasslot,count).ClusterCommands::cluster_getkeysinslotreturnsR: Response(was()).TimeSeriesCommands::ts_decrbyreturnsu64(was()).ClusterBumpEpochResult::BumpedandStillnow 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
andSentinelCommands::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_valueswas removed, replaced by
HashCommands::hscan_no_values, which returns(u64, R)and emitsNOVALUES
itself.SortOptions::storewas removed; usesort_and_store, which appendsSTORE
itself.BfInfoResult::expansion_rateis anOption<usize>, not ausize.TsInfoResult::key_self_nameis anOption<String>, not aString.FtSearchResultRow::scoreis anFtScore { value, explanation }, not an
f64.TsRangeOptions::bucket_timestampandTsMRangeOptions::bucket_timestamp
take the newTsBucketTimestamp(Low/High/Mid), not au64.TsRangeOptions::filter_by_tsandTsMRangeOptions::filter_by_tstake
impl IntoIterator<Item = u64>, not a single&str.FtSearchOptions::inkeyandFtSearchOptions::infieldslost an
unconstrained generic parameter, which had made them uncallable without a
turbofish naming an unused type — so no working caller can break.RestoreOptions::frequencytakes au8, not anf64. It emitted
FREQUENCY 10.0whereRESTOREtakesFREQ 10, so every call failed;u8
is the LFU counter's actual range.
-
FtSearchResultRow::valuesandextra_attributeshold an
FtAttributeValue, not aString.FT.AGGREGATE'sTOLISTand
RANDOM_SAMPLEreducers return an array of strings per group, which a
Vec<(String, String)>could not hold — both failed with
CannotParseString.FtAttributeValueisText(String) | Array(Vec<String>). Read it with
as_str()oras_array(), which returnOption.PartialEqagainststr,
&str,Stringand[String]is implemented in both directions, so
assert_eq!("40", value)still compiles; code binding the value as aString
needs.as_str()or amatch. -
JsonRefis 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 asJsonRef(&value)did —&Tis itselfSerialize— so the
migration is a rename, withJson(value)available when the value can be
moved. -
A value
serde_jsoncannot encode now fails the command instead of being
stored as an empty one. The wrapper'sSerializeswallowed the error and
sent a unit argument in the value's place, soclient.set(key, Json(&value))
returnedOk(())having written an absent value underkey. 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
namesOption<Json<T>>, which is what a possibly-missing key needs, instead
of serde'sinvalid type: Option. Only the text changes. -
JsonArrIndexOptions::startandstopareisize. They wereu32and
i32, so a negativestart— whichJSON.ARRINDEXreads 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 au32ori32variable
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-tlsbackends 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.-1remains
the nil form. All limits are configurable throughConfig::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_slicingadditionally denied in therespandnetworkmodules,
enforced in CI. A panic on the network task would take down every in-flight
command along with the reconnection loop.
Added
-
client::ClientTrackingInvalidationStreamis now exported.
Client::create_client_tracking_invalidation_streamreturns 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'sCOMMAND DOCSdiffed 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
ArrayCommandstrait: 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 witharseek). Gaps cost nothing, which
is whyarlen(highest index plus one) andarcount(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, withArGrep,ArGrepPredicate,ArOperation,
ArInfoOptions,ArLastItemsOptionsandArrayInfo.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 is0when a bound stopped it. With
ubound_intas the cap andenxto 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 outmin-idle-time.
Arguments added to existing commands:
ZAggregate::Countonzinter,zinterstore,zunionandzunionstoreFtFieldType::Geoshape(+FtGeoShapeCoordSystem) onft_createJsonSetOptions::fpha(+JsonFpType) onjson_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 ownCOMMAND DOCSagainst 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, withHotKeysMetric,HotKeysStartOptionsand
HotKeysInfo.HOTKEYS GETreplies with one entry per node; the fields
tied to a metric are absent — not empty — when that metric was not tracked,
so they areOption.- Stream idempotent production:
xcfgset(+XCfgSetOptions),
XAddOptions::idmpandXAddOptions::idmp_auto, and the six IDMP fields on
XStreamInfo. TsAggregationType::CountNanandCountAll.
Commands that pre-dated 8.6 and had been missed:
xsetid(+XSetIdOptionsforENTRIESADDED/MAXDELETEDID)cluster_myshardidmodule_loadex(+ModuleLoadexOptions) andmodule_unloadjson_mergebf_card,topk_countvismember,vrange
Arguments that existed on implemented commands but were not reachable:
XClaimOptions::last_id(LASTID)JsonGetOptions::formatwithJsonGetFormat::{String, Expand1, Expand}FtFieldSchema::index_missing/index_empty, and
FtCreateOptions::index_all(+FtIndexAll), which takes an explicit
ENABLE/DISABLEvalue rather than being a flag
-
Rustis now emits
tracingevents and spans
instead of plainlogrecords.If you use
log, nothing changes and you need to do nothing. Thelog
feature oftracingis enabled, so every event also emits alogrecord and
existingenv_logger-style setups keep receiving the same output.What you gain by installing a
tracingsubscriber instead: every event from
the network task is wrapped in aconnectionspan carrying atagfield, so
output from several clients stays attributable; reconnections open a nested
reconnectspan grouping the in-flight purge, the retries and the
subscription replay; and in cluster mode, events about a specific node carry a
nodefield. 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.tomlnow 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
andDELEX. Plus the options that were missing from various Redis 8.x
commands, andXADD/XTRIM's entries-deletion policy. -
StreamCommands::xdelexandStreamCommands::xackdel(Redis 8.2), which
delete — and forXACKDELacknowledge — 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 shapeSORTBY 2 field ASCand
PARAMS 4 n1 v1 n2 v2require. 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_stepand
resp::CommandBuilder::key_with_count_and_step, which prefix a collection with
the number ofstep-sized groups it holds rather than its raw argument count —
the shapesHSETEX key FIELDS numfields field value …andMSETEX numkeys key value …require. Thekey_form additionally marks everystep-th element as
a routing key for the cluster client. Both derive their count the same way
arg_counteddoes. -
The examples now declare
tokio-runtimein theirrequired-features, so a
feature set that does not build them no longer fails the whole target set. -
Confignow 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_attemptsandSentinelConfig::max_discovery_rounds.
Config::validate()runs at connection time and rejects a value that would
disable a behavior.RespLimits::DEFAULTandBufferConfig::DEFAULTgive the
same defaults in aconstcontext. -
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-fuzztargets over the RESP read path, and afuzz_apimodule exposing
the parser entry points they drive. -
resp::bench_support, the benchmark counterpart tofuzz_api: thin entry
points into the decode-and-deserialize path, isolated from the network, so an
externalbenches/*.rscrate can measure the parser on hand-built RESP
buffers. Gated behind thebenchfeature and compiled out of shipped builds.
Changed
-
Three
if letguards in theValuedeserializer 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:
BlockingCommandsand each of its methods now state prominently that a
blocking command monopolizes its connection, thatcommand_timeoutbounds
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_arrayheuristic in theValuedeserializer 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 usedOption::ok_or, which eagerly
constructs the (large)Errorenum on every call and drops it again on
success. Switching took_or_elsebuilds anErroronly 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'smpsc/oneshot,
Messageshrank 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 aBiLock. Measured against0.19.3on 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,ArgLayoutshrank to 12 bytes, and
the cluster client indexesMGETreordering 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. OnlyCLIENT SETNAMEandCLIENT SETINFOdeclared a routing
policy;AUTH,CLIENT TRACKING,CLIENT NO-EVICT,CLIENT NO-TOUCHand
RESETcarried 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 REPLYnow 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.ONandOFFare 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.SKIPsilences 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.SELECTandREADONLYare 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 restoredHELLO, the config credentials, the config
connection name and the config database, so aSELECT, anAUTH, a
CLIENT SETNAME, aCLIENT SETINFO, aCLIENT NO-EVICT/NO-TOUCH, a
CLIENT TRACKINGissued directly or aSCRIPT DEBUGissued 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 asCLIENT TRACKINGcome back exactly as they
were sent. A replay the server rejects is logged and dropped rather than
failing the connection, so one badAUTHcannot turn into a permanently dead
client.READONLYis 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 OFFno 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 SKIPsilences one command again, instead of the connection.
It was treated as a stickyOFFuntil an explicitON, soSKIPfollowed by
more than one command made the client expect fewer replies than the server
sends.SKIPbefore exactly one command — the only case the suite covered —
behaved correctly and hid this. -
RESETnow clears the client's picture of the connection too. It was
recognised for a single one of its effects (leavingMONITOR). 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_brokenreturnedfalseunconditionally, 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 onPooledClientManager, and callers
needing a clean slate should issueRESETthemselves. -
Eighty-six option builders that nothing called got a test, and seventeen of
them were broken. Counting*Options/*Schema/*Attributebuilder
methods rather than commands shows that a tested command says nothing about its
options:ft_searchhad 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::useremittedUSERNAMEinstead ofUSER, so any use
answeredERR syntax error.ClientKillOptions::addrandladdrsentADDR ip portas two arguments
where the server wants a singleip:port. Same error.ZAddOptions::changeemittedCHANGEinstead ofCH.FtSearchOptions::inkeyandinfieldssuppressed their own field name
through#[serde(rename = "")], so the count and values went out with no
INKEYS/INFIELDStoken.FtAggregateOptions::load_allemittedLOAD 1 *;LOAD *carries no count,
and the counted form loads a field named*, which is to say nothing.FtAggregateOptions::add_scoreswas serialized afterLOAD, and the server
acceptsADDSCORESonly before it.
Options no signature could express:
TsGroupByOptionshad noDefault, sots_mrangeandts_mrevrangeforced
aGROUPBYthe server makes optional — and grouping renames every returned
series. It now defaults to emitting nothing.FtSearchSummarizeOptionshad neitherDefaultnor a constructor, so
FtSearchOptions::summarizehad no argument that could be built.
Replies the declared type could not hold:
client_listreturned one phantomClientInfowithid: 0on every call:
the reply is newline-terminated and the trailing empty line was parsed as a
client.ts_info(key, false)always failed withmissing field keySelfName; the
existing test only ever passedtrue.bf_info_allalways failed on aNONSCALINGfilter — the filters the two
nonscaling()options exist to create.FT.SEARCH ... EXPLAINSCOREalways failed withCannotParseDouble, 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_byandfirst_value_by_ordernever emitted the
BYkeyword: they sentFIRST_VALUE 2 @name @ageand
FIRST_VALUE 3 @name @age DESC, both rejected with
Unknown argument @age at position 1 for FIRST_VALUE. Now
FIRST_VALUE 3 @name BY @ageandFIRST_VALUE 4 @name BY @age DESC.RestoreOptions::frequencyemittedFREQUENCY 10.0whereRESTOREtakes
FREQ 10, so every call answeredsyntax error. See the breaking-changes
section.
FT.HYBRID's per-clauseYIELD_SCORE_ASis confirmed working on Redis 8.8,
on both theSEARCHand theVSIMclause. -
ZAggregatewas never introduced by itsAGGREGATEtoken.zinter,
zinterstore,zunionandzunionstoreappended the bare value, so
zinter(keys, None, ZAggregate::Sum)sentZINTER 2 k1 k2 SUMand failed
withsyntax error. The whole enum was therefore unusable, and no test
passed one — the same blind spot as the two token bugs below. Found while
addingZAggregate::Count. -
XPendingMessageResult::elapsed_milliscould not hold the value the server
sends. It was au64, butXPENDINGanswers-1for an entry with no
delivery to measure from — the statexnackputs entries in — which failed
to deserialize. It is now ani64. See the breaking-changes section. -
Two option builders emitted a token the server rejects, so the options were
unusable. Both came from arename_allrule silently producing the wrong
spelling, and neither had a call site anywhere in the crate — which is why no
test caught them:ZRangeOptions::reverse()emittedREVERSEinstead ofREV, so any
zrangeorzrangestoreusing it failed withsyntax error.VSimOptions::with_attributes()emittedWITHATTRIBUTESinstead of
WITHATTRIBS, so anyvsimrequesting 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 asMOVEDis
still filed as a result, so redirections keep working. -
spublishwas 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'sMOVEDto find the shard — one useless round
trip per call, on the path that then hit the deadlock above.ssubscribeand
sunsubscribealready 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 isCommandBuilder::arg),
Client::send_batch(removed from the public API, still linked from three
places — usePipeline),FtAggregateOptions::reduce(it is onFtGroupBy),
FtSugGetOptions::withpayload(plural),TsMGetOptions::selected_labels(the
method isselected_label),Command::compute_slots(private), a bare
Serialize, and a doubled parenthesis swallowing a URL.cargo docnow runs
in CI with warnings denied, so these cannot come back unnoticed. -
zadd_incrnever incremented anything. It omitted theINCRkeyword, so
it sent a plainZADDand answered the number of elements added —Some(0.0)
for an existing member — instead of the member's new score. -
vlinks_with_scorenever asked for the scores. It emitted the same
VLINKScommand asvlinks, so it answered bare neighbour names and any
attempt to deserialize the scores failed. -
cluster_infocould not succeed. It sent two argumentsCLUSTER INFOdoes
not accept, andClusterInfowas derived as if the reply were a map when the
server answers a text blob offield:valuelines. The type now parses that
text, tolerating both the counters a server omits and the fields a newer one
adds. -
cluster_getkeysinslotandts_decrbythrew 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_bumpepochcould not succeed.CLUSTER BUMPEPOCHanswers a single
line holding both the outcome and the resulting epoch, as inSTILL 84, while
ClusterBumpEpochResultwas derived as a plain lowercase enum tag. Every call
failed withunknown 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_searchandsentinel_failover. -
ClusterInfo,ClusterState,ClusterBumpEpochResult,ClusterLinkInfoand
ClusterLinkDirectionnow deriveDebug, and the two enumsPartialEq, 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; anASKredirection 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 pastConfig::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; theMONITORparser 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_timeoutnow applies tosubscribeandmonitor. -
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/ASKaddresses are split at the last colon (IPv6), and
Sentinel discovery is bounded and resilient. -
Commands:
MSETEXandHPERSISTsend their mandatory count argument;
HSETEXno longer panics on an odd field/value list; theCLIENT 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'sLOADandFT.SEARCH'sRETURNannounced
the number of attributes where Redis counts arguments, so renaming an attribute
(FtAttribute::new("a").r#as("b")) producedLOAD 1 a AS band failed with
Unknown argument AS.PARAMSannounced the number of pairs instead of twice
that, so any query with more than one parameter failed.FT.SPELLCHECK's
TERMSwas 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'sBooleancompares by value rather than by discriminant. -
Closing the last
Clientclone closes the connection race-free. -
A collection element that fails to parse mid-iteration now surfaces an error
instead of silently truncating the iteration. -
Debugon 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.