Skip to main content

rustlavel_cache/redis/
connection.rs

1//! One Redis connection: a TCP socket, a read buffer, and the handshake.
2//!
3//! Nothing here is generic over a transport — Redis is a request/response
4//! protocol over a stream, so the whole client is "write a command, read one
5//! reply", plus the discipline of never reusing a connection whose framing may
6//! have drifted.
7
8use super::config::RedisConfig;
9use super::resp::{self, Value};
10use rustlavel_core::{Error, Result};
11use tokio::io::{AsyncReadExt, AsyncWriteExt};
12use tokio::net::TcpStream;
13
14/// How much to read from the socket per syscall.
15const READ_CHUNK: usize = 8 * 1024;
16
17pub struct Connection {
18    stream: TcpStream,
19    /// Bytes read from the socket but not yet consumed as a reply. Redis is
20    /// allowed to answer in as many TCP segments as it likes, and a reply for
21    /// the *next* command can arrive in the same segment as this one.
22    buffer: Vec<u8>,
23    /// Where in `buffer` the unconsumed bytes start, so a reply that arrived
24    /// alongside the previous one does not force a memmove per command.
25    consumed: usize,
26    config: RedisConfig,
27    /// Set the moment framing might be wrong. A connection that timed out
28    /// mid-reply must never go back into the pool: the next borrower would read
29    /// somebody else's answer.
30    broken: bool,
31}
32
33impl Connection {
34    /// Open a connection and complete AUTH and SELECT.
35    pub async fn connect(config: &RedisConfig) -> Result<Connection> {
36        let address = config.address();
37
38        let stream = tokio::time::timeout(config.connect_timeout, TcpStream::connect(&address))
39            .await
40            .map_err(|_| {
41                Error::msg(format!("timed out connecting to {address}. Is Redis running and reachable?"))
42            })?
43            .map_err(|e| Error::msg(format!("cannot connect to {}: {e}", config.redacted_url())))?;
44
45        // Cache reads are small and latency-sensitive; Nagle would batch them
46        // into an extra round trip's worth of delay.
47        let _ = stream.set_nodelay(true);
48
49        let mut connection = Connection {
50            stream,
51            buffer: Vec::with_capacity(READ_CHUNK),
52            consumed: 0,
53            config: config.clone(),
54            broken: false,
55        };
56
57        connection.handshake().await?;
58        Ok(connection)
59    }
60
61    pub fn is_broken(&self) -> bool {
62        self.broken
63    }
64
65    async fn handshake(&mut self) -> Result<()> {
66        // Copied out first: `command` needs `&mut self`, so the arguments
67        // cannot borrow from `self.config`.
68        let username = self.config.username.clone();
69        let password = self.config.password.clone();
70
71        if !password.is_empty() {
72            // Redis 6 takes `AUTH user password`; earlier servers only know
73            // `AUTH password`, which is still what an empty username means.
74            let reply = if username.is_empty() {
75                self.command(&[b"AUTH", password.as_bytes()]).await?
76            } else {
77                self.command(&[b"AUTH", username.as_bytes(), password.as_bytes()]).await?
78            };
79
80            if let Value::Error(message) = reply {
81                // Deliberately does not echo the password back in the error.
82                return Err(Error::msg(format!(
83                    "Redis rejected authentication for {}: {message}",
84                    self.config.redacted_url()
85                )));
86            }
87        }
88
89        if self.config.database != 0 {
90            let db = self.config.database.to_string();
91            self.command(&[b"SELECT", db.as_bytes()]).await?.into_result().map_err(|e| {
92                Error::msg(format!("cannot SELECT database {}: {e}", self.config.database))
93            })?;
94        }
95
96        Ok(())
97    }
98
99    /// Send a command and read exactly one reply.
100    ///
101    /// An error *reply* comes back as [`Value::Error`]; only a transport
102    /// failure is an `Err`, because a `WRONGTYPE` says nothing about whether
103    /// the socket is still usable.
104    pub async fn command(&mut self, args: &[&[u8]]) -> Result<Value> {
105        let request = resp::encode_command(args);
106
107        let write = self.stream.write_all(&request);
108        match tokio::time::timeout(self.config.command_timeout, write).await {
109            Ok(Ok(())) => {}
110            Ok(Err(e)) => {
111                self.broken = true;
112                return Err(Error::msg(format!("cannot write to Redis: {e}")));
113            }
114            Err(_) => {
115                self.broken = true;
116                return Err(Error::msg("timed out sending a command to Redis"));
117            }
118        }
119
120        self.read_reply().await
121    }
122
123    async fn read_reply(&mut self) -> Result<Value> {
124        loop {
125            // Try what is already buffered first: a pipelined reply may have
126            // arrived with the previous one, costing no syscall at all.
127            match resp::decode(&self.buffer[self.consumed..]) {
128                Ok(Some((value, used))) => {
129                    self.consumed += used;
130                    if self.consumed == self.buffer.len() {
131                        self.buffer.clear();
132                        self.consumed = 0;
133                    }
134                    return Ok(value);
135                }
136                Ok(None) => {}
137                Err(e) => {
138                    // The stream no longer parses; the connection is finished.
139                    self.broken = true;
140                    return Err(e);
141                }
142            }
143
144            // Reclaim the front of the buffer before growing it, so a long-lived
145            // connection does not accumulate consumed bytes.
146            if self.consumed > 0 {
147                self.buffer.drain(..self.consumed);
148                self.consumed = 0;
149            }
150
151            let start = self.buffer.len();
152            self.buffer.resize(start + READ_CHUNK, 0);
153
154            let read = self.stream.read(&mut self.buffer[start..]);
155            let count = match tokio::time::timeout(self.config.command_timeout, read).await {
156                Ok(Ok(count)) => count,
157                Ok(Err(e)) => {
158                    self.buffer.truncate(start);
159                    self.broken = true;
160                    return Err(Error::msg(format!("cannot read from Redis: {e}")));
161                }
162                Err(_) => {
163                    self.buffer.truncate(start);
164                    self.broken = true;
165                    return Err(Error::msg("timed out waiting for a reply from Redis"));
166                }
167            };
168
169            self.buffer.truncate(start + count);
170
171            if count == 0 {
172                self.broken = true;
173                return Err(Error::msg(format!(
174                    "Redis at {} closed the connection mid-reply",
175                    self.config.redacted_url()
176                )));
177            }
178        }
179    }
180
181    /// Send `QUIT` and drop the socket, ignoring anything that goes wrong: the
182    /// connection is being discarded either way.
183    pub async fn close(mut self) {
184        let _ = self.stream.write_all(&resp::encode_command(&[b"QUIT"])).await;
185        let _ = self.stream.shutdown().await;
186    }
187}
188
189#[cfg(test)]
190mod tests {
191    use super::*;
192    use std::time::Duration;
193    use tokio::net::TcpListener;
194
195    /// A one-shot fake server that replies with canned bytes, so the framing
196    /// logic can be tested without a Redis installation. Each test binds port
197    /// zero and is handed a real free port, so tests never collide.
198    async fn fake_server(script: Vec<Vec<u8>>) -> (RedisConfig, tokio::task::JoinHandle<Vec<u8>>) {
199        let listener = TcpListener::bind("127.0.0.1:0").await.expect("a free port");
200        let port = listener.local_addr().unwrap().port();
201
202        let handle = tokio::spawn(async move {
203            let (mut socket, _) = listener.accept().await.expect("a client");
204            let mut received = Vec::new();
205
206            for reply in script {
207                let mut chunk = vec![0u8; 1024];
208                match socket.read(&mut chunk).await {
209                    Ok(0) | Err(_) => break,
210                    Ok(count) => received.extend_from_slice(&chunk[..count]),
211                }
212                if socket.write_all(&reply).await.is_err() {
213                    break;
214                }
215            }
216            received
217        });
218
219        let config = RedisConfig {
220            port,
221            connect_timeout: Duration::from_secs(2),
222            command_timeout: Duration::from_secs(2),
223            ..RedisConfig::default()
224        };
225        (config, handle)
226    }
227
228    #[tokio::test]
229    async fn a_command_is_written_as_resp_and_its_reply_decoded() {
230        let (config, server) = fake_server(vec![b"+PONG\r\n".to_vec()]).await;
231        let mut connection = Connection::connect(&config).await.unwrap();
232
233        assert_eq!(connection.command(&[b"PING"]).await.unwrap(), Value::Simple("PONG".into()));
234
235        drop(connection);
236        assert_eq!(server.await.unwrap(), b"*1\r\n$4\r\nPING\r\n".to_vec());
237    }
238
239    #[tokio::test]
240    async fn the_handshake_sends_auth_and_select_before_anything_else() {
241        let (mut config, server) =
242            fake_server(vec![b"+OK\r\n".to_vec(), b"+OK\r\n".to_vec(), b":1\r\n".to_vec()]).await;
243        config.password = "hunter2".into();
244        config.database = 4;
245
246        let mut connection = Connection::connect(&config).await.unwrap();
247        connection.command(&[b"EXISTS", b"k"]).await.unwrap();
248        drop(connection);
249
250        let sent = server.await.unwrap();
251        let expected = [
252            resp::encode_command(&[b"AUTH", b"hunter2"]),
253            resp::encode_command(&[b"SELECT", b"4"]),
254            resp::encode_command(&[b"EXISTS", b"k"]),
255        ]
256        .concat();
257        assert_eq!(sent, expected);
258    }
259
260    #[tokio::test]
261    async fn a_rejected_password_is_reported_without_echoing_it() {
262        let (mut config, _server) = fake_server(vec![b"-WRONGPASS invalid password\r\n".to_vec()]).await;
263        config.password = "hunter2".into();
264
265        let error = match Connection::connect(&config).await {
266            Ok(_) => panic!("connecting should have failed"),
267            Err(e) => e.to_string(),
268        };
269
270        assert!(error.contains("rejected authentication"), "got: {error}");
271        assert!(!error.contains("hunter2"), "the error must not leak the password");
272    }
273
274    #[tokio::test]
275    async fn a_reply_split_across_packets_is_reassembled() {
276        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
277        let port = listener.local_addr().unwrap().port();
278
279        tokio::spawn(async move {
280            let (mut socket, _) = listener.accept().await.unwrap();
281            let mut chunk = vec![0u8; 1024];
282            let _ = socket.read(&mut chunk).await;
283
284            // Deliberately dribbled out: length header, then payload, then CRLF.
285            for piece in [&b"$11\r\n"[..], &b"hello "[..], &b"world"[..], &b"\r\n"[..]] {
286                socket.write_all(piece).await.unwrap();
287                socket.flush().await.unwrap();
288                tokio::time::sleep(Duration::from_millis(5)).await;
289            }
290            tokio::time::sleep(Duration::from_millis(50)).await;
291        });
292
293        let config = RedisConfig { port, ..RedisConfig::default() };
294        let mut connection = Connection::connect(&config).await.unwrap();
295
296        assert_eq!(
297            connection.command(&[b"GET", b"greeting"]).await.unwrap(),
298            Value::Bulk(b"hello world".to_vec())
299        );
300    }
301
302    #[tokio::test]
303    async fn a_server_that_hangs_up_mid_reply_marks_the_connection_broken() {
304        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
305        let port = listener.local_addr().unwrap().port();
306
307        tokio::spawn(async move {
308            let (mut socket, _) = listener.accept().await.unwrap();
309            let mut chunk = vec![0u8; 1024];
310            let _ = socket.read(&mut chunk).await;
311            // A length header promising eleven bytes that never come.
312            socket.write_all(b"$11\r\n").await.unwrap();
313            socket.shutdown().await.unwrap();
314        });
315
316        let config = RedisConfig { port, ..RedisConfig::default() };
317        let mut connection = Connection::connect(&config).await.unwrap();
318
319        let error = connection.command(&[b"GET", b"k"]).await.unwrap_err();
320        assert!(error.to_string().contains("closed the connection"), "got: {error}");
321        assert!(connection.is_broken(), "a truncated reply must never be reused");
322    }
323
324    #[tokio::test]
325    async fn something_that_is_not_redis_is_reported_as_a_protocol_error() {
326        let (config, _server) = fake_server(vec![b"HTTP/1.1 400 Bad Request\r\n".to_vec()]).await;
327        let mut connection = Connection::connect(&config).await.unwrap();
328
329        assert!(connection.command(&[b"PING"]).await.is_err());
330        assert!(connection.is_broken());
331    }
332
333    #[tokio::test]
334    async fn connecting_to_a_closed_port_explains_itself() {
335        let config = RedisConfig { port: 1, connect_timeout: Duration::from_secs(2), ..RedisConfig::default() };
336        let error = match Connection::connect(&config).await {
337            Ok(_) => panic!("connecting should have failed"),
338            Err(e) => e.to_string(),
339        };
340
341        assert!(error.contains("cannot connect to redis://"), "got: {error}");
342    }
343}