github dahomey-technologies/rustis 0.25.0

3 hours ago

BREAKING CHANGES

The upgrade checklist. Each item is stated in the section it belongs to below.

  • Six connection-driving commands leave the public traits: hello, asking,
    readonly, readwrite and cluster_slots become internal, and quit is deleted.
    The four types that served them become internal too: HelloOptions, HelloResult,
    LegacyClusterShardResult and LegacyClusterNodeResult.

  • Five deprecated string commands are removed: getset, psetex, setex,
    setnx and substr.

  • A nil reply read as a scalar is now an error, not 0 / "" / '\0'. Declare
    the response as an Option to accept the absence. The rule reaches inside a reply, so
    hmget reads as Vec<Option<String>>, and three public fields become Option<String>:
    TsInfoResult::source_key, FunctionInfo::description and
    XPendingResult::{smallest_id, greatest_id}. Collections, Value and bool are exempt.

  • Seven commands now route on a key they only named before. A cross-slot call to
    sdiffstore, sinterstore, zdiffstore, zinterstore, zunionstore,
    sort_and_store or lcs was refused by the server with CROSSSLOT, and is now
    refused locally with ClientError::MismatchedKeySlots.

  • The queue memory budget now covers what is in flight. A command was charged
    to BackpressureConfig::max_queued_bytes until it was written, and is now charged
    until its reply arrives. A configuration sized against the send queue alone may
    shed commands it used to accept.

  • Value::Map holds Vec<(Value, Value)> instead of HashMap<Value, Value>,
    and Value no longer implements Hash.
    Code that built a map with
    HashMap::from([…]) or read it with get/contains_key on the inner type must
    change; Value::get replaces the lookup.

  • Value::SimpleString equals the Value::BulkString carrying the same bytes.
    A comparison that relied on the two being distinct now answers true.

  • ErrorKind::Timeout carries a TimeoutKind. matches!(e.kind(), ErrorKind::Timeout) becomes ErrorKind::Timeout(_), and the two deadlines can
    now be told apart by name.

  • ClientError::Unexpected is removed, replaced by the seven variants that
    say which condition occurred: MalformedFrame, InconsistentRespTape,
    NotACollection, MissingTransactionReply, IncompatibleShardReplies,
    NotAUnitVariant and MissingMapValue.

  • ClientError::InvalidChannel is removed. No code path produced it: it stood for
    a client holding no send channel, which nothing can observe.

  • Pipeline::queue/forget and Transaction::queue/forget become
    queue_command/forget_command.
    They took a generic command; the batch trait
    methods of the same name take a prepared one. Two calls that read alike and are
    not the same call now differ by name.

  • CommandBuilder::key takes exactly one key, and a collection goes through the new
    CommandBuilder::keys. Built-in commands are unaffected — the 24 multi-key ones were
    moved — but a hand-built cmd("DEL").key(my_vec) now fails with
    ClientError::InvalidKeyArity. The counted forms are unchanged and may still declare
    zero keys, as EVAL does.

  • resp::Response is deleted. Replace it with serde::de::DeserializeOwned in a
    where clause. The trait was pub trait Response {} with a blanket impl for every
    Deserialize type, so the R: Response bound on 232 command signatures constrained
    nothing while IntoFuture re-required DeserializeOwned behind it. The bound now
    says what it always meant.

  • Client::close returns CloseOutcome instead of (). A connection is shared
    by every clone of a client, so a close that finds a clone alive shuts nothing down
    and used to report that as Ok(()). CloseOutcome::Closed and StillShared now
    tell the two apart. ExclusiveClient::close follows.

  • RedisError::description is a method, not a field, and the bytes are kept. A server
    error reply is bytes and can echo a key, which String::from_utf8_lossy used to mangle
    on the way in. description() answers a Cow<str> with the same lossy reading,
    description_bytes() the exact bytes. kind stays a public field.

  • ClientError::InvalidTag is removed. No code path could produce it.

cargo semver-checks against 0.24.0 reports 9 failing major checks, among them 11
removed trait methods, 4 removed structs, the resp::Response trait, the
ClientError::InvalidTag variant and the RedisError::description field.

