Skip to content

Commit 8c98090

Browse files
authored
feat: Implement Lua Scripting and Functions Support (Issue #56) (#120)
Signed-off-by: Joe Brinkman <[email protected]> Signed-off-by: Joseph Brinkman <[email protected]>
1 parent 3c80ce8 commit 8c98090

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

43 files changed

+8165
-6
lines changed

.gitignore

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,3 +155,10 @@ $RECYCLE.BIN/
155155
_NCrunch*
156156

157157
glide-logs/
158+
159+
# Test results and reports
160+
reports/
161+
testresults/
162+
163+
# Temporary submodules (not for commit)
164+
StackExchange-Redis/

rust/src/lib.rs

Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -440,6 +440,241 @@ pub unsafe extern "C" fn init(level: Option<Level>, file_name: *const c_char) ->
440440
logger_level.into()
441441
}
442442

443+
#[repr(C)]
444+
pub struct ScriptHashBuffer {
445+
pub ptr: *mut u8,
446+
pub len: usize,
447+
pub capacity: usize,
448+
}
449+
450+
/// Store a Lua script in the script cache and return its SHA1 hash.
451+
///
452+
/// # Parameters
453+
///
454+
/// * `script_bytes`: Pointer to the script bytes.
455+
/// * `script_len`: Length of the script in bytes.
456+
///
457+
/// # Returns
458+
///
459+
/// A pointer to a `ScriptHashBuffer` containing the SHA1 hash of the script.
460+
/// The caller is responsible for freeing this memory using [`free_script_hash_buffer`].
461+
///
462+
/// # Safety
463+
///
464+
/// * `script_bytes` must point to `script_len` consecutive properly initialized bytes.
465+
/// * The returned buffer must be freed by the caller using [`free_script_hash_buffer`].
466+
#[unsafe(no_mangle)]
467+
pub unsafe extern "C" fn store_script(
468+
script_bytes: *const u8,
469+
script_len: usize,
470+
) -> *mut ScriptHashBuffer {
471+
let script = unsafe { std::slice::from_raw_parts(script_bytes, script_len) };
472+
let hash = glide_core::scripts_container::add_script(script);
473+
let mut hash = std::mem::ManuallyDrop::new(hash);
474+
let script_hash_buffer = ScriptHashBuffer {
475+
ptr: hash.as_mut_ptr(),
476+
len: hash.len(),
477+
capacity: hash.capacity(),
478+
};
479+
Box::into_raw(Box::new(script_hash_buffer))
480+
}
481+
482+
/// Free a `ScriptHashBuffer` obtained from [`store_script`].
483+
///
484+
/// # Parameters
485+
///
486+
/// * `buffer`: Pointer to the `ScriptHashBuffer`.
487+
///
488+
/// # Safety
489+
///
490+
/// * `buffer` must be a pointer returned from [`store_script`].
491+
/// * This function must be called exactly once per buffer.
492+
#[unsafe(no_mangle)]
493+
pub unsafe extern "C" fn free_script_hash_buffer(buffer: *mut ScriptHashBuffer) {
494+
if buffer.is_null() {
495+
return;
496+
}
497+
let buffer = unsafe { Box::from_raw(buffer) };
498+
let _hash = unsafe { String::from_raw_parts(buffer.ptr, buffer.len, buffer.capacity) };
499+
}
500+
501+
/// Remove a script from the script cache.
502+
///
503+
/// Returns a null pointer if it succeeds and a C string error message if it fails.
504+
///
505+
/// # Parameters
506+
///
507+
/// * `hash`: The SHA1 hash of the script to remove as a byte array.
508+
/// * `len`: The length of `hash`.
509+
///
510+
/// # Returns
511+
///
512+
/// A null pointer on success, or a pointer to a C string error message on failure.
513+
/// The caller is responsible for freeing the error message using [`free_drop_script_error`].
514+
///
515+
/// # Safety
516+
///
517+
/// * `hash` must be a valid pointer to a UTF-8 string.
518+
/// * The returned error pointer (if not null) must be freed using [`free_drop_script_error`].
519+
#[unsafe(no_mangle)]
520+
pub unsafe extern "C" fn drop_script(hash: *mut u8, len: usize) -> *mut c_char {
521+
if hash.is_null() {
522+
return CString::new("Hash pointer was null.").unwrap().into_raw();
523+
}
524+
525+
let slice = std::ptr::slice_from_raw_parts_mut(hash, len);
526+
let Ok(hash_str) = std::str::from_utf8(unsafe { &*slice }) else {
527+
return CString::new("Unable to convert hash to UTF-8 string.")
528+
.unwrap()
529+
.into_raw();
530+
};
531+
532+
glide_core::scripts_container::remove_script(hash_str);
533+
std::ptr::null_mut()
534+
}
535+
536+
/// Free an error message from a failed drop_script call.
537+
///
538+
/// # Parameters
539+
///
540+
/// * `error`: The error to free.
541+
///
542+
/// # Safety
543+
///
544+
/// * `error` must be an error returned by [`drop_script`].
545+
/// * This function must be called exactly once per error.
546+
#[unsafe(no_mangle)]
547+
pub unsafe extern "C" fn free_drop_script_error(error: *mut c_char) {
548+
if !error.is_null() {
549+
_ = unsafe { CString::from_raw(error) };
550+
}
551+
}
552+
553+
/// Executes a Lua script using EVALSHA with automatic fallback to EVAL.
554+
///
555+
/// # Parameters
556+
///
557+
/// * `client_ptr`: Pointer to a valid `GlideClient` returned from [`create_client`].
558+
/// * `callback_index`: Unique identifier for the callback.
559+
/// * `hash`: SHA1 hash of the script as a null-terminated C string.
560+
/// * `keys_count`: Number of keys in the keys array.
561+
/// * `keys`: Array of pointers to key data.
562+
/// * `keys_len`: Array of key lengths.
563+
/// * `args_count`: Number of arguments in the args array.
564+
/// * `args`: Array of pointers to argument data.
565+
/// * `args_len`: Array of argument lengths.
566+
/// * `route_bytes`: Optional routing information (not used, reserved for future).
567+
/// * `route_bytes_len`: Length of route_bytes.
568+
///
569+
/// # Safety
570+
///
571+
/// * `client_ptr` must not be `null` and must be obtained from [`create_client`].
572+
/// * `hash` must be a valid null-terminated C string.
573+
/// * `keys` and `keys_len` must be valid arrays of size `keys_count`, or both null if `keys_count` is 0.
574+
/// * `args` and `args_len` must be valid arrays of size `args_count`, or both null if `args_count` is 0.
575+
#[unsafe(no_mangle)]
576+
pub unsafe extern "C-unwind" fn invoke_script(
577+
client_ptr: *const c_void,
578+
callback_index: usize,
579+
hash: *const c_char,
580+
keys_count: usize,
581+
keys: *const usize,
582+
keys_len: *const usize,
583+
args_count: usize,
584+
args: *const usize,
585+
args_len: *const usize,
586+
_route_bytes: *const u8,
587+
_route_bytes_len: usize,
588+
) {
589+
let client = unsafe {
590+
Arc::increment_strong_count(client_ptr);
591+
Arc::from_raw(client_ptr as *mut Client)
592+
};
593+
let core = client.core.clone();
594+
595+
let mut panic_guard = PanicGuard {
596+
panicked: true,
597+
failure_callback: core.failure_callback,
598+
callback_index,
599+
};
600+
601+
// Convert hash to Rust string
602+
let hash_str = match unsafe { CStr::from_ptr(hash).to_str() } {
603+
Ok(s) => s.to_string(),
604+
Err(e) => {
605+
unsafe {
606+
report_error(
607+
core.failure_callback,
608+
callback_index,
609+
format!("Invalid hash string: {}", e),
610+
RequestErrorType::Unspecified,
611+
);
612+
}
613+
return;
614+
}
615+
};
616+
617+
// Convert keys
618+
let keys_vec: Vec<&[u8]> = if !keys.is_null() && !keys_len.is_null() && keys_count > 0 {
619+
unsafe {
620+
ffi::convert_string_pointer_array_to_vector(
621+
keys as *const *const u8,
622+
keys_count,
623+
keys_len,
624+
)
625+
}
626+
} else {
627+
Vec::new()
628+
};
629+
630+
// Convert args
631+
let args_vec: Vec<&[u8]> = if !args.is_null() && !args_len.is_null() && args_count > 0 {
632+
unsafe {
633+
ffi::convert_string_pointer_array_to_vector(
634+
args as *const *const u8,
635+
args_count,
636+
args_len,
637+
)
638+
}
639+
} else {
640+
Vec::new()
641+
};
642+
643+
client.runtime.spawn(async move {
644+
let mut panic_guard = PanicGuard {
645+
panicked: true,
646+
failure_callback: core.failure_callback,
647+
callback_index,
648+
};
649+
650+
let result = core
651+
.client
652+
.clone()
653+
.invoke_script(&hash_str, &keys_vec, &args_vec, None)
654+
.await;
655+
656+
match result {
657+
Ok(value) => {
658+
let ptr = Box::into_raw(Box::new(ResponseValue::from_value(value)));
659+
unsafe { (core.success_callback)(callback_index, ptr) };
660+
}
661+
Err(err) => unsafe {
662+
report_error(
663+
core.failure_callback,
664+
callback_index,
665+
error_message(&err),
666+
error_type(&err),
667+
);
668+
},
669+
};
670+
panic_guard.panicked = false;
671+
drop(panic_guard);
672+
});
673+
674+
panic_guard.panicked = false;
675+
drop(panic_guard);
676+
}
677+
443678
/// Execute a cluster scan request.
444679
///
445680
/// # Safety

