github dahomey-technologies/rustis 0.23.0

5 hours ago

BREAKING CHANGES

The upgrade checklist. Each item is stated fully, with the reason it moved, in the
section it belongs to below.

  • Blocking commands and WATCH are implemented for ExclusiveClient alone.
    Client::connect(cfg).await? becomes ExclusiveClient::connect(cfg).await?, or
    Client::connect(cfg).await?.into_exclusive()?, wherever a blocking command or
    watch/unwatch is called. Nothing else moves.

  • Error is a struct rather than an enum. Its variants live in ErrorKind, so
    a match on Error::Timeout becomes one on e.kind() -- or on e.into_kind()
    to match by value -- against ErrorKind::Timeout.

  • PubSubMessage is read through accessors. message.channel becomes
    message.channel() and String::from_utf8(message.payload) becomes
    std::str::from_utf8(message.payload()). The public fields and the Deserialize
    impl are gone.

  • rustis::Future<'_, T> is now rustis::client::CommandFuture<'_, T>. Only
    code naming the type is affected; awaiting a command is unchanged.

  • An empty collection reply decodes as Some of an empty collection. Code using
    Option<Vec<T>> over an LRANGE, SMEMBERS or ZRANGE as an emptiness test must
    switch to Vec<T> and .is_empty(). Only a nil is None now.

  • ts_get returns TsGetResult instead of Option<(u64, f64)>. It derefs to
    that option, so *sample keeps the existing patterns compiling; .into() converts
    it.

  • A textual reply read as a bool is an error outside OK/1/true/0/false
    where it used to be false, and a bulk string OK is now true.

  • A one-element array read as an integer from a resp::Value now requires that
    element to be an integer
    , as reading it off the wire already did.

Added

  • The transport is open: Unix sockets, and a caller-supplied stream.
    ServerConfig::UnixSocket { path } reaches a server listening on a Unix domain
    socket, spelled unix:///var/run/redis.sock in a URI — the socket path being the
    whole URI path, the database is a db query parameter there rather than the last
    path segment, and keep_alive / no_delay, which describe a TCP socket, are not
    applied. ServerConfig::Custom takes a TransportFactory, which hands the client
    any AsyncRead + AsyncWrite pair to speak RESP over: a tokio::io::duplex pipe
    driven by a server of your own, a tunnel, a TLS stack configured elsewhere. The
    trait is implemented for any closure returning a future, and is asked for a stream
    at every dial rather than handed one once, so a reconnection gets a fresh stream —
    the same reason CredentialsProvider is consulted at every handshake. Neither has
    a Debug or Display that reveals anything about the factory behind it.

  • Error classifies itself. is_connection_error(), is_timeout(),
    is_server_error() and is_retryable() answer the questions every caller asks
    of a failure: whose fault is it, and is it worth trying again. ErrorKind and
    ClientError are #[non_exhaustive], so this classification could not be
    written outside the crate — a downstream match must end in a catch-all arm and
    therefore silently misclassifies every variant added afterwards.
    is_connection_error() covers the RESP framing failures too, since a stream the
    parser lost track of costs the connection. is_retryable() reports a transient
    failure, not a command that certainly did not run: its documentation says so,
    because a timeout or a lost connection can follow a write the server applied.