Added

  • Client::send_raw hands a reply back as RESP bytes. A proxy or a protocol bridge
    had to read through Value, which drops what it cannot spell back: a server's
    rendering of a float, a verbatim string's tag, an error's exact wording. send_raw
    answers a resp::RawResponse — the frame as received, or RESP3 for a reply the client
    built itself. A Redis error is a reply here, not a failure; is_error tells them apart.

  • rustis::prelude holds every command trait. A command lives on a trait, so a program
    calling several families collected one use per family. The prelude re-exports all 28,
    the two batch traits, the four executors and the pub/sub types. Result stays out: a
    glob import of it shadows the standard prelude's and leaves Result<T, E> naming
    nothing. A test reads src/commands/mod.rs and fails on a family left out.

  • A pub/sub message reads as text or as a Rust type. PubSubMessage::channel_str()
    and pattern_str() answer a &str, failing with ErrorKind::Utf8 on a binary name
    rather than replacing what they cannot decode. payload_as::<T>() runs the payload
    through the same serde machinery as a bulk string reply, so a published number reads as
    a number and a document as Json<T>; T may borrow, so &str allocates nothing.

  • Every tuning knob is now addressable in a URL. buffers, backpressure and limits
    take one query parameter per field, named after it — buffers.read_capacity,
    limits.max_bulk_length. reconnection names the policy and reconnection.delay and
    its siblings shape it; a field the policy does not carry is rejected, not dropped.
    Display writes them all back, so a config round-trips through its URL.

  • A Sentinel failover is now noticed before a command fails. The client subscribes
    to +switch-master and rediscovers the master when a Sentinel announces one, and
    polls the fleet every SentinelConfig::master_check_interval (default 10 s) to cover
    the announcements published while that subscription is itself redialling. A rediscovery
    either path already made leaves the other with nothing to do.

  • ClientError::UnexpectedNil, raised when a nil reply is read as a type that
    cannot hold an absence. The message names the target type and points at Option.

  • A key argument that is not a single key fails the command, with
    ClientError::InvalidKeyArity naming the command and the argument count. Arguments are
    impl Serialize, so the compiler cannot count what a value produces: a None key used
    to reach the server a key short and, in Cluster mode, with no hash slot — which routes
    it to a random node. Any type serializing to one argument is still a valid key.

  • Client::stats returns a ClientStats snapshot: queued commands and bytes,
    the bytes high-water mark, shed commands and reconnections. The numbers existed
    as #[cfg(test)] hooks, so an operator was told to size
    BackpressureConfig::max_queued_bytes with no way to see whether it was hit.

  • Client::config, Client::is_connected and Client::server_version report
    what a client is connected to. A readiness probe needed a PING, and branching on
    the server version needed a second HELLO. server_version is None on a
    cluster, whose nodes have versions of their own. ExclusiveClient has all four.

  • Config is Serialize and Deserialize, so a service maps a TOML/YAML/JSON
    section onto it. buffers, backpressure, limits and reconnection had no URI
    spelling and were reachable from Rust only. Missing fields take their defaults;
    credentials_provider, tls_config and ServerConfig::Custom carry Rust code and
    are skipped.

  • TlsConfig::new (rustls) wraps a rustls::ClientConfig built elsewhere. The type
    is #[non_exhaustive] and had no constructor, so a private CA, a client certificate
    or a pinned issuer could not be supplied from outside the crate at all.

  • Seven examples: cluster, sentinel, tls, transaction, pipelining,
    scripting and client_side_caching. wakeup_cost_probe and cache_stampede_probe
    now need the bench feature, like the nine other profiling harnesses.

  • Config::interceptor takes a CommandInterceptor, called on every command the
    client sends and on every command that resolves, with its elapsed time and its
    error. Per-command metrics, a request identifier or an audit trail had no hook at
    all. It may rewrite the command before it goes out.

  • Cache::with_store takes a CacheStore, so the client-side cache can be backed by a
    store shared between clients, or one with an eviction policy of its own. Cache is
    generic over it and defaults to MokaStore, so Cache alone still means what it did.
    An entry is an opaque CachedValue: handing the bytes out would pin a recycled network
    buffer, so a store cannot persist an entry.

  • ReconnectionConfig::Custom takes a ReconnectionPolicy, so a delay can depend
    on more than the attempt number: a circuit breaker, an external health signal, a
    backoff coordinated across a pool. Implemented for any Fn(u32) -> Option<Duration>,
    so a closure is enough. The three built-in shapes are unchanged.

  • prepare_command is public, with the extension pattern on the crate's front
    page. A missing command is added in the crate's own idiom, client.myget("key").await,
    instead of client.send(cmd("MYGET")…). Every built-in command trait is written this
    way; only the helper was private.

  • Client::is_terminated reports a client whose network task has ended — a
    non-zero reconnection budget exhausted, or the last handle dropped. The state was
    invisible: the process stays alive and serving traffic it can never answer. A
    liveness probe reads this; the only recovery is a new client. ExclusiveClient
    has it too.

  • ClusterConfig::topology_refresh_interval reloads the cluster topology on a
    timer, 60 seconds by default (?topology_refresh_interval=, 0 to disable). A
    redirection was the only thing that corrected the local slot map, so a resharding
    touching no slot this client uses was never noticed, and a node added to the
    cluster was never connected to.

  • Value accessors: as_str, as_bytes, as_i64, as_f64, as_bool,
    as_array, as_map, as_error, is_null and get. The object model held one
    method, into, so reading a reply whose shape the caller does not model meant
    pattern matching or a detour through serde.

