fred

repository·main·Indexed 19 days ago

https://github.com/aembke/fred.rs

An async client for Redis and Valkey (version 10.1.0). The project includes a suite of performance and stability tools: a benchmark tool to measure throughput using Tokio tasks and connection pools, benchmark_metrics for automating performance tuning sweeps, inf_loop for testing connection stability and cluster failover, and replica_consistency for verifying replica consistency and measuring replication lag.

Tokens
63.4K
Snippets
202
Records
325
Agent score
68%

What's inside fred

  1. Overview of Fred Benchmark

    main

    Fred Benchmark is a tool designed to measure the throughput of a Redis client/connection pool using Tokio and Fred. It aims to reproduce the behavior of the official Redis redis-benchmark tool.

    Core Strategy:

    • Uses an atomic global counter to track progress.
    • Spawns -c Tokio tasks that share -P clients.
    • Sends -n total INCR commands to the server as quickly as possible.
    • Each task uses a different random key to ensure uniform distribution across clusters or replica sets.
    • This model simulates real-world web server use cases (like Axum or Actix) where multiple tasks share a common client pool.
  2. Understand Fred's design goals

    main

    Fred is designed with the following core principles to ensure reliability and performance in high-throughput environments:

    1. Robust failure mode support: The client should handle networking issues (connection, server, or cluster failures) without losing data or failing permanently. It should automatically recover once the server is back online.
    2. Performance: Optimized for high-throughput and memory-intensive use cases.
    3. Ergonomics: Focused on being easy to use and read.
    4. Safety and error handling: Built with no unsafe Rust and a guarantee to never panic.
    5. Feature parity: Aiming to support the latest Valkey or Redis features.
  3. How command routing works in Clustered mode

    main

    In a clustered environment, the Router uses a Connections enum to manage multiple nodes. When using Connections::Clustered:

    1. The Router identifies the target server by calling cache.get_server(command.first_key()) using the first key of the command to determine its hash slot.
    2. It looks up the corresponding Connection in a HashMap<Server, Connection>.
    3. If the server is found and a connection exists, the command is written to that specific connection.

    If the routing fails (e.g., the hash slot cannot be mapped or the connection to the specific server is missing), the client returns an error with ErrorKind::Routing.

  4. Use benchmark_metrics to tune performance

    main

    The benchmark_metrics tool is used for performance tuning by repeatedly running the core benchmark tool with varying combinations of Tokio task concurrency and Redis connection pool size. It generates a CSV file mapping these input combinations to their respective throughput values.

    Key tuning variables:

    • Concurrency (--concurrency / -c): The number of concurrent Tokio tasks used to run commands.
    • Pool Size (--pool / -P): The number of clients in the Redis connection pool.

    To run a sweep across ranges of these values, use the --concurrency-step and --pool-step flags to define the increment for each test run.

    ./run.sh -h redis-cluster-1 -p 30001 --cluster -n 1000000 -P 1-16 --pool-step 2 -c 50-10000 --concurrency-step 50 pipeline
  5. How pipelining is implemented via connection buffering

    main

    To support pipelining, each Connection maintains an internal buffer: VecDeque<Command>. This buffer tracks in-flight requests that are waiting for a response from the server.

    The Workflow:

    1. Writing: When a command is written, it is encoded into a frame, pushed to the back of the buffer, and sent to the transport.
    2. Reading: When a frame is received from the server, the client pops the command from the front of the buffer.

    Because Redis/Valkey guarantees that responses are returned in the same order as requests, the VecDeque ensures that the next received frame is correctly associated with the oldest pending command.

    impl Connection {
      pub async fn write(&mut self, command: Command) -> Result<(), Error> {
        let frame = encode_frame(&command)?;
        self.buffer.push_back(command);
        self.transport.send(frame).await
      }
    
      pub async fn read(&mut self) -> Result<Option<(Resp3Frame, Command)>, Error> {
        let frame = self.transport.next().await?;
        Ok(self.buffer.pop_front().map(|cmd| (frame, cmd)))
      }
    }
  6. Supported connection types in Fred

    main

    Fred hides connection implementation details behind a private ConnectionKind enum to provide a clean, unified Client API. The library supports the following transport layers:

    • TCP: Standard TCP streams.
    • TCP + TLS: Secure connections via rustls (enabled via enable-rustls or enable-rustls-ring features) or native-tls (enabled via enable-native-tls feature).
    • Unix Sockets: Local socket communication (enabled via unix-sockets feature).

    Because these details are encapsulated, you do not need to manage the specific stream types in your high-level client code.

  7. How the Fred routing task and connection management work

    main

    Fred uses a decoupled architecture to separate request-response logic from connection management. This is achieved through a routing task (the connection manager) and a Client (the request interface).

    The Routing Task

    When you call client.connect(), it spawns a Tokio task that manages all connections to the servers. This task is responsible for:

    • Managing private state (connections, retry buffers, replicas) via a Router struct.
    • Listening for commands on a channel (command_rx).
    • Handling connection-related events (errors, closures, reconnections) independently of individual requests.

    The Client Interface

    The Client is a thin, clonable wrapper around ClientInner. It provides the public API (e.g., get, set) by sending Command objects to the routing task via an unbounded channel (command_tx).

    Request-Response Lifecycle

    1. The caller invokes a method (e.g., client.get(key)).
    2. A Command is created containing the command type, arguments, and a oneshot channel sender (tx).
    3. The Command is sent to the routing task.
    4. The routing task processes the command and sends the response back to the caller via the oneshot receiver (rx).

    This model allows the client to handle connection failures or reconnections without requiring the user to manually manage connection state for every request.

    // High-level pattern for request-response functions
    impl Client {
        pub async fn get<K: Into<Key>>(&self, key: K) -> Result<Value, Error> {
            let (tx, rx) = oneshot_channel();
            let command = Command {
                kind: CommandKind::Get,
                args: vec![key.into().into()],
                tx
            };
    
            self.inner.command_tx.load().send(command);
            rx.await.and_then(|f| f.into())
        }
    }
  8. Configure Fred for advanced Redis features

    main

    Fred supports several specialized Redis interfaces and deployment patterns. Key patterns include:

    • Connection Management: Using Sentinel for high availability, Pool (round-robin), or Dynamic Pool for scaling.
    • Data Patterns: Using Publish-Subscribe for resilient messaging, Streams (XADD/XREAD) for task communication, and Transactions (MULTI/EXEC).
    • Scripting & Commands: Using the Lua scripting interface or sending Custom commands/RESP frames.
    • Observability: Using the Monitor interface to process a MONITOR stream or EventsInterface to respond to connection events.
    • Data Formats: Using Serde JSON for type conversion or Redis JSON (via i-redis-json feature) for native RedisJSON support.
  9. Tuning Benchmark Results

    main

    When running benchmarks, several factors can significantly impact throughput. Consider these when selecting your CLI arguments:

    • Tracing: Enabling tracing can reduce throughput by approximately 20%.
    • Clustering: The deployment type (standalone vs cluster) affects performance.
    • Backpressure: Settings for backpressure can influence results.
    • Network Latency: The physical or virtual distance to the server.
    • Log Levels: High log verbosity can cause contention on pipes, file handles, or sockets.
    • Connection Pool Size: The -P value should be tuned to find the optimal number of clients.
    • Assertion Overhead: The assert-expected feature flag adds an assert! call after each INCR command to verify correctness, which adds overhead.
  10. How the write path and pipelining work

    main

    The write path in Fred is designed to allow automatic pipelining across different tasks. The routing task processes commands from its receiver and writes them to the appropriate server sockets without waiting for a response before moving to the next command.

    Write Process

    1. Receive: The router receives a RouterCommand from the command_rx channel.
    2. Route: It identifies the target Server (applying cluster hashing if necessary).
    3. Encode: The command is encoded as a Resp3Frame using the redis-protocol crate.
    4. Buffer & Write: The frame is stored in a socket-associated buffer and written to the TcpStream.
    5. Error Handling: If a write fails, the command is placed in the Router's retry_buffer.

    RouterCommand Types

    The routing task handles more than just standard Redis commands. It uses a RouterCommand enum to manage complex operations:

    • Command(Command): A standard Valkey/Redis command.
    • Reconnect(Server): Triggers a reconnection attempt for a specific server.
    • Transaction(Vec<Command>): A group of commands to be executed as a transaction.
    • Pipeline(Vec<Command>): A group of commands to be pipelined.

    Reconnection Logic

    When a Reconnect command is received, the router enters a loop using the provided ReconnectPolicy:

    • It attempts to reconnect using reconnect_once.
    • If it fails, it sleeps for a duration determined by reconnect_policy.next_delay().
    • Once a connection is successfully re-established, the router calls retry_failed_commands() to drain the retry_buffer and retry commands that were in-flight when the connection was lost.

    Note: While the router is in a sleep loop during a reconnection attempt, no other normal commands will be written to any other sockets.

    // Simplified RouterCommand structure
    enum RouterCommand {
      Command(Command),
      Reconnect(Server),
      Transaction(Vec<Command>),
      Pipeline(Vec<Command>),
      // ...
    }
  11. How Redis/Valkey clustering works

    main

    Redis and Valkey clustering distributes the keyspace across multiple nodes.

    • Nodes: Can be primary (receive writes) or replica (read-only). A cluster typically maintains one connection to each primary node.
    • Hash Slots: The keyspace is divided into 16,384 hash slots (0-16383). A hashing model maps keys to these slots, and each primary node owns a specific subset of slots.
    • Redirections: If a command is sent to the wrong node, the cluster responds with MOVED or ASK errors to redirect the client.
    • Discovery: Clients use commands like CLUSTER SLOTS or CLUSTER SHARDS to discover the cluster topology (hostnames, ports, TLS settings, and slot ownership) and build an in-memory routing map. This map is refreshed periodically or upon receiving redirection errors.
  12. How Fred handles concurrent reads and writes

    main

    The Fred client manages multiple Redis connections within a single Tokio task. To handle concurrent reads from multiple sockets and writes from the user without losing data, the client uses a specialized architecture:

    1. ReadAllFuture: Instead of using a standard Stream interface (which is not cancel-safe when combined with TcpStream in a select loop), Fred implements a custom ReadAllFuture. This future polls all active connections and returns a collection of responses (Responses) containing the server identifier and the received frame.
    2. read_or_write Loop: The core execution loop uses tokio::select! with a biased configuration. It prioritizes reading inbound responses from servers over processing new outgoing commands.
    3. Response Processing: When a frame is received, process_response matches the frame to the expected command in the connection's buffer and sends the result back to the caller via a channel.
    4. Error Handling: If a connection closes due to an IO error, the client automatically disconnects the server, moves in-flight commands to a retry_buffer, and triggers a reconnection attempt via RouterCommand::Reconnect.