/build/source/nativelink-redis-tester/src/dynamic_fake_redis.rs
Line | Count | Source |
1 | | // Copyright 2026 The NativeLink Authors. All rights reserved. |
2 | | // |
3 | | // Licensed under the Functional Source License, Version 1.1, Apache 2.0 Future License (the "License"); |
4 | | // you may not use this file except in compliance with the License. |
5 | | // You may obtain a copy of the License at |
6 | | // |
7 | | // See LICENSE file for details |
8 | | // |
9 | | // Unless required by applicable law or agreed to in writing, software |
10 | | // distributed under the License is distributed on an "AS IS" BASIS, |
11 | | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
12 | | // See the License for the specific language governing permissions and |
13 | | // limitations under the License. |
14 | | |
15 | | use core::fmt; |
16 | | use std::collections::HashMap; |
17 | | use std::collections::hash_map::Entry; |
18 | | use std::sync::{Arc, Mutex}; |
19 | | |
20 | | use nativelink_util::background_spawn; |
21 | | use redis::Value; |
22 | | use redis_protocol::resp2::decode::decode; |
23 | | use redis_protocol::resp2::types::{OwnedFrame, Resp2Frame}; |
24 | | use tokio::net::TcpListener; |
25 | | use tokio::sync::oneshot::{self, Sender}; |
26 | | use tracing::{debug, info, trace}; |
27 | | |
28 | | use crate::fake_redis::{arg_as_string, fake_redis_internal}; |
29 | | |
30 | | pub trait SubscriptionManagerNotify { |
31 | | fn notify_for_test(&self, value: String); |
32 | | } |
33 | | |
34 | | #[derive(Clone)] |
35 | | pub struct FakeRedisBackend<S: SubscriptionManagerNotify> { |
36 | | /// Contains a list of all of the Redis keys -> fields. |
37 | | pub table: Arc<Mutex<HashMap<String, HashMap<String, Value>>>>, |
38 | | /// TTL (seconds) attached to each key via `EXPIRE`, so tests can |
39 | | /// assert a key was given a bounded lifetime. |
40 | | pub expiries: Arc<Mutex<HashMap<String, i64>>>, |
41 | | subscription_manager: Arc<Mutex<Option<Arc<S>>>>, |
42 | | } |
43 | | |
44 | | impl<S: SubscriptionManagerNotify + Send + 'static + Sync> Default for FakeRedisBackend<S> { |
45 | 0 | fn default() -> Self { |
46 | 0 | Self::new() |
47 | 0 | } |
48 | | } |
49 | | |
50 | | impl<S: SubscriptionManagerNotify> fmt::Debug for FakeRedisBackend<S> { |
51 | 0 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { |
52 | 0 | f.debug_struct("FakeRedisBackend").finish() |
53 | 0 | } |
54 | | } |
55 | | |
56 | | const FAKE_SCRIPT_SHA: &str = "5148c724ce419ea27d1971dcb61c111dbbc6b63e"; |
57 | | |
58 | | impl<S: SubscriptionManagerNotify + Send + 'static + Sync> FakeRedisBackend<S> { |
59 | 4 | pub fn new() -> Self { |
60 | 4 | Self { |
61 | 4 | table: Arc::new(Mutex::new(HashMap::new())), |
62 | 4 | expiries: Arc::new(Mutex::new(HashMap::new())), |
63 | 4 | subscription_manager: Arc::new(Mutex::new(None)), |
64 | 4 | } |
65 | 4 | } |
66 | | |
67 | 3 | pub fn set_subscription_manager(&self, subscription_manager: Arc<S>) { |
68 | 3 | self.subscription_manager |
69 | 3 | .lock() |
70 | 3 | .unwrap() |
71 | 3 | .replace(subscription_manager); |
72 | 3 | } |
73 | | |
74 | 4 | async fn dynamic_fake_redis(self, listener: TcpListener, listener_ready_tx: Sender<()>) { |
75 | 169 | let inner4 = move |buf: &[u8]| -> String { |
76 | 169 | let mut output = String::new(); |
77 | 169 | let mut buf_index = 0; |
78 | | loop { |
79 | 183 | let frame = match decode(&buf[buf_index..]).unwrap() { |
80 | 183 | Some((frame, amt)) => { |
81 | 183 | buf_index += amt; |
82 | 183 | frame |
83 | | } |
84 | | None => { |
85 | 0 | panic!("No frame!"); |
86 | | } |
87 | | }; |
88 | 183 | let (cmd, args) = { |
89 | 183 | if let OwnedFrame::Array(a) = frame { |
90 | 183 | if let OwnedFrame::BulkString(s) = a.first().unwrap() { |
91 | 183 | let args: Vec<_> = a[1..].to_vec(); |
92 | 183 | (str::from_utf8(s).unwrap().to_string(), args) |
93 | | } else { |
94 | 0 | panic!("Array not starting with cmd: {a:?}"); |
95 | | } |
96 | | } else { |
97 | 0 | panic!("Non array cmd: {frame:?}"); |
98 | | } |
99 | | }; |
100 | | |
101 | 183 | let ret: Value = match cmd.as_str() { |
102 | 183 | "HELLO" => Value::Map(4 vec!4 [( |
103 | 4 | Value::SimpleString("server".into()), |
104 | 4 | Value::SimpleString("redis".into()), |
105 | 4 | )]), |
106 | 179 | "CLIENT" => { |
107 | | // We can safely ignore these, as it's just setting the library name/version |
108 | 8 | Value::Int(0) |
109 | | } |
110 | 171 | "SCRIPT" => { |
111 | 4 | assert_eq!(args[0], OwnedFrame::BulkString(b"LOAD".to_vec())); |
112 | | |
113 | 4 | let OwnedFrame::BulkString(ref _script) = args[1] else { |
114 | 0 | panic!("Script should be a bulkstring: {args:?}"); |
115 | | }; |
116 | 4 | Value::SimpleString(FAKE_SCRIPT_SHA.to_string()) |
117 | | } |
118 | | |
119 | 167 | "PSUBSCRIBE" => { |
120 | | // This does nothing at the moment, maybe we need to implement it later. |
121 | 8 | Value::Int(0) |
122 | | } |
123 | | |
124 | 159 | "PUBLISH" => { |
125 | 16 | if let Some(subscription_manager) = |
126 | 17 | self.subscription_manager.lock().unwrap().as_ref() |
127 | | { |
128 | 16 | subscription_manager.notify_for_test( |
129 | 16 | str::from_utf8(args[1].as_bytes().expect("Notification not bytes")) |
130 | 16 | .expect("Notification not UTF-8") |
131 | 16 | .into(), |
132 | 16 | ); |
133 | 16 | Value::Int(1) |
134 | | } else { |
135 | 1 | Value::Int(0) |
136 | | } |
137 | | } |
138 | | |
139 | 142 | "FT.AGGREGATE" => { |
140 | | // The query is either "*" (match all) or @field:{ value }. |
141 | 23 | let OwnedFrame::BulkString(ref raw_query) = args[1] else { |
142 | 0 | panic!("Aggregate query should be a string: {args:?}"); |
143 | | }; |
144 | 23 | let query = str::from_utf8(raw_query).unwrap(); |
145 | | // The real ft_aggregate caller now passes an explicit |
146 | | // `TIMEOUT <ms>` clause before `LOAD`. Tolerate both |
147 | | // shapes here so this fake doesn't break older callers |
148 | | // and the LOAD-args check still validates the bit we |
149 | | // actually care about. |
150 | 23 | let load_offset = if matches!(args.get(2), Some(OwnedFrame::BulkString(b)) if b == b"TIMEOUT") |
151 | | { |
152 | | // Skip "TIMEOUT" and its millisecond argument. |
153 | 23 | 4 |
154 | | } else { |
155 | 0 | 2 |
156 | | }; |
157 | | // Lazy implementation making assumptions. |
158 | 23 | assert_eq!( |
159 | 23 | args[load_offset..load_offset + 4], |
160 | 23 | vec![ |
161 | 23 | OwnedFrame::BulkString(b"LOAD".to_vec()), |
162 | 23 | OwnedFrame::BulkString(b"2".to_vec()), |
163 | 23 | OwnedFrame::BulkString(b"data".to_vec()), |
164 | 23 | OwnedFrame::BulkString(b"version".to_vec()) |
165 | | ] |
166 | | ); |
167 | 23 | let mut results = vec![Value::Int(0)]; |
168 | | |
169 | 23 | if query == "*" { |
170 | | // Wildcard query - return all records that have both data and version fields. |
171 | | // Some entries (e.g., from HSET) may not have version field. |
172 | 0 | for fields in self.table.lock().unwrap().values() { |
173 | 0 | if let (Some(data), Some(version)) = |
174 | 0 | (fields.get("data"), fields.get("version")) |
175 | 0 | { |
176 | 0 | results.push(Value::Array(vec![ |
177 | 0 | Value::BulkString(b"data".to_vec()), |
178 | 0 | data.clone(), |
179 | 0 | Value::BulkString(b"version".to_vec()), |
180 | 0 | version.clone(), |
181 | 0 | ])); |
182 | 0 | } |
183 | | } |
184 | | } else { |
185 | | // Field-specific query: @field:{ value } |
186 | 23 | assert_eq!(&query[..1], "@"); |
187 | 23 | let mut parts = query[1..].split(':'); |
188 | 23 | let field = parts.next().expect("No field name"); |
189 | 23 | let value = parts.next().expect("No value"); |
190 | 23 | let value = value |
191 | 23 | .strip_prefix("{ ") |
192 | 23 | .and_then(|s| s.strip_suffix(" }")) |
193 | 23 | .unwrap_or(value); |
194 | 56 | for fields in self.table.lock().unwrap().values()23 { |
195 | 56 | if let Some(key_value19 ) = fields.get(field) |
196 | 19 | && *key_value == Value::BulkString(value.as_bytes().to_vec()) |
197 | 10 | { |
198 | 10 | results.push(Value::Array(vec![ |
199 | 10 | Value::BulkString(b"data".to_vec()), |
200 | 10 | fields.get("data").expect("No data field").clone(), |
201 | 10 | Value::BulkString(b"version".to_vec()), |
202 | 10 | fields.get("version").expect("No version field").clone(), |
203 | 10 | ])); |
204 | 46 | } |
205 | | } |
206 | | } |
207 | | |
208 | 23 | results[0] = |
209 | 23 | Value::Int(i64::try_from(results.len() - 1).unwrap_or(i64::MAX)); |
210 | 23 | Value::Array(vec![ |
211 | 23 | Value::Array(results), |
212 | 23 | Value::Int(0), // Means no more items in cursor. |
213 | 23 | ]) |
214 | | } |
215 | | |
216 | 119 | "EVALSHA" => { |
217 | 17 | assert_eq!( |
218 | 17 | args[0], |
219 | 17 | OwnedFrame::BulkString(FAKE_SCRIPT_SHA.as_bytes().to_vec()) |
220 | | ); |
221 | 17 | assert_eq!(args[1], OwnedFrame::BulkString(b"1".to_vec())); |
222 | 17 | let mut value: HashMap<_, Value> = HashMap::new(); |
223 | 17 | value.insert( |
224 | 17 | "data".into(), |
225 | 17 | Value::BulkString(args[5].as_bytes().unwrap().to_vec()), |
226 | | ); |
227 | 51 | for pair in args[6..]17 .chunks17 (2) { |
228 | 51 | value.insert( |
229 | 51 | str::from_utf8(pair[0].as_bytes().expect("Field name not bytes")) |
230 | 51 | .expect("Unable to parse field name as string") |
231 | 51 | .into(), |
232 | 51 | Value::BulkString(pair[1].as_bytes().unwrap().to_vec()), |
233 | 51 | ); |
234 | 51 | } |
235 | 17 | let mut ret: Option<Value> = None; |
236 | 17 | let key: String = |
237 | 17 | str::from_utf8(args[2].as_bytes().expect("Key not bytes")) |
238 | 17 | .expect("Key cannot be parsed as string") |
239 | 17 | .into(); |
240 | 17 | let expected_existing_version: i64 = |
241 | 17 | str::from_utf8(args[3].as_bytes().unwrap()) |
242 | 17 | .unwrap() |
243 | 17 | .parse() |
244 | 17 | .expect("Unable to parse existing version field"); |
245 | 17 | let expiry: i64 = str::from_utf8(args[4].as_bytes().unwrap()) |
246 | 17 | .unwrap() |
247 | 17 | .parse() |
248 | 17 | .expect("Unable to parse expiry field"); |
249 | 17 | trace!( |
250 | | key, |
251 | | expected_existing_version, |
252 | | expiry, |
253 | | ?value, |
254 | | "Want to insert with EVALSHA" |
255 | | ); |
256 | 17 | let version = match self.table.lock().unwrap().entry(key.clone()) { |
257 | 13 | Entry::Occupied(mut occupied_entry) => { |
258 | 13 | let version = occupied_entry |
259 | 13 | .get() |
260 | 13 | .get("version") |
261 | 13 | .expect("No version field"); |
262 | 13 | let Value::BulkString(version_bytes) = version else { |
263 | 0 | panic!("Non-bulkstring version: {version:?}"); |
264 | | }; |
265 | 13 | let version_int: i64 = str::from_utf8(version_bytes) |
266 | 13 | .expect("Version field not valid string") |
267 | 13 | .parse() |
268 | 13 | .expect("Unable to parse version field"); |
269 | 13 | if version_int == expected_existing_version { |
270 | 9 | let new_version = version_int + 1; |
271 | 9 | debug!(%key, %new_version, "Version update"); |
272 | 9 | value.insert( |
273 | 9 | "version".into(), |
274 | 9 | Value::BulkString( |
275 | 9 | format!("{new_version}").as_bytes().to_vec(), |
276 | 9 | ), |
277 | | ); |
278 | 9 | occupied_entry.insert(value); |
279 | 9 | new_version |
280 | | } else { |
281 | | // Version mismatch. |
282 | 4 | debug!(%key, %version_int, %expected_existing_version, "Version mismatch"); |
283 | 4 | ret = Some(Value::Array(vec![ |
284 | 4 | Value::Int(0), |
285 | 4 | Value::Int(version_int), |
286 | 4 | ])); |
287 | 4 | -1 |
288 | | } |
289 | | } |
290 | 4 | Entry::Vacant(vacant_entry) => { |
291 | 4 | if expected_existing_version != 0 { |
292 | | // Version mismatch. |
293 | 0 | debug!(%key, %expected_existing_version, "Version mismatch, expected zero"); |
294 | 0 | ret = Some(Value::Array(vec![Value::Int(0), Value::Int(0)])); |
295 | 0 | -1 |
296 | | } else { |
297 | 4 | debug!(%key, "Version insert"); |
298 | 4 | value |
299 | 4 | .insert("version".into(), Value::BulkString(b"1".to_vec())); |
300 | 4 | vacant_entry.insert_entry(value); |
301 | 4 | 1 |
302 | | } |
303 | | } |
304 | | }; |
305 | 17 | if let Some(r4 ) = ret { |
306 | 4 | r |
307 | | } else { |
308 | 13 | Value::Array(vec![Value::Int(1), Value::Int(version)]) |
309 | | } |
310 | | } |
311 | | |
312 | 102 | "HMSET" => { |
313 | 4 | let mut values = HashMap::new(); |
314 | 4 | assert_eq!( |
315 | 4 | (args.len() - 1).rem_euclid(2), |
316 | | 0, |
317 | | "Non-even args for hmset: {args:?}" |
318 | | ); |
319 | 4 | let chunks = args[1..].chunks_exact(2); |
320 | 4 | for chunk in chunks { |
321 | 4 | let [key, value] = chunk else { |
322 | 0 | panic!("Uneven hmset args"); |
323 | | }; |
324 | 4 | let key_name: String = |
325 | 4 | str::from_utf8(key.as_bytes().expect("Key argument is not bytes")) |
326 | 4 | .expect("Unable to parse key as string") |
327 | 4 | .into(); |
328 | 4 | values.insert( |
329 | 4 | key_name, |
330 | 4 | Value::BulkString(value.as_bytes().unwrap().to_vec()), |
331 | | ); |
332 | | } |
333 | 4 | let key = |
334 | 4 | str::from_utf8(args[0].as_bytes().expect("Key argument is not bytes")) |
335 | 4 | .expect("Unable to parse key as string") |
336 | 4 | .into(); |
337 | 4 | debug!(%key, ?values, "Inserting with HMSET"); |
338 | 4 | self.table.lock().unwrap().insert(key, values); |
339 | 4 | Value::Okay |
340 | | } |
341 | | |
342 | 98 | "EXPIRE" => { |
343 | | // `EXPIRE key seconds`: record the TTL; return 1 |
344 | | // if the key existed, else 0 (as real Redis does). |
345 | 4 | let key_name = |
346 | 4 | str::from_utf8(args[0].as_bytes().expect("Key argument is not bytes")) |
347 | 4 | .expect("Unable to parse key name") |
348 | 4 | .to_string(); |
349 | 4 | let seconds = str::from_utf8( |
350 | 4 | args[1].as_bytes().expect("EXPIRE seconds is not bytes"), |
351 | 4 | ) |
352 | 4 | .expect("EXPIRE seconds is not utf8") |
353 | 4 | .parse::<i64>() |
354 | 4 | .expect("EXPIRE seconds is not an integer"); |
355 | 4 | let exists = self.table.lock().unwrap().contains_key(&key_name); |
356 | 4 | if exists { |
357 | 4 | self.expiries.lock().unwrap().insert(key_name, seconds); |
358 | 4 | Value::Int(1) |
359 | | } else { |
360 | 0 | Value::Int(0) |
361 | | } |
362 | | } |
363 | | |
364 | 94 | "HMGET" => { |
365 | 94 | let key_name = |
366 | 94 | str::from_utf8(args[0].as_bytes().expect("Key argument is not bytes")) |
367 | 94 | .expect("Unable to parse key name"); |
368 | | |
369 | 94 | if let Some(fields48 ) = self.table.lock().unwrap().get(key_name) { |
370 | 48 | trace!(%key_name, keys = ?fields.keys(), "Getting keys with HMGET, some keys"); |
371 | 48 | let mut result = vec![]; |
372 | 96 | for key in &args[1..]48 { |
373 | 96 | let field_name = str::from_utf8( |
374 | 96 | key.as_bytes().expect("Field argument is not bytes"), |
375 | | ) |
376 | 96 | .expect("Unable to parse requested field"); |
377 | 96 | if let Some(value94 ) = fields.get(field_name) { |
378 | 94 | result.push(value.clone()); |
379 | 94 | } else { |
380 | 2 | debug!(%key_name, %field_name, "Missing field"); |
381 | 2 | result.push(Value::Nil); |
382 | | } |
383 | | } |
384 | 48 | Value::Array(result) |
385 | | } else { |
386 | 46 | trace!(%key_name, "Getting keys with HMGET, empty"); |
387 | 46 | let null_count = i64::try_from(args.len() - 1).unwrap(); |
388 | 46 | Value::Array(vec![Value::Nil, Value::Int(null_count)]) |
389 | | } |
390 | | } |
391 | 0 | actual => { |
392 | 0 | panic!("Mock command not implemented! {actual:?}"); |
393 | | } |
394 | | }; |
395 | | |
396 | 183 | arg_as_string(&mut output, ret); |
397 | 183 | if buf_index == buf.len() { |
398 | 169 | break; |
399 | 14 | } |
400 | | } |
401 | 169 | output |
402 | 169 | }; |
403 | 4 | fake_redis_internal(listener, listener_ready_tx, vec![inner]).await; |
404 | 0 | } |
405 | | |
406 | 4 | pub async fn run(self) -> u16 { |
407 | 4 | let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); |
408 | 4 | let port = listener.local_addr().unwrap().port(); |
409 | 4 | info!("Using port {port}"); |
410 | | |
411 | 4 | let (listener_ready_tx, listener_ready_rx) = oneshot::channel::<()>(); |
412 | | |
413 | 4 | background_spawn!("listener", async move { |
414 | 4 | self.dynamic_fake_redis(listener, listener_ready_tx).await; |
415 | 0 | }); |
416 | | |
417 | 4 | listener_ready_rx |
418 | 4 | .await |
419 | 4 | .expect("Expected successful listener boot"); |
420 | | |
421 | 4 | port |
422 | 4 | } |
423 | | } |