Changed

  • The send queue counts its commands incrementally. Deciding whether to emit one
    debug! line folded the whole queue on every send wave, in shipped builds, whether
    or not anything was listening. The total is now maintained alongside the byte total
    it sits next to.

  • The response type on a queued command is documented as ignored. R is
    discarded when a command enters a batch — the tuple on execute decides the
    decoding — and the crate's own examples wrote it two different ways. They now all
    write ::<()>.

  • The raw-bytes limitation is stated on the front page. client.set("key", b"val")
    compiles and fails at runtime; the explanation lived only on the resp module page.

  • Value::Map keeps the reply. Its entries are in the order the server sent
    them, and a field the server repeats appears twice. A HashMap lost both, made
    Display/Debug nondeterministic, and was the sole reason Value carried a
    hand-written Hash over f64 and nested maps.

  • Value compares payloads, not variants. SimpleString and BulkString carry
    the same thing and the deserializers read them identically, so which one a reply
    arrives in is a server-version detail. Comparing on the variant made caller code
    fail on a server upgrade.

  • A reply nobody awaits is logged at debug!, not warn!, and names its command.
    A caller that gives up on its reply — a command_timeout, a dropped future — is the
    documented contract, not a fault, and a service with deadlines flooded its logs
    exactly when Redis was slow. Giving up on reconnection moved the other way, to
    error!: that client will never answer again.

Removed

  • The connection-driving commands are internal. A Client is clonable, so one
    connection carries the commands of every clone: HELLO changed the protocol version the
    deserializers depend on, READONLY the read mode ClusterConfig::read_preference
    depends on, and ASKING is correct only immediately before the command it redirects.
    The client now sends them itself. CLUSTER SLOTS callers use cluster_shards.

  • quit is deleted. Redis deprecated it in 7.2.0. On a multiplexed client it
    closed the connection of every clone. Use Client::close.

  • ClientError::InvalidChannel is removed. It reported a client whose send channel
    was gone, a state a Client cannot be in: close takes the client by value, so the
    handle that gives the channel up is unreachable afterwards. A send that finds the
    network task gone reports ClientError::DisconnectedFromServer.

  • The deprecated string commands are removed. Use set_get_with_options for
    getset, set_with_options with SetExpiration::Ex or Px for setex and
    psetex, set_with_options with SetCondition::NX for setnx, and getrange for
    substr. COMMAND DOCS reports 21 deprecated commands; the crate implemented these
    five and quit. No module command reports a deprecation.