sources/Valkey.Glide/Abstract/IDatabase.cs

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,4 +29,48 @@ public interface IDatabase : IDatabaseAsync
2929
/// <param name="asyncState">The async state is not supported by GLIDE.</param>
3030
/// <returns>The created transaction.</returns>
3131
ITransaction CreateTransaction(object? asyncState = null);
32+
33+
// ===== StackExchange.Redis Compatibility Methods (Synchronous) =====
34+
35+
/// <summary>
36+
/// Evaluates a Lua script on the server (StackExchange.Redis compatibility).
37+
/// </summary>
38+
/// <param name="script">The Lua script to evaluate.</param>
39+
/// <param name="keys">The keys to pass to the script (KEYS array).</param>
40+
/// <param name="values">The values to pass to the script (ARGV array).</param>
41+
/// <param name="flags">Command flags (currently not supported by GLIDE).</param>
42+
/// <returns>The result of the script execution.</returns>
43+
ValkeyResult ScriptEvaluate(string script, ValkeyKey[]? keys = null, ValkeyValue[]? values = null,
44+
CommandFlags flags = CommandFlags.None);
45+
46+
/// <summary>
47+
/// Evaluates a pre-loaded Lua script on the server using its SHA1 hash (StackExchange.Redis compatibility).
48+
/// </summary>
49+
/// <param name="hash">The SHA1 hash of the script to evaluate.</param>
50+
/// <param name="keys">The keys to pass to the script (KEYS array).</param>
51+
/// <param name="values">The values to pass to the script (ARGV array).</param>
52+
/// <param name="flags">Command flags (currently not supported by GLIDE).</param>
53+
/// <returns>The result of the script execution.</returns>
54+
ValkeyResult ScriptEvaluate(byte[] hash, ValkeyKey[]? keys = null, ValkeyValue[]? values = null,
55+
CommandFlags flags = CommandFlags.None);
56+
57+
/// <summary>
58+
/// Evaluates a LuaScript with named parameter support (StackExchange.Redis compatibility).
59+
/// </summary>
60+
/// <param name="script">The LuaScript to evaluate.</param>
61+
/// <param name="parameters">An object containing parameter values.</param>
62+
/// <param name="flags">Command flags (currently not supported by GLIDE).</param>
63+
/// <returns>The result of the script execution.</returns>
64+
ValkeyResult ScriptEvaluate(LuaScript script, object? parameters = null,
65+
CommandFlags flags = CommandFlags.None);
66+
67+
/// <summary>
68+
/// Evaluates a pre-loaded LuaScript using EVALSHA (StackExchange.Redis compatibility).
69+
/// </summary>
70+
/// <param name="script">The LoadedLuaScript to evaluate.</param>
71+
/// <param name="parameters">An object containing parameter values.</param>
72+
/// <param name="flags">Command flags (currently not supported by GLIDE).</param>
73+
/// <returns>The result of the script execution.</returns>
74+
ValkeyResult ScriptEvaluate(LoadedLuaScript script, object? parameters = null,
75+
CommandFlags flags = CommandFlags.None);
3276
}