Changed

  • The connection-holding commands live on their own client type. A Client is
    clonable, and every clone multiplexes over one connection; blocking commands and
    WATCH are incompatible with that — the first holds the connection until it
    returns, the second attaches state to the connection rather than to the handle
    that asked for it. Both were nonetheless implemented on every Client, so cloning
    is what turned a legal program illegal, and the failure was a stalled shared
    connection at run time. BlockingCommands and TransactionCommands are now
    implemented for ExclusiveClient alone — a client that is not Clone — so the
    mistake is a compile error. ExclusiveClient::connect opens a connection of its
    own, Client::into_exclusive converts an existing handle and returns
    ClientError::NotExclusive when another handle on the connection is alive (streams
    and transactions opened from the client hold one too), and
    ExclusiveClient::into_multiplexed goes back. It carries every other command
    family, and PooledClientManager::Connection is an ExclusiveClient: a borrowed
    connection is exclusive until it is given back, so the two families are legitimate
    there. Migration: Client::connect(cfg).await? becomes
    ExclusiveClient::connect(cfg).await?, or
    Client::connect(cfg).await?.into_exclusive()?, wherever a blocking command or
    watch/unwatch is called. Nothing else moves — MULTI/EXEC through
    Client::create_transaction, pub/sub, and the ~600 other commands are unchanged on
    a multiplexed client.

  • A pub/sub message is one block, read through accessors. PubSubMessage held
    three public Vec<u8> fields, allocating two per delivered message (three for a
    pmessage). Its segments now share one exactly-sized block read through
    pattern(), channel() and payload(), built from the push frame without serde;
    the fields and the Deserialize impl are gone. They stay owned rather than
    borrowed: the read buffer is a 64 KiB block the network task recycles, which a
    retained view would pin. ClientError::UnexpectedPubSubMessage replaces the serde
    error for a push that is not a message, smessage or pmessage. Migration:
    message.channel becomes message.channel();
    String::from_utf8(message.payload) becomes
    std::str::from_utf8(message.payload()).

    Worth less than it looks: benches/pub_sub_decode puts delivery at ~220–340 ns per
    message, dominated by the parse, so this buys 2–5 % up to 512-byte payloads and
    nothing measurable at 4 KiB. A 64-byte inline buffer removing every allocation
    was measured at 8–12 % slower and rejected.

  • An awaited command no longer allocates. client.get("key").await — the form
    every example, the README and all built-in command methods use — went through
    Box::pin(async move { … }), so the documented path cost one heap allocation plus
    one virtual call per command while the generic client.send(…) cost neither, in a
    crate whose first philosophy point is low allocations. IntoFuture for PreparedCommand<'_, &Client, R> now resolves to client::CommandFuture, a
    hand-written state machine that lives in the caller's frame; building the future
    and dropping it still sends nothing, and command_timeout still applies. Awaiting
    is unchanged, but naming the old type is not: an IntoFuture associated type
    spelled rustis::Future<'_, T> becomes rustis::client::CommandFuture<'_, T>.
    Construction measures ~69 ns against ~80 ns boxed, with client.send(…) unmoved
    at ~55 ns in the same rounds (benches/into_future.rs).

  • Errors name the command they belong to. Error is now a struct rather than an
    enum: its variants moved to ErrorKind, reachable through Error::kind() and
    Error::into_kind(), and it carries the command alongside them, reachable through
    Error::command(). A client multiplexes hundreds of commands over one connection,
    so Err(Error::Timeout) named nothing the application could act on, a shed command
    did not say what had been shed, and a cross-slot refusal did not say which command
    was refused. The command is attached wherever the client fails a command on its
    behalf — a command_timeout, a full send queue, a lost connection, a deferred
    serialization error, a mismatched-slot routing refusal — and is absent for the
    errors raised outside any command, a connection timeout in particular. Display
    appends it: The I/O operation's timeout expired (while executing BLMPOP). Calling
    code matching on Error::Timeout matches on e.kind() against
    ErrorKind::Timeout, or on e.into_kind() to match by value.

  • An empty collection no longer decodes as None. Option<T> treated an empty
    RESP array as a nil, so Option<Vec<T>> over an LRANGE, SMEMBERS or ZRANGE
    could never observe an empty vector: "the collection is empty" and "the key does
    not exist" collapsed into the same None. Only a nil — *-1 in RESP2, _ in
    RESP3 — is None now; an empty array, map or set yields Some of an empty
    collection. Blocking commands are unaffected: BLPOP, BRPOP, BLMPOP,
    BZMPOP, BZPOPMIN/BZPOPMAX and ZMPOP reply nil on timeout, not an empty
    array. Calling code that used Option<Vec<T>> as an emptiness test must switch
    to Vec<T> and .is_empty().

  • ts_get returns TsGetResult instead of Option<(u64, f64)>. The time
    series module reports an empty series as an empty array rather than a nil, so
    that command was the one relying on the conflation above. TsGetResult reads
    that shape itself and derefs to Option<(u64, f64)>, so assert_eq!(None, *sample) and if let Some((ts, value)) = *sample keep working; .into()
    converts it to the plain option.