Fixed

  • A cluster reconnection rediscovers the topology from the nodes it holds, not only from
    the configured seeds.
    reconnect dialled ClusterConfig::nodes alone, so a cluster
    whose seeds are one control-plane endpoint stayed down for as long as that endpoint did,
    every attempt repeating the same too-small dial while nodes that had answered sat untried.
    It now dials the held nodes first, as the two other discovery paths already did.

  • A query parameter written on the wrong scheme now names the URI it belongs to.
    sentinel_username, sentinel_password and wait_between_failures are read only by
    a sentinel URI, read_preference and topology_refresh_interval only by a cluster one,
    and db only by a unix socket one. On any other scheme they were reported as unknown,
    sending the caller after a typo that is not there. The error now names the owning URI.

  • A cached RESP3 double read as a string now spells the value the way the server did.
    The client-side cache decodes a double when it compacts an entry, and a read as String
    rebuilt the text from that f64: a score of 1e+20 came back as
    100000000000000000000 on a hit and 1e+20 on a miss, nan as NaN. A compacted
    double now keeps the reply's own bytes, and the read borrows them (56 ns saved).

  • A rendered server error no longer carries a stray space. RedisError's
    Display wrote "{kind} {description}" unconditionally, so an error whose kind rustis
    does not recognise came out with a leading space, and a redirection, whose detail is all
    in the kind, with a trailing one. The separator is now written only between two non-empty
    halves.

  • A cluster SUBSCRIBE no longer fails when its channels span several nodes. The
    command is split per node, but the confirmations were matched by rank against the
    order the caller named the channels — and the nodes answer in their own order, so a
    legitimate call failed with ClientError::UnexpectedSubscriptionConfirmation,
    non-deterministically. A confirmation is now matched by name.

  • A channel-less UNSUBSCRIBE now reaches every node of a cluster. It names nothing
    to hash, so unlike its argument-carrying form it was served by a single node: it
    cancelled that node's share of the connection's subscriptions and silently left the
    rest. It is now sent to every master, and the caller waits for all the confirmations
    rather than for the first.

  • Seven commands did not route on a key they name. Six store commands added their
    destination as a plain argument, and lcs did the same with its second key, so the
    key took no part in slot computation: the command routed on its remaining keys
    alone, and the local cross-slot check could not see the unmarked one.

  • Four replies reported an absence as a value. TS.INFO on a series that is not a
    compaction target answered "" for its source key, FUNCTION LIST did the same for
    a function with no description, FT.CONFIG GET for an option carrying no value, and
    XPENDING on an empty group for its smallest and greatest ids. All four now answer
    None.

  • A client-side cache key that serialized to several arguments filed the entry
    under the first of them.
    Cache::get on a struct key kept one entry for every
    key sharing a first field, each read returning another key's value. Only a key
    serializing to no argument was refused; both counts now are.

  • ClientStats::queued_commands no longer over-reports after a reconnection. The
    replay rebuilt the byte total before re-queuing the messages it kept, and left the
    command total standing, so each replayed command was counted twice and the excess
    stayed for the life of the connection. Both totals now belong to one type that
    zeroes them with the queues it empties.

  • connect_timeout bounds the handshake, not only the dial. A server that accepted the
    socket and never answered HELLO left Client::connect waiting forever: the dial
    succeeded in microseconds, so the only deadline in the path had already been met. The
    budget now covers both, raising ErrorKind::Timeout(TimeoutKind::Connect).

  • An internal failure names the condition it hit. ClientError::Unexpected reported a
    dozen distinguishable conditions as Unexpected error. Worse, the frame parser raised it
    and the framing list did not carry it, so a failure leaving the reader at an unknown
    offset was dispatched to a single caller with the stream possibly desynchronised. The
    parser's two sites are now MalformedFrame, which the framing list does carry.

  • Enabling both TLS backends reports one error. rustls and native-tls each
    define a TlsConfig and an Error::Tls, with different fields, so the union defined
    both names twice and produced 61 errors and no usable message. Feature unification
    reaches this configuration without anyone asking for it. A guard now names the pair,
    and each of the five rejected feature configurations reports exactly one cause.

  • cargo bench no longer costs three minutes to measure nothing. Every benchmark
    is a criterion target with harness = false, but the lib test target was still built
    under [profile.bench] on every invocation, to report 0 measured. bench = false
    in a [lib] section removes the build: 3m02s becomes 0.13s. The README now names the
    bench feature the targets require.

  • Reconnection jitter no longer vanishes when the backoff saturates. The delay was
    clamped to max_delay after the jitter was added, so every client of a fleet woke
    at exactly max_delay — re-synchronising the herd precisely when the outage is
    longest. The clamp now applies to the delay and the jitter is added to the result, so
    the effective ceiling is max_delay + jitter.

  • The pool health check no longer parks on a silent server. is_valid pinged with
    no deadline of its own, and command_timeout defaults to none, so a server that
    accepts the socket and never answers held the check — and every caller waiting for a
    connection — for good. The ping is bounded by command_timeout, or by
    connect_timeout when that is unset.

  • A Sentinel connection learns the fleet from the fleet. The instance list was
    frozen at whatever the configuration named, against the client spec, so replacing
    every named Sentinel left the client with nothing reachable. A confirmed master is
    now followed by SENTINEL SENTINELS, which adds the unknown instances and moves the
    one that answered to the front.

  • The memory budget bounds the replies still awaited. The charge was released the
    moment a command was written, but writing it frees nothing — the memory is held
    until the reply arrives. A connection that accepted every byte and answered none
    therefore grew messages_to_receive without limit, which is the hole in the
    documented "bound memory with BackpressureConfig" story.

  • Neither direction of the network loop drains without bound. The two share one
    task, so a caller flooding the channel delayed every reply and a firehose of
    replies delayed every send; one send wave was measured taking 2001 messages. Both
    waves now hand control back after max_messages_per_wave. Measured on
    rustis_long_pipeline: no change outside the run-to-run drift.

