Skip to content

0.20.0

Latest

Choose a tag to compare

@mcatanzariti mcatanzariti released this 29 Jul 00:54

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 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.