nautilus_infrastructure/redis/
mod.rs1pub mod cache;
19pub mod msgbus;
20pub mod queries;
21
22use std::{fmt::Write as _, time::Duration};
23
24use nautilus_common::{logging::log_task_awaiting, msgbus::MessageBusConfig};
25use nautilus_core::{UUID4, string::semver::SemVer};
26use nautilus_model::identifiers::TraderId;
27use redis::RedisError;
28
29const REDIS_MIN_VERSION: &str = "6.2.0";
30const REDIS_DELIMITER: char = ':';
31const REDIS_INDEX_PATTERN: &str = ":index:";
32const REDIS_XTRIM: &str = "XTRIM";
33const REDIS_MINID: &str = "MINID";
34const REDIS_FLUSHDB: &str = "FLUSHDB";
35
36pub(crate) fn get_index_key(key: &str) -> anyhow::Result<&str> {
41 if let Some(pos) = key.find(REDIS_INDEX_PATTERN) {
42 return Ok(&key[pos + 1..]);
43 }
44
45 if key.starts_with("index:") {
46 return Ok(key);
47 }
48
49 anyhow::bail!("Invalid index key format: {key}")
50}
51
52async fn await_handle(handle: Option<tokio::task::JoinHandle<()>>, task_name: &str) {
53 if let Some(handle) = handle {
54 log_task_awaiting(task_name);
55
56 let timeout = Duration::from_secs(2);
57 match tokio::time::timeout(timeout, handle).await {
58 Ok(result) => {
59 if let Err(e) = result {
60 log::error!("Error awaiting task '{task_name}': {e:?}");
61 }
62 }
63 Err(_) => {
64 log::warn!("Timeout {timeout:?} awaiting task '{task_name}'");
65 }
66 }
67 }
68}
69
70pub trait RedisConnectionConfig {
72 fn host(&self) -> Option<&str>;
73 fn port(&self) -> Option<u16>;
74 fn username(&self) -> Option<&str>;
75 fn password(&self) -> Option<&str>;
76 fn ssl(&self) -> bool;
77 fn connection_timeout(&self) -> u16;
78 fn response_timeout(&self) -> u16;
79 fn number_of_retries(&self) -> usize;
80 fn exponent_base(&self) -> u64;
81 fn max_delay(&self) -> u64;
82 fn factor(&self) -> u64;
83}
84
85#[must_use]
101pub fn get_redis_url(config: &impl RedisConnectionConfig) -> (String, String) {
102 let host = config.host().unwrap_or("127.0.0.1");
103 let port = config.port().unwrap_or(6379);
104 let username = config.username().unwrap_or_default();
105 let password = config.password().unwrap_or_default();
106 let ssl = config.ssl();
107
108 let redact_pw = |pw: &str| {
110 if pw.len() > 4 {
111 format!("{}...{}", &pw[..2], &pw[pw.len() - 2..])
112 } else {
113 pw.to_owned()
114 }
115 };
116
117 let (auth, auth_redacted) = match (username.is_empty(), password.is_empty()) {
119 (false, false) => (
121 format!("{username}:{password}@"),
122 format!("{username}:{}@", redact_pw(password)),
123 ),
124 (true, false) => (
126 format!(":{password}@"),
127 format!(":{}@", redact_pw(password)),
128 ),
129 (false, true) => panic!(
131 "Redis config error: username supplied without password. \
132 Either supply a password or omit the username."
133 ),
134 (true, true) => (String::new(), String::new()),
136 };
137
138 let scheme = if ssl { "rediss" } else { "redis" };
139
140 let url = format!("{scheme}://{auth}{host}:{port}");
141 let redacted_url = format!("{scheme}://{auth_redacted}{host}:{port}");
142
143 (url, redacted_url)
144}
145
146pub async fn create_redis_connection(
161 con_name: &str,
162 config: &impl RedisConnectionConfig,
163) -> anyhow::Result<redis::aio::ConnectionManager> {
164 log::debug!("Creating {con_name} redis connection");
165 let (redis_url, redacted_url) = get_redis_url(config);
166 log::debug!("Connecting to {redacted_url}");
167
168 let connection_timeout = Duration::from_secs(u64::from(config.connection_timeout()));
169 let response_timeout = Duration::from_secs(u64::from(config.response_timeout()));
170 let number_of_retries = config.number_of_retries();
171 #[expect(
172 clippy::cast_precision_loss,
173 reason = "redis connection manager API accepts exponent base as f32"
174 )]
175 let exponent_base = config.exponent_base() as f32;
176
177 let min_delay = Duration::from_millis(config.factor());
179 let max_delay = Duration::from_secs(config.max_delay());
180
181 let client = redis::Client::open(redis_url)?;
182
183 let connection_manager_config = redis::aio::ConnectionManagerConfig::new()
184 .set_exponent_base(exponent_base)
185 .set_number_of_retries(number_of_retries)
186 .set_response_timeout(Some(response_timeout))
187 .set_connection_timeout(Some(connection_timeout))
188 .set_min_delay(min_delay)
189 .set_max_delay(max_delay);
190
191 let mut con = client
192 .get_connection_manager_with_config(connection_manager_config)
193 .await?;
194
195 let version = get_redis_version(&mut con).await?;
196 let min_version = SemVer::parse(REDIS_MIN_VERSION)?;
197 let con_msg = format!("Connected to redis v{version}");
198
199 if version >= min_version {
200 log::info!("{con_msg}");
201 } else {
202 log::error!("{con_msg}, but minimum supported version is {REDIS_MIN_VERSION}");
203 }
204
205 Ok(con)
206}
207
208pub async fn flush_redis(
214 con: &mut redis::aio::ConnectionManager,
215) -> anyhow::Result<(), RedisError> {
216 redis::cmd(REDIS_FLUSHDB).exec_async(con).await
217}
218
219#[must_use]
221pub fn get_stream_key(
222 trader_id: TraderId,
223 instance_id: UUID4,
224 config: &MessageBusConfig,
225) -> String {
226 let mut stream_key = String::new();
227
228 if config.use_trader_prefix {
229 stream_key.push_str("trader-");
230 }
231
232 if config.use_trader_id {
233 stream_key.push_str(trader_id.as_str());
234 stream_key.push(REDIS_DELIMITER);
235 }
236
237 if config.use_instance_id {
238 write!(stream_key, "{instance_id}").expect("writing to String cannot fail");
239 stream_key.push(REDIS_DELIMITER);
240 }
241
242 stream_key.push_str(&config.streams_prefix);
243 stream_key
244}
245
246async fn get_redis_version(conn: &mut redis::aio::ConnectionManager) -> anyhow::Result<SemVer> {
247 let info: String = redis::cmd("INFO").query_async(conn).await?;
248 let Some(version_str) = info.lines().find_map(|line| {
249 if line.starts_with("redis_version:") {
250 line.split(':').nth(1).map(|s| s.trim().to_string())
251 } else {
252 None
253 }
254 }) else {
255 anyhow::bail!("Redis version not available");
256 };
257
258 SemVer::parse(&version_str)
259}
260
261#[cfg(test)]
262mod tests {
263 use rstest::rstest;
264 use serde_json::json;
265
266 use super::*;
267 use crate::redis::cache::RedisCacheConfig;
268
269 #[rstest]
270 fn test_get_redis_url_default_values() {
271 let config: RedisCacheConfig = serde_json::from_value(json!({})).unwrap();
272 let (url, redacted_url) = get_redis_url(&config);
273 assert_eq!(url, "redis://127.0.0.1:6379");
274 assert_eq!(redacted_url, "redis://127.0.0.1:6379");
275 }
276
277 #[rstest]
278 fn test_get_redis_url_password_only() {
279 let config_json = json!({
281 "host": "example.com",
282 "port": 6380,
283 "password": "secretpw", });
285 let config: RedisCacheConfig = serde_json::from_value(config_json).unwrap();
286 let (url, redacted_url) = get_redis_url(&config);
287 assert_eq!(url, "redis://:secretpw@example.com:6380");
288 assert_eq!(redacted_url, "redis://:se...pw@example.com:6380");
289 }
290
291 #[rstest]
292 fn test_get_redis_url_full_config_with_ssl() {
293 let config_json = json!({
294 "host": "example.com",
295 "port": 6380,
296 "username": "user",
297 "password": "pass",
298 "ssl": true,
299 });
300 let config: RedisCacheConfig = serde_json::from_value(config_json).unwrap();
301 let (url, redacted_url) = get_redis_url(&config);
302 assert_eq!(url, "rediss://user:pass@example.com:6380");
303 assert_eq!(redacted_url, "rediss://user:pass@example.com:6380");
304 }
305
306 #[rstest]
307 fn test_get_redis_url_full_config_without_ssl() {
308 let config_json = json!({
309 "host": "example.com",
310 "port": 6380,
311 "username": "username",
312 "password": "password",
313 "ssl": false,
314 });
315 let config: RedisCacheConfig = serde_json::from_value(config_json).unwrap();
316 let (url, redacted_url) = get_redis_url(&config);
317 assert_eq!(url, "redis://username:password@example.com:6380");
318 assert_eq!(redacted_url, "redis://username:pa...rd@example.com:6380");
319 }
320
321 #[rstest]
322 fn test_get_redis_url_missing_username_and_password() {
323 let config_json = json!({
324 "host": "example.com",
325 "port": 6380,
326 "ssl": false,
327 });
328 let config: RedisCacheConfig = serde_json::from_value(config_json).unwrap();
329 let (url, redacted_url) = get_redis_url(&config);
330 assert_eq!(url, "redis://example.com:6380");
331 assert_eq!(redacted_url, "redis://example.com:6380");
332 }
333
334 #[rstest]
335 fn test_get_redis_url_ssl_default_false() {
336 let config_json = json!({
337 "host": "example.com",
338 "port": 6380,
339 "username": "username",
340 "password": "password",
341 });
343 let config: RedisCacheConfig = serde_json::from_value(config_json).unwrap();
344 let (url, redacted_url) = get_redis_url(&config);
345 assert_eq!(url, "redis://username:password@example.com:6380");
346 assert_eq!(redacted_url, "redis://username:pa...rd@example.com:6380");
347 }
348
349 #[rstest]
350 fn test_get_stream_key_with_trader_prefix_and_instance_id() {
351 let trader_id = TraderId::from("tester-123");
352 let instance_id = UUID4::new();
353 let config = MessageBusConfig {
354 use_instance_id: true,
355 ..Default::default()
356 };
357
358 let key = get_stream_key(trader_id, instance_id, &config);
359 assert_eq!(key, format!("trader-tester-123:{instance_id}:stream"));
360 }
361
362 #[rstest]
363 fn test_get_stream_key_without_trader_prefix_or_instance_id() {
364 let trader_id = TraderId::from("tester-123");
365 let instance_id = UUID4::new();
366 let config = MessageBusConfig {
367 use_trader_prefix: false,
368 use_trader_id: false,
369 ..Default::default()
370 };
371
372 let key = get_stream_key(trader_id, instance_id, &config);
373 assert_eq!(key, "stream".to_string());
374 }
375
376 #[rstest]
377 fn test_get_index_key_without_prefix() {
378 let key = "index:order_position";
379 assert_eq!(get_index_key(key).unwrap(), "index:order_position");
380 }
381
382 #[rstest]
383 fn test_get_index_key_with_trader_prefix() {
384 let key = "trader-tester-123:index:order_position";
385 assert_eq!(get_index_key(key).unwrap(), "index:order_position");
386 }
387
388 #[rstest]
389 fn test_get_index_key_with_instance_id() {
390 let key = "trader-tester-123:abc-uuid-123:index:order_position";
391 assert_eq!(get_index_key(key).unwrap(), "index:order_position");
392 }
393
394 #[rstest]
395 fn test_get_index_key_invalid() {
396 let key = "no_index_pattern";
397 assert!(get_index_key(key).is_err());
398 }
399}