Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@ All notable changes to the Valkey GLIDE C# client will be documented in this fil
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html)

## 1.2.0

### Added

- Custom socket address resolution support via callback (#392)

## 1.1.0

### Added
Expand Down
13 changes: 6 additions & 7 deletions Taskfile.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,6 @@ vars:
TARGET: '{{.target | default "all"}}'
BUILD_TARGET: '{{if eq .TARGET "lib"}}sources/Valkey.Glide/{{end}}'

# Filter tests by class or method name.
# Usage: `task test:unit filter=MyTestClass`
FILTER: '{{.filter | default ""}}'
TEST_FILTER: "{{if .FILTER}}--filter FullyQualifiedName~{{.FILTER}}{{end}}"

tasks:
clean:
desc: Clean test results and reports directories
Expand All @@ -36,20 +31,24 @@ tasks:
test:unit:
desc: Run unit tests only
deps: [clean]
vars:
FILTER: '{{.filter | default ""}}'
cmds:
- dotnet test {{.UNIT_TEST_PROJECT}}
--configuration Release
--verbosity normal
{{.TEST_FILTER}}
{{if .FILTER}}--filter FullyQualifiedName~{{.FILTER}}{{end}}

test:integration:
desc: Run integration tests only
deps: [clean]
vars:
FILTER: '{{.filter | default ""}}'
cmds:
- dotnet test {{.INTEGRATION_TEST_PROJECT}}
--configuration Release
--verbosity normal
{{.TEST_FILTER}}
{{if .FILTER}}--filter FullyQualifiedName~{{.FILTER}}{{end}}

test:coverage:unit:
desc: Run unit tests with coverage collection
Expand Down
1 change: 1 addition & 0 deletions rust/src/ffi.rs
Original file line number Diff line number Diff line change
Expand Up @@ -358,6 +358,7 @@ pub(crate) unsafe fn create_connection_request(
periodic_checks: None,
inflight_requests_limit: None,
node_discovery_mode: glide_core::client::NodeDiscoveryMode::default(),
address_resolver: None,
})
}

Expand Down
84 changes: 83 additions & 1 deletion rust/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,81 @@ pub type FailureCallback = unsafe extern "C-unwind" fn(
error_type: RequestErrorType,
) -> ();

/// Callback for resolving server addresses before connection.
///
/// Invoked synchronously during connection setup to translate a configured (host, port) pair
/// into the actual address to connect to. The callback writes the resolved hostname into the
/// provided buffer and returns the resolved port.
///
/// # Arguments
/// * `host` is a pointer to the UTF-8 encoded hostname to resolve.
/// * `host_len` is the length of `host` in bytes.
/// * `port` is the configured port number.
/// * `resolved_host_buf` is a caller-allocated buffer to write the resolved hostname into.
/// * `resolved_host_buf_len` is the size of `resolved_host_buf` in bytes.
/// * `resolved_host_len` is an out-parameter set to the number of bytes written to `resolved_host_buf`.
///
/// # Returns
/// The resolved port number, or `0` to signal an error (in which case the original address is used).
///
/// # Safety
/// * `host` must be a valid pointer to `host_len` bytes of UTF-8 data.
/// * `resolved_host_buf` must be a valid pointer to a writable buffer of at least `resolved_host_buf_len` bytes.
/// * `resolved_host_len` must be a valid pointer to a writable `usize`.
/// * The callback must not store any of the provided pointers beyond the duration of the call.
pub type AddressResolverCallback = unsafe extern "C-unwind" fn(
host: *const u8,
host_len: usize,
port: u16,
resolved_host_buf: *mut u8,
resolved_host_buf_len: usize,
resolved_host_len: *mut usize,
) -> u16;

/// Maximum resolved hostname length in bytes.
const MAX_RESOLVED_HOST_LEN: usize = 1024;

/// Implements the [`redis::AddressResolver`] trait.
struct FFIAddressResolver {
callback: AddressResolverCallback,
}

unsafe impl Send for FFIAddressResolver {}
unsafe impl Sync for FFIAddressResolver {}

impl std::fmt::Debug for FFIAddressResolver {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "FFIAddressResolver")
}
}

impl redis::AddressResolver for FFIAddressResolver {
/// Resolves the given host and port by invoking the C# callback.
///
/// On error, falls back to the original `(host, port)` unchanged.
fn resolve(&self, host: &str, port: u16) -> (String, u16) {
let mut buf = [0u8; MAX_RESOLVED_HOST_LEN];
let mut len: usize = 0;
let resolved_port = unsafe {
(self.callback)(
host.as_ptr(),
host.len(),
port,
buf.as_mut_ptr(),
buf.len(),
&mut len,
)
};
if resolved_port == 0 || len == 0 || len > buf.len() {
return (host.to_string(), port);
}
match std::str::from_utf8(&buf[..len]) {
Ok(h) => (h.to_string(), resolved_port),
Err(_) => (host.to_string(), port),
}
Comment thread
currantw marked this conversation as resolved.
}
}