Fixed

  • Dropping a PubSubStream releases its subscriptions. Drop named the
    channels it was cancelling as a bare &[u8], which serde renders as a sequence
    of integers rather than as one bulk string: the client asked the server to
    unsubscribe from 49 49 -- the ASCII codes of the channel 11 -- so it left the
    real channel subscribed, and the wrong command being legal in itself, the server
    answered it without complaining. The fire-and-forget failure was assigned to
    _result and never logged, so nothing surfaced either. close() was unaffected,
    passing the Bytes itself, which is why the documented equivalence between the
    two -- "drop will achieve the same process but silently in background" -- did
    not hold. Every later subscribe on that channel was then refused with
    AlreadySubscribed for the life of the connection, which a long-polling handler
    reaches on every cancelled HTTP request: its stream is dropped, never closed. The
    names now go through RefBulkString, the error is logged, and
    dropping_a_stream_releases_its_subscriptions covers the drop-then-resubscribe
    cycle the existing AlreadySubscribed test did not.

  • A cluster subscription is cancellable. SUBSCRIBE, PSUBSCRIBE,
    UNSUBSCRIBE and PUNSUBSCRIBE name no key, so the cluster connection served
    each of them on a node drawn at random: the unsubscription almost never reached
    the node holding the subscription, which kept publishing on the channel for the
    life of the connection — including through PubSubStream::close(). Each channel
    or pattern is now hashed like a key to pick its node, so a subscription and its
    cancellation always meet, and a command naming channels of different shards is
    split per node. A channel-less UNSUBSCRIBE still goes to a single node, since
    it names nothing to hash.

  • A subscription whose subscriber is gone is cleaned up. When a pub/sub message
    could not be handed to its subscriber because the receiving half had been dropped,
    the client logged a warning and kept the subscription: the server went on
    publishing to a channel nobody could receive on, one warning per message, for the
    life of the connection. That state needs no bug to reach — a command_timeout
    cutting subscribe() short after the server accepted it is enough, and so is
    leaking the stream. The subscription is now removed and an UNSUBSCRIBE
    (PUNSUBSCRIBE, SUNSUBSCRIBE) is sent, so the failed delivery is reported once
    and the server stops publishing. On a cluster the SUNSUBSCRIBE is routed by the
    shard channel's hash slot, so it reaches the node actually holding the
    subscription.

  • The bench feature compiles again. resp::bench_support still built Err
    from an ErrorKind rather than an Error, so --features bench failed with five
    errors. No CI job compiles that feature, which is why the Error restructuring
    missed it.

  • A textual reply read as a bool follows one rule. Asking for a bool
    directly — client.send(cmd).await? typed as bool — and asking for a Value
    and converting it afterwards — value.into::<bool>()? — gave different answers
    for the same reply: a simple string other than OK, and a bulk string other than
    0/false/1/true, were false the first way and a CannotParseBoolean
    error the second. The reply's encoding mattered too, +OK being true where
    $2\r\nOK\r\n was not, so a server switching between RESP2 and RESP3 could flip
    the result. One rule now covers the reply's text whichever way it is read and
    whichever encoding carries it: OK, 1 and true are true, 0 and false
    are false, anything else is CannotParseBoolean. Behaviour change: text
    outside that list used to be false when the bool was asked for directly and
    is now an error — the server never said false, and the error names the problem
    where the false hid it — and a bulk string OK is now true, as the simple
    string OK already was. Integers, doubles, RESP booleans and nil are unchanged.

  • Value equality is total on doubles. Value asserts Eq and is hashed as a
    Value::Map key, yet doubles were compared with ==, under which a NaN is not
    even equal to itself. ,nan is a legal RESP double — T-Digest and TimeSeries
    return it for an empty sketch or an empty bucket — so a nan key inserted in a
    map could never be looked up again, and two identical replies containing one
    compared unequal as Array, Set or Push. Doubles are now compared and hashed
    on a canonical bit pattern: all NaNs are equal to each other, and -0.0 equals
    0.0 as before. The only observable change is Value::Double(f64::NAN) now
    equalling itself.

  • The two deserializers agree on their coercions. A reply read as a Value and
    the same reply read straight off the wire went through two different Deserializer
    implementations, which disagreed: Value rejected an integer or a boolean that the
    wire path renders as text, so client.incr(k) typed as a String succeeded while
    Value::into::<String>() on the same reply failed; a one-element array unwrapped to
    an integer for every width on the wire but only for i64/u64 from a Value; and
    i128/u128 were unimplemented on the Value side. The Value deserializer now
    applies the wire path's rules: numbers and booleans are readable as text through
    both deserialize_str and deserialize_string, the one-element-array unwrapping
    covers the twelve integer widths, and i128/u128 are supported. That unwrapping
    now also requires the element to be an integer, as the wire path already did.

Removed

  • examples/loop.rs. It was not an example of anything — a CPU load for
    profiling, ten thousand SET/GET round trips in a loop — and it was the only
    such probe not gated behind the bench feature, so it built under the default
    features and shipped in the published .crate tarball. The remaining profiling
    probes (pprof_*, head_to_head, strace_workload, …) stay behind bench.

Don't miss a new rustis release

NewReleases is sending notifications on new releases.