sources/Valkey.Glide/Abstract/IDatabaseAsync.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ namespace Valkey.Glide;
88
/// Describes functionality that is common to both standalone and cluster servers.<br />
99
/// See also <see cref="GlideClient" /> and <see cref="GlideClusterClient" />.
1010
/// </summary>
11-
public interface IDatabaseAsync : IConnectionManagementCommands, IGenericCommands, IGenericBaseCommands, IHashCommands, IHyperLogLogCommands, IListCommands, IServerManagementCommands, ISetCommands, ISortedSetCommands, IStringCommands
11+
public interface IDatabaseAsync : IConnectionManagementCommands, IGenericCommands, IGenericBaseCommands, IHashCommands, IHyperLogLogCommands, IListCommands, IScriptingAndFunctionBaseCommands, IServerManagementCommands, ISetCommands, ISortedSetCommands, IStringCommands
1212
{
1313
/// <summary>
1414
/// Execute an arbitrary command against the server; this is primarily intended for executing modules,

sources/Valkey.Glide/Abstract/IServer.cs

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -249,4 +249,55 @@ public interface IServer
249249
/// </example>
250250
/// </remarks>
251251
Task<long> ClientIdAsync(CommandFlags flags = CommandFlags.None);
252-
}
252+
253+
/// <summary>
254+
/// Checks if a script exists in the server's script cache.
255+
/// </summary>
256+
/// <param name="script">The Lua script to check.</param>
257+
/// <param name="flags">Command flags (currently not supported by GLIDE).</param>
258+
/// <returns>A task representing the asynchronous operation, containing true if the script exists in the cache, false otherwise.</returns>
259+
/// <remarks>
260+
/// This method calculates the SHA1 hash of the script and checks if it exists in the server's cache.
261+
/// </remarks>
262+
Task<bool> ScriptExistsAsync(string script, CommandFlags flags = CommandFlags.None);
263+
264+
/// <summary>
265+
/// Checks if a script exists in the server's script cache by its SHA1 hash.
266+
/// </summary>
267+
/// <param name="sha1">The SHA1 hash of the script to check.</param>
268+
/// <param name="flags">Command flags (currently not supported by GLIDE).</param>
269+
/// <returns>A task representing the asynchronous operation, containing true if the script exists in the cache, false otherwise.</returns>
270+
Task<bool> ScriptExistsAsync(byte[] sha1, CommandFlags flags = CommandFlags.None);
271+
272+
/// <summary>
273+
/// Loads a Lua script onto the server and returns its SHA1 hash.
274+
/// </summary>
275+
/// <param name="script">The Lua script to load.</param>
276+
/// <param name="flags">Command flags (currently not supported by GLIDE).</param>
277+
/// <returns>A task representing the asynchronous operation, containing the SHA1 hash of the loaded script.</returns>
278+
/// <remarks>
279+
/// The script is cached on the server and can be executed using EVALSHA with the returned hash.
280+
/// </remarks>
281+
Task<byte[]> ScriptLoadAsync(string script, CommandFlags flags = CommandFlags.None);
282+
283+
/// <summary>
284+
/// Loads a LuaScript onto the server and returns a LoadedLuaScript.
285+
/// </summary>
286+
/// <param name="script">The LuaScript to load.</param>
287+
/// <param name="flags">Command flags (currently not supported by GLIDE).</param>
288+
/// <returns>A task representing the asynchronous operation, containing a LoadedLuaScript instance.</returns>
289+
/// <remarks>
290+
/// The script is cached on the server and can be executed using the returned LoadedLuaScript.
291+
/// </remarks>
292+
Task<LoadedLuaScript> ScriptLoadAsync(LuaScript script, CommandFlags flags = CommandFlags.None);
293+
294+
/// <summary>
295+
/// Removes all scripts from the server's script cache.
296+
/// </summary>
297+
/// <param name="flags">Command flags (currently not supported by GLIDE).</param>
298+
/// <returns>A task representing the asynchronous operation.</returns>
299+
/// <remarks>
300+
/// After calling this method, all scripts must be reloaded before they can be executed with EVALSHA.
301+
/// </remarks>
302+
Task ScriptFlushAsync(CommandFlags flags = CommandFlags.None);
303+
} ///

0 commit comments

Comments
 (0)