struct CommandExecutionCore {
client: GlideClient,
success_callback: SuccessCallback,
Expand Down Expand Up @@ -153,21 +228,23 @@ impl Drop for PanicGuard {
/// See the safety documentation of [`SuccessCallback`] and [`FailureCallback`].
/// * `pubsub_callback` is an optional callback. When provided, it must be a valid function pointer.
/// See the safety documentation in the FFI module for PubSubCallback.
/// * `address_resolver` is an optional callback. When provided, it must be a valid function pointer.
#[allow(rustdoc::private_intra_doc_links)]
#[unsafe(no_mangle)]
pub unsafe extern "C-unwind" fn create_client(
config: *const ConnectionConfig,
success_callback: SuccessCallback,
failure_callback: FailureCallback,
#[allow(unused_variables)] pubsub_callback: Option<PubSubCallback>,
address_resolver: Option<AddressResolverCallback>,
) {
let mut panic_guard = PanicGuard {
panicked: true,
failure_callback,
callback_index: 0,
};

let request = match unsafe { create_connection_request(config) } {
let mut request = match unsafe { create_connection_request(config) } {
Ok(req) => req,
Err(err) => {
panic_guard.panicked = false;
Expand All @@ -178,6 +255,11 @@ pub unsafe extern "C-unwind" fn create_client(
}
};

// Set address resolver if provided
if let Some(cb) = address_resolver {
request.address_resolver = Some(std::sync::Arc::new(FFIAddressResolver { callback: cb }));
}

let runtime = Builder::new_multi_thread()
.enable_all()
.worker_threads(10)
Expand Down
61 changes: 60 additions & 1 deletion sources/Valkey.Glide/Client/BaseClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,57 @@ protected static async Task<T> CreateClient<T>(BaseClientConfiguration config, F
}

Message message = client.MessageContainer.GetMessageForCall();
CreateClientFfi(request.ToPtr(), successCallbackPointer, failureCallbackPointer, pubsubCallbackPointer);

IntPtr addressResolverPointer = IntPtr.Zero;
if (config.Request.AddressResolver != null)
{
var addressResolver = config.Request.AddressResolver;

client._addressResolverDelegate = (hostPtr, hostLen, port, bufPtr, bufLen, outLen) =>
Comment thread
currantw marked this conversation as resolved.
{
try
{
string host = Marshal.PtrToStringUTF8(hostPtr, (int)hostLen);
var (resolvedHost, resolvedPort) = addressResolver(host, port);

if (string.IsNullOrEmpty(resolvedHost))
{
var msg = $"Address resolver returned an empty or null host for {host}:{port}";
Logger.Log(Level.Error, "AddressResolver", msg);
return 0;
}

byte[] bytes = System.Text.Encoding.UTF8.GetBytes(resolvedHost);
if (bytes.Length > (int)bufLen)
{
var msg = $"Resolved host length ({bytes.Length} bytes) exceeds maximum length ({bufLen} bytes) for {host}:{port}";
Logger.Log(Level.Error, "AddressResolver", msg);
return 0;
}

if (resolvedPort == 0)
{
var msg = $"Address resolver returned an invalid port ({resolvedPort}) for {host}:{port}";
Logger.Log(Level.Error, "AddressResolver", msg);
return 0;
}

Marshal.Copy(bytes, 0, bufPtr, bytes.Length);
unsafe { *(UIntPtr*)outLen = (UIntPtr)bytes.Length; }

return resolvedPort;
}
catch (Exception ex)
{
var msg = $"Address resolver callback threw an exception: {ex.Message}";
Logger.Log(Level.Error, "AddressResolver", msg, ex);
return 0;
}
};
addressResolverPointer = Marshal.GetFunctionPointerForDelegate(client._addressResolverDelegate);
}

CreateClientFfi(request.ToPtr(), successCallbackPointer, failureCallbackPointer, pubsubCallbackPointer, addressResolverPointer);
client.ClientPointer = await message; // This will throw an error thru failure callback if any

if (client.ClientPointer == IntPtr.Zero)
Expand Down Expand Up @@ -626,6 +676,12 @@ private delegate void PubSubAction(
ulong channelLen,
IntPtr patternPtr,
ulong patternLen);

[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
private delegate ushort AddressResolverAction(
IntPtr host, UIntPtr hostLen, ushort port,
IntPtr resolvedHostBuf, UIntPtr resolvedHostBufLen,
UIntPtr resolvedHostLen);
#endregion private methods

#region private fields
Expand All @@ -642,6 +698,9 @@ private delegate void PubSubAction(
/// and held in order to prevent the cost of marshalling on each function call.
private readonly PubSubAction _pubsubCallbackDelegate;

/// Held to prevent the delegate being garbage collected.
private AddressResolverAction? _addressResolverDelegate;

private readonly object _lock = new();
private string _clientInfo = ""; // used to distinguish and identify clients during tests

Expand Down
41 changes: 40 additions & 1 deletion sources/Valkey.Glide/ConnectionConfiguration.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,24 @@ public abstract class ConnectionConfiguration
/// </summary>
public static readonly long CertificateMaxSize = 10 * 1024 * 1024; // 10 MB

/// <summary>
/// A callback for resolving server addresses before connection.
/// </summary>
/// <param name="host">The configured host name or IP address.</param>
/// <param name="port">The configured port number.</param>
/// <returns>The resolved (host, port) to use for the actual connection.</returns>
/// <remarks>
/// The resolver must be thread-safe and should avoid blocking operations, as it is
/// called synchronously during the connection process.
/// <para/>
/// If the resolver throws an exception, the client falls back to the original
/// (unresolved) address and logs the exception at <see cref="Level.Error"/>.
/// <para/>
/// The resolver is invoked once per address at initial connection time. It is not
/// invoked on subsequent reconnection attempts.
/// </remarks>
public delegate (string host, ushort port) AddressResolverDelegate(string host, ushort port);

#region Structs and Enums definitions

internal record ConnectionConfig
Expand All @@ -43,6 +61,7 @@ internal record ConnectionConfig
public CompressionConfig? CompressionConfig;
public bool ReadOnly;
public ClientSideCacheConfig? ClientSideCacheConfig;
public AddressResolverDelegate? AddressResolver;

internal FFI.ConnectionConfig ToFfi() =>
new(
Expand Down Expand Up @@ -852,7 +871,6 @@ public T WithCompression(CompressionConfig compressionConfig)
}

#endregion

#region Client-Side Cache

/// <summary>
Expand All @@ -875,6 +893,27 @@ public T WithClientSideCache(ClientSideCacheConfig clientSideCacheConfig)
return (T)this;
}

#endregion
#region Address Resolver

/// <summary>
/// Optional callback for resolving server addresses before connection.
/// When set, the callback is invoked for each server address and can return a different host/port.
/// </summary>
/// <seealso cref="AddressResolverDelegate"/>
public AddressResolverDelegate? AddressResolver
{
get => Config.AddressResolver;
set => Config.AddressResolver = value;
}

/// <inheritdoc cref="AddressResolver" />
public T WithAddressResolver(AddressResolverDelegate addressResolver)
{
AddressResolver = addressResolver;
return (T)this;
}
Comment thread
currantw marked this conversation as resolved.

#endregion

internal ConnectionConfig Build() => Config;
Expand Down
4 changes: 2 additions & 2 deletions sources/Valkey.Glide/Internals/FFI.methods.cs
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ internal delegate void PubSubMessageCallback(

[LibraryImport("libglide_rs", EntryPoint = "create_client")]
[UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])]
public static partial void CreateClientFfi(IntPtr config, IntPtr successCallback, IntPtr failureCallback, IntPtr pubsubCallback);
public static partial void CreateClientFfi(IntPtr config, IntPtr successCallback, IntPtr failureCallback, IntPtr pubsubCallback, IntPtr addressResolverCallback);

[LibraryImport("libglide_rs", EntryPoint = "close_client")]
[UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])]
Expand Down Expand Up @@ -135,7 +135,7 @@ public static partial void ScriptInvokeFfi(
public static extern void FreeString(IntPtr strPtr);

[DllImport("libglide_rs", CallingConvention = CallingConvention.Cdecl, EntryPoint = "create_client")]
public static extern void CreateClientFfi(IntPtr config, IntPtr successCallback, IntPtr failureCallback, IntPtr pubsubCallback);
public static extern void CreateClientFfi(IntPtr config, IntPtr successCallback, IntPtr failureCallback, IntPtr pubsubCallback, IntPtr addressResolverCallback);

[DllImport("libglide_rs", CallingConvention = CallingConvention.Cdecl, EntryPoint = "close_client")]
public static extern void CloseClientFfi(IntPtr client);
Expand Down
Loading
Loading