Documentation

  • CloseOutcome::StillShared says what it does not promise. It read as "a clone still
    holds the connection, which stays up", which does not hold for handles given up at the
    same time: the shutdown goes to whichever goes last, so a call reading StillShared may
    be racing the one that closes. Client::close now states the rule for any mix of close
    and Drop.

  • A shedding budget states the memory it does not bound. A single message larger
    than max_pubsub_bytes or max_push_bytes is delivered rather than made
    undeliverable, so the memory actually held is the budget plus one message, itself
    bounded by RespLimits::max_bulk_length — 512 MiB by default. Both fields now say
    so, since sizing a container is what the knobs are for.

  • ReconnectionConfig states that a cluster reconnection is all-or-nothing. One
    attempt must reach a seed that answers and connect every master in the topology it
    returns; a single unreachable master sends the client back to the delay rather than
    retrying that node. A cluster client therefore spends more attempts than a standalone
    one on the same partial outage, which is a reason to leave max_attempts at 0.

  • The README says which profile the benchmark numbers hold under. [profile.bench]
    sets lto = "fat" and codegen-units = 1, neither of which a downstream --release
    build gets, and the in-tree comparisons against fred and redis-rs are measured
    under it.

  • CONTRIBUTING.md names the fuzzing feature and how to run the targets. It was
    discoverable only from a Cargo.toml comment.

  • The resp page says why command arguments are impl Serialize and not a trait of the
    crate's own.
    The orphan rule allows an impl only in the crate defining the trait or
    the type, so nobody could implement a rustis marker trait for uuid::Uuid — which
    already implements Serialize. Such a trait could not be honest either: it answers for a
    type, while the argument count is a property of the value.

  • select and auth warn that the connection is shared. Every clone of a
    Client shares one connection, so these commands apply to all clones. A new
    Connection-scoped commands section in the client module lists the nine commands
    that configure the connection, and points to Config::database and the credentials
    fields.

  • retry_on_error says why it defaults to false. Replaying a command the server
    may already have applied makes delivery at-least-once, so it stays opt-in. The
    default does not make max_command_attempts inert: that budget bounds cluster
    ASK/MOVED redirections whatever the flag says. set_jitter gained the rule for
    sizing jitter against the delay it spreads.

