redis_oxide/script.rs
1//! Lua scripting support for Redis
2//!
3//! This module provides functionality for executing Lua scripts on Redis servers
4//! using EVAL and EVALSHA commands. Scripts are automatically cached and managed
5//! for optimal performance.
6//!
7//! # Examples
8//!
9//! ## Basic Script Execution
10//!
11//! ```no_run
12//! use redis_oxide::{Client, ConnectionConfig};
13//!
14//! # #[tokio::main]
15//! # async fn main() -> Result<(), Box<dyn std::error::Error>> {
16//! let config = ConnectionConfig::new("redis://localhost:6379");
17//! let client = Client::connect(config).await?;
18//!
19//! // Execute a simple Lua script
20//! let script = "return redis.call('GET', KEYS[1])";
21//! let result: Option<String> = client.eval(script, vec!["mykey".to_string()], vec![]).await?;
22//! println!("Result: {:?}", result);
23//! # Ok(())
24//! # }
25//! ```
26//!
27//! ## Script with Arguments
28//!
29//! ```no_run
30//! use redis_oxide::{Client, ConnectionConfig};
31//!
32//! # #[tokio::main]
33//! # async fn main() -> Result<(), Box<dyn std::error::Error>> {
34//! let config = ConnectionConfig::new("redis://localhost:6379");
35//! let client = Client::connect(config).await?;
36//!
37//! // Script that increments a counter by a given amount
38//! let script = r#"
39//! local current = redis.call('GET', KEYS[1]) or 0
40//! local increment = tonumber(ARGV[1])
41//! local new_value = tonumber(current) + increment
42//! redis.call('SET', KEYS[1], new_value)
43//! return new_value
44//! "#;
45//!
46//! let result: i64 = client.eval(
47//! script,
48//! vec!["counter".to_string()],
49//! vec!["5".to_string()]
50//! ).await?;
51//! println!("New counter value: {}", result);
52//! # Ok(())
53//! # }
54//! ```
55//!
56//! ## Using Script Manager for Caching
57//!
58//! ```no_run
59//! use redis_oxide::{Client, ConnectionConfig, Script};
60//!
61//! # #[tokio::main]
62//! # async fn main() -> Result<(), Box<dyn std::error::Error>> {
63//! let config = ConnectionConfig::new("redis://localhost:6379");
64//! let client = Client::connect(config).await?;
65//!
66//! // Create a reusable script
67//! let script = Script::new(r#"
68//! local key = KEYS[1]
69//! local value = ARGV[1]
70//! redis.call('SET', key, value)
71//! return redis.call('GET', key)
72//! "#);
73//!
74//! // Execute the script (automatically uses EVALSHA if cached)
75//! let result: String = script.execute(
76//! &client,
77//! vec!["mykey".to_string()],
78//! vec!["myvalue".to_string()]
79//! ).await?;
80//! println!("Result: {}", result);
81//! # Ok(())
82//! # }
83//! ```
84
85use crate::core::{
86 error::{RedisError, RedisResult},
87 value::RespValue,
88};
89use sha1::{Digest, Sha1};
90use std::collections::HashMap;
91use std::convert::TryFrom;
92use std::sync::Arc;
93use tokio::sync::RwLock;
94
95/// A Lua script that can be executed on Redis
96#[derive(Debug, Clone)]
97pub struct Script {
98 /// The Lua script source code
99 source: String,
100 /// SHA1 hash of the script (for EVALSHA)
101 sha: String,
102}
103
104impl Script {
105 /// Create a new script from Lua source code
106 ///
107 /// The script is automatically hashed for use with EVALSHA.
108 ///
109 /// # Examples
110 ///
111 /// ```
112 /// use redis_oxide::Script;
113 ///
114 /// let script = Script::new("return redis.call('GET', KEYS[1])");
115 /// println!("Script SHA: {}", script.sha());
116 /// ```
117 pub fn new(source: impl Into<String>) -> Self {
118 let source = source.into();
119 let sha = calculate_sha1(&source);
120
121 Self { source, sha }
122 }
123
124 /// Get the SHA1 hash of the script
125 #[must_use]
126 pub fn sha(&self) -> &str {
127 &self.sha
128 }
129
130 /// Get the source code of the script
131 #[must_use]
132 pub fn source(&self) -> &str {
133 &self.source
134 }
135
136 /// Execute the script on the given client
137 ///
138 /// This method will first try to use EVALSHA (if the script is cached on the server),
139 /// and fall back to EVAL if the script is not cached.
140 ///
141 /// # Arguments
142 ///
143 /// * `client` - The Redis client to execute the script on
144 /// * `keys` - List of Redis keys that the script will access (KEYS array in Lua)
145 /// * `args` - List of arguments to pass to the script (ARGV array in Lua)
146 ///
147 /// # Examples
148 ///
149 /// ```no_run
150 /// # use redis_oxide::{Client, ConnectionConfig, Script};
151 /// # #[tokio::main]
152 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
153 /// # let config = ConnectionConfig::new("redis://localhost:6379");
154 /// # let client = Client::connect(config).await?;
155 /// let script = Script::new("return KEYS[1] .. ':' .. ARGV[1]");
156 ///
157 /// let result: String = script.execute(
158 /// &client,
159 /// vec!["user".to_string()],
160 /// vec!["123".to_string()]
161 /// ).await?;
162 ///
163 /// assert_eq!(result, "user:123");
164 /// # Ok(())
165 /// # }
166 /// ```
167 pub async fn execute<T>(
168 &self,
169 client: &crate::Client,
170 keys: Vec<String>,
171 args: Vec<String>,
172 ) -> RedisResult<T>
173 where
174 T: TryFrom<RespValue>,
175 T::Error: Into<RedisError>,
176 {
177 // First try EVALSHA
178 match client.evalsha(&self.sha, keys.clone(), args.clone()).await {
179 Ok(result) => Ok(result),
180 Err(RedisError::Protocol(msg) | RedisError::Server(msg))
181 if msg.contains("NOSCRIPT") =>
182 {
183 // Script not cached, use EVAL
184 client.eval(&self.source, keys, args).await
185 }
186 Err(e) => Err(e),
187 }
188 }
189
190 /// Load the script into Redis cache
191 ///
192 /// This sends the script to Redis using SCRIPT LOAD, which caches it
193 /// for future EVALSHA calls.
194 ///
195 /// # Examples
196 ///
197 /// ```no_run
198 /// # use redis_oxide::{Client, ConnectionConfig, Script};
199 /// # #[tokio::main]
200 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
201 /// # let config = ConnectionConfig::new("redis://localhost:6379");
202 /// # let client = Client::connect(config).await?;
203 /// let script = Script::new("return 'Hello, World!'");
204 ///
205 /// // Preload the script
206 /// let sha = script.load(&client).await?;
207 /// println!("Script loaded with SHA: {}", sha);
208 /// # Ok(())
209 /// # }
210 /// ```
211 pub async fn load(&self, client: &crate::Client) -> RedisResult<String> {
212 client.script_load(&self.source).await
213 }
214}
215
216/// Script manager for caching and managing multiple scripts
217#[derive(Debug)]
218pub struct ScriptManager {
219 scripts: Arc<RwLock<HashMap<String, Script>>>,
220}
221
222impl ScriptManager {
223 /// Create a new script manager
224 #[must_use]
225 pub fn new() -> Self {
226 Self {
227 scripts: Arc::new(RwLock::new(HashMap::new())),
228 }
229 }
230
231 /// Register a script with the manager
232 ///
233 /// # Examples
234 ///
235 /// ```no_run
236 /// use redis_oxide::{Script, ScriptManager};
237 ///
238 /// # #[tokio::main]
239 /// # async fn main() {
240 /// let mut manager = ScriptManager::new();
241 /// let script = Script::new("return 'Hello'");
242 ///
243 /// manager.register("greeting", script).await;
244 /// # }
245 /// ```
246 pub async fn register(&self, name: impl Into<String>, script: Script) {
247 let mut scripts = self.scripts.write().await;
248 scripts.insert(name.into(), script);
249 }
250
251 /// Get a script by name
252 ///
253 /// # Examples
254 ///
255 /// ```
256 /// # use redis_oxide::{Script, ScriptManager};
257 /// # #[tokio::main]
258 /// # async fn main() {
259 /// let manager = ScriptManager::new();
260 /// let script = Script::new("return 'Hello'");
261 /// manager.register("greeting", script).await;
262 ///
263 /// if let Some(script) = manager.get("greeting").await {
264 /// println!("Found script with SHA: {}", script.sha());
265 /// }
266 /// # }
267 /// ```
268 pub async fn get(&self, name: &str) -> Option<Script> {
269 let scripts = self.scripts.read().await;
270 scripts.get(name).cloned()
271 }
272
273 /// Execute a script by name
274 ///
275 /// # Examples
276 ///
277 /// ```no_run
278 /// # use redis_oxide::{Client, ConnectionConfig, Script, ScriptManager};
279 /// # #[tokio::main]
280 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
281 /// # let config = ConnectionConfig::new("redis://localhost:6379");
282 /// # let client = Client::connect(config).await?;
283 /// let manager = ScriptManager::new();
284 /// let script = Script::new("return KEYS[1]");
285 /// manager.register("get_key", script).await;
286 ///
287 /// let result: String = manager.execute(
288 /// "get_key",
289 /// &client,
290 /// vec!["mykey".to_string()],
291 /// vec![]
292 /// ).await?;
293 /// # Ok(())
294 /// # }
295 /// ```
296 pub async fn execute<T>(
297 &self,
298 name: &str,
299 client: &crate::Client,
300 keys: Vec<String>,
301 args: Vec<String>,
302 ) -> RedisResult<T>
303 where
304 T: TryFrom<RespValue>,
305 T::Error: Into<RedisError>,
306 {
307 let script = self
308 .get(name)
309 .await
310 .ok_or_else(|| RedisError::Protocol(format!("Script '{}' not found", name)))?;
311
312 script.execute(client, keys, args).await
313 }
314
315 /// Load all registered scripts into Redis cache
316 ///
317 /// # Examples
318 ///
319 /// ```no_run
320 /// # use redis_oxide::{Client, ConnectionConfig, Script, ScriptManager};
321 /// # #[tokio::main]
322 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
323 /// # let config = ConnectionConfig::new("redis://localhost:6379");
324 /// # let client = Client::connect(config).await?;
325 /// let manager = ScriptManager::new();
326 ///
327 /// // Register some scripts
328 /// manager.register("script1", Script::new("return 1")).await;
329 /// manager.register("script2", Script::new("return 2")).await;
330 ///
331 /// // Load all scripts at once
332 /// let results = manager.load_all(&client).await?;
333 /// println!("Loaded {} scripts", results.len());
334 /// # Ok(())
335 /// # }
336 /// ```
337 pub async fn load_all(&self, client: &crate::Client) -> RedisResult<HashMap<String, String>> {
338 let scripts = self.scripts.read().await;
339 let mut results = HashMap::new();
340
341 for (name, script) in scripts.iter() {
342 let sha = script.load(client).await?;
343 results.insert(name.clone(), sha);
344 }
345
346 Ok(results)
347 }
348
349 /// Get the number of registered scripts
350 #[must_use]
351 pub async fn len(&self) -> usize {
352 let scripts = self.scripts.read().await;
353 scripts.len()
354 }
355
356 /// Check if the manager has any scripts
357 #[must_use]
358 pub async fn is_empty(&self) -> bool {
359 let scripts = self.scripts.read().await;
360 scripts.is_empty()
361 }
362
363 /// List all registered script names
364 pub async fn list_scripts(&self) -> Vec<String> {
365 let scripts = self.scripts.read().await;
366 scripts.keys().cloned().collect()
367 }
368
369 /// Remove a script from the manager
370 pub async fn remove(&self, name: &str) -> Option<Script> {
371 let mut scripts = self.scripts.write().await;
372 scripts.remove(name)
373 }
374
375 /// Clear all scripts from the manager
376 pub async fn clear(&self) {
377 let mut scripts = self.scripts.write().await;
378 scripts.clear();
379 }
380}
381
382impl Default for ScriptManager {
383 fn default() -> Self {
384 Self::new()
385 }
386}
387
388/// Calculate SHA1 hash of a string
389fn calculate_sha1(input: &str) -> String {
390 let mut hasher = Sha1::new();
391 hasher.update(input.as_bytes());
392 let result = hasher.finalize();
393 hex::encode(result)
394}
395
396/// Common Lua script patterns
397pub mod patterns {
398 use super::Script;
399
400 /// Atomic increment with expiration
401 ///
402 /// # Arguments
403 /// - KEYS[1]: The key to increment
404 /// - ARGV\[1\]: Increment amount
405 /// - ARGV\[2\]: Expiration time in seconds
406 pub fn atomic_increment_with_expiration() -> Script {
407 Script::new(
408 r"
409 local key = KEYS[1]
410 local increment = tonumber(ARGV[1])
411 local expiration = tonumber(ARGV[2])
412
413 local current = redis.call('GET', key)
414 local new_value
415
416 if current == false then
417 new_value = increment
418 else
419 new_value = tonumber(current) + increment
420 end
421
422 redis.call('SET', key, new_value)
423 redis.call('EXPIRE', key, expiration)
424
425 return new_value
426 ",
427 )
428 }
429
430 /// Conditional set (SET if value matches)
431 ///
432 /// # Arguments
433 /// - KEYS\[1\]: The key to set
434 /// - ARGV\[1\]: Expected current value
435 /// - ARGV\[2\]: New value to set
436 pub fn conditional_set() -> Script {
437 Script::new(
438 r"
439 local key = KEYS[1]
440 local expected = ARGV[1]
441 local new_value = ARGV[2]
442
443 local current = redis.call('GET', key)
444
445 if current == expected then
446 redis.call('SET', key, new_value)
447 return 1
448 else
449 return 0
450 end
451 ",
452 )
453 }
454
455 /// Rate limiting with sliding window
456 ///
457 /// # Arguments
458 /// - KEYS\[1\]: The rate limit key
459 /// - ARGV\[1\]: Window size in seconds
460 /// - ARGV\[2\]: Maximum requests per window
461 pub fn sliding_window_rate_limit() -> Script {
462 Script::new(
463 r#"
464 local key = KEYS[1]
465 local window = tonumber(ARGV[1])
466 local limit = tonumber(ARGV[2])
467 local now = redis.call('TIME')[1]
468
469 -- Remove old entries
470 redis.call('ZREMRANGEBYSCORE', key, 0, now - window)
471
472 -- Count current entries
473 local current = redis.call('ZCARD', key)
474
475 if current < limit then
476 -- Add current request
477 redis.call('ZADD', key, now, now)
478 redis.call('EXPIRE', key, window)
479 return { 1, limit - current - 1 }
480 else
481 return { 0, 0 }
482 end
483 "#,
484 )
485 }
486
487 /// Distributed lock with expiration
488 ///
489 /// # Arguments
490 /// - KEYS\[1\]: The lock key
491 /// - ARGV\[1\]: Lock identifier (unique per client)
492 /// - ARGV\[2\]: Lock expiration in seconds
493 pub fn distributed_lock() -> Script {
494 Script::new(
495 r#"
496 local key = KEYS[1]
497 local identifier = ARGV[1]
498 local expiration = tonumber(ARGV[2])
499
500 if redis.call('SET', key, identifier, 'NX', 'EX', expiration) then
501 return 1
502 else
503 return 0
504 end
505 "#,
506 )
507 }
508
509 /// Release distributed lock
510 ///
511 /// # Arguments
512 /// - KEYS\[1\]: The lock key
513 /// - ARGV\[1\]: Lock identifier (must match)
514 pub fn release_lock() -> Script {
515 Script::new(
516 r#"
517 local key = KEYS[1]
518 local identifier = ARGV[1]
519
520 if redis.call('GET', key) == identifier then
521 return redis.call('DEL', key)
522 else
523 return 0
524 end
525 "#,
526 )
527 }
528}
529
530#[cfg(test)]
531mod tests {
532 use super::*;
533
534 #[test]
535 fn test_script_creation() {
536 let script = Script::new("return 'hello'");
537 assert_eq!(script.source(), "return 'hello'");
538 assert!(!script.sha().is_empty());
539 assert_eq!(script.sha().len(), 40); // SHA1 is 40 hex characters
540 }
541
542 #[test]
543 fn test_sha1_calculation() {
544 let sha = calculate_sha1("hello world");
545 assert_eq!(sha, "2aae6c35c94fcfb415dbe95f408b9ce91ee846ed");
546 }
547
548 #[test]
549 fn test_script_sha_consistency() {
550 let script1 = Script::new("return 1");
551 let script2 = Script::new("return 1");
552 assert_eq!(script1.sha(), script2.sha());
553 }
554
555 #[test]
556 fn test_script_sha_uniqueness() {
557 let script1 = Script::new("return 1");
558 let script2 = Script::new("return 2");
559 assert_ne!(script1.sha(), script2.sha());
560 }
561
562 #[tokio::test]
563 async fn test_script_manager_creation() {
564 let manager = ScriptManager::new();
565 assert!(manager.is_empty().await);
566 assert_eq!(manager.len().await, 0);
567 }
568
569 #[tokio::test]
570 async fn test_script_manager_register_and_get() {
571 let manager = ScriptManager::new();
572 let script = Script::new("return 'test'");
573 let sha = script.sha().to_string();
574
575 manager.register("test_script", script).await;
576
577 assert!(!manager.is_empty().await);
578 assert_eq!(manager.len().await, 1);
579
580 let retrieved = manager.get("test_script").await.unwrap();
581 assert_eq!(retrieved.sha(), sha);
582 assert_eq!(retrieved.source(), "return 'test'");
583 }
584
585 #[tokio::test]
586 async fn test_script_manager_remove() {
587 let manager = ScriptManager::new();
588 let script = Script::new("return 'test'");
589
590 manager.register("test_script", script).await;
591 assert_eq!(manager.len().await, 1);
592
593 let removed = manager.remove("test_script").await;
594 assert!(removed.is_some());
595 assert_eq!(manager.len().await, 0);
596
597 let not_found = manager.remove("nonexistent").await;
598 assert!(not_found.is_none());
599 }
600
601 #[tokio::test]
602 async fn test_script_manager_clear() {
603 let manager = ScriptManager::new();
604
605 manager.register("script1", Script::new("return 1")).await;
606 manager.register("script2", Script::new("return 2")).await;
607 assert_eq!(manager.len().await, 2);
608
609 manager.clear().await;
610 assert_eq!(manager.len().await, 0);
611 assert!(manager.is_empty().await);
612 }
613
614 #[tokio::test]
615 async fn test_script_manager_list_scripts() {
616 let manager = ScriptManager::new();
617
618 manager
619 .register("script_a", Script::new("return 'a'"))
620 .await;
621 manager
622 .register("script_b", Script::new("return 'b'"))
623 .await;
624
625 let mut scripts = manager.list_scripts().await;
626 scripts.sort();
627
628 assert_eq!(scripts, vec!["script_a", "script_b"]);
629 }
630
631 #[test]
632 fn test_pattern_scripts() {
633 // Test that pattern scripts can be created without panicking
634 let _increment = patterns::atomic_increment_with_expiration();
635 let _conditional = patterns::conditional_set();
636 let _rate_limit = patterns::sliding_window_rate_limit();
637 let _lock = patterns::distributed_lock();
638 let _unlock = patterns::release_lock();
639 }
640}