Internal

  • The shutdown race is tested on close too, and with more than two handles. Which
    handle ends the connection is decided by Arc::into_inner, an invariant a comment argued
    and one test covered for two concurrent drops. Eight handles now close at once over a
    thousand rounds, and a second test mixes drops with closes. The first fails on both
    designs this replaced: a reference-count check, and Arc::try_unwrap.

  • The state a client's clones share holds no sentinel. The field was
    Arc<Option<ClientShared>>, the Option there only so close could swap its reference
    out before Arc::into_inner. A Client has no Drop, so close takes the Arc out of
    the client it already owns. Both readers lose a None branch, and close loses the
    allocation of the sentinel it swapped in.

  • A batch hands its replies back unnamed. Every batch paired a Bytes name onto every
    reply, which the pipeline unzipped apart again and dropped: a name is read only when a
    reply fails, and only when exactly one command is awaited. The pipeline now takes that
    name from the awaited-command flags, and the transaction takes the list itself. Worth
    ~57 µs of caller CPU per thousand commands — three vectors and 200 KiB of moves.

  • A held CLIENT REPLY SKIP is borrowed while it is routed, not cloned. The five
    cluster routing paths read it through .cloned(), because the reply mode and the node
    topology looked like one borrow of the connection; they are separate fields, so the read
    is a disjoint borrow. The clone measured 27 ns on the shared network task, once per
    command the caller silences.

  • Reading a cluster tip off a Command no longer calls Clone::clone.
    request_policy() and response_policy() returned their fieldless enums through
    .clone(); both accessors now copy. Not a measurable win — a release build already
    compiled the clone away — but a fieldless tip that reads as if it allocates costs a
    reader more than it costs the machine.

  • The bench-gated RESP entry points are behind a named module. They were glob
    re-exported into resp, standing beside the real API with nothing marking them apart.
    They are now resp::bench_support, whose page states that it is a development instrument
    with no stability guarantee. docs.rs omits the bench feature and semver-checks runs
    on the explicit ones, so the module is documented and checked nowhere.

  • A command routed to a single shard no longer builds a key list. It built two, one on
    the sub-request and one on the request, and nothing read either: a key list is read only
    to line one node's replies up against another's. The removal is not measurable on
    cluster_routing (a 100-key single-slot mget unchanged at ~118 µs, p = 0.29); it ships
    because the work was dead.

  • TLS and cluster routing have benchmarks. The 16 existing targets covered neither, so
    two headline features had no figure to weigh a change against. tls_round_trip measures
    the handshake and the per-command record layer against a plain connection;
    cluster_routing measures a routed command, and a cross-slot mget at 2, 10 and 100
    keys, against a plain connection to the same node.

  • The cluster retry reasons are no longer a public type. RetryReason named the ASK,
    MOVED and TRYAGAIN redirections in the crate root, and ErrorKind::Retry carried a
    SmallVec of them. Both were #[doc(hidden)], so neither was in the documented
    contract, yet a caller could read a redirect target out of an error no command is
    answered with. It is now crate-internal, behind an opaque RetryReasons.

  • The benchmark and web-example crates are dev dependencies. criterion,
    fred, redis, axum, actix-web and pprof were optional dependencies so a
    Cargo feature could gate them, which made two competing drivers read as
    dependencies of this crate on crates.io. bench and web-examples carry no
    dependency now; required-features still keeps their targets out of a build.

  • The two connection modules are split into nine. network_handler and
    cluster_connection had reached 1987 and 2477 lines, each holding the router, the
    reply mode, the retry rule, the subscription table, the topology and the in-flight
    queue in one impl over shared fields. Those move out, leaving 1460 and 1387 lines,
    and each new type owns the invariant it used to share.

  • The command families are declared once instead of four times. Client,
    ExclusiveClient, Pipeline and Transaction each carried a hand-written block of
    empty impls, and nothing checked the four against each other. A family added to
    the client and forgotten in a batch executor compiled, then failed at the call site.
    The 22 data families now live in one list. The implemented sets are unchanged.

  • A tests/ directory compiles the crate as a downstream consumer. The whole
    suite lived in src/tests/, where pub(crate) is in scope, so nothing exercised the
    published surface by path. tests/public_api.rs queues a command from each family
    into a pipeline and a transaction, which fails when a batch impl list falls behind.

  • The test suite selects the half that needs no server. 493 tests reach neither a
    Redis nor the network — 491 of the 1185 in the library, plus tests/public_api.rs
    and nothing named them. The server-tests feature, on by default, carries the
    server-bound half, so ./run_tests.sh --hermetic runs the rest in about a second
    with no Docker. The 19 modules that held both kinds are split.

  • CI builds the targets and feature sets it skipped. No job built the 18 benchmark
    targets, the 10 bench-gated examples or the 4 web-examples ones: --all-targets
    covers only what the named features enable. The feature matrix gains fuzzing, and
    compiles the test tree with warnings denied — every job that built the suite named
    tokio-rustls. publish.yml checks the docs.rs set and the native-tls backend.

Don't miss a new rustis release

NewReleases is sending notifications on new releases.