diff --git a/CHANGELOG.md b/CHANGELOG.md index 8bf2bd8c..2d9c9c23 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/Taskfile.yml b/Taskfile.yml index a4b825b9..c4a2aa4d 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -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 @@ -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 diff --git a/rust/src/ffi.rs b/rust/src/ffi.rs index a070fef7..d2005677 100644 --- a/rust/src/ffi.rs +++ b/rust/src/ffi.rs @@ -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, }) } diff --git a/rust/src/lib.rs b/rust/src/lib.rs index 4e9f1f6f..a5222207 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -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), + } + } +} + struct CommandExecutionCore { client: GlideClient, success_callback: SuccessCallback, @@ -153,6 +228,7 @@ 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( @@ -160,6 +236,7 @@ pub unsafe extern "C-unwind" fn create_client( success_callback: SuccessCallback, failure_callback: FailureCallback, #[allow(unused_variables)] pubsub_callback: Option, + address_resolver: Option, ) { let mut panic_guard = PanicGuard { panicked: true, @@ -167,7 +244,7 @@ pub unsafe extern "C-unwind" fn create_client( 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; @@ -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) diff --git a/sources/Valkey.Glide/Client/BaseClient.cs b/sources/Valkey.Glide/Client/BaseClient.cs index 75bb6e80..62124d35 100644 --- a/sources/Valkey.Glide/Client/BaseClient.cs +++ b/sources/Valkey.Glide/Client/BaseClient.cs @@ -201,7 +201,57 @@ protected static async Task CreateClient(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) => + { + 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) @@ -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 @@ -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 diff --git a/sources/Valkey.Glide/ConnectionConfiguration.cs b/sources/Valkey.Glide/ConnectionConfiguration.cs index 1913f309..6fd660c2 100644 --- a/sources/Valkey.Glide/ConnectionConfiguration.cs +++ b/sources/Valkey.Glide/ConnectionConfiguration.cs @@ -20,6 +20,24 @@ public abstract class ConnectionConfiguration /// public static readonly long CertificateMaxSize = 10 * 1024 * 1024; // 10 MB + /// + /// A callback for resolving server addresses before connection. + /// + /// The configured host name or IP address. + /// The configured port number. + /// The resolved (host, port) to use for the actual connection. + /// + /// The resolver must be thread-safe and should avoid blocking operations, as it is + /// called synchronously during the connection process. + /// + /// If the resolver throws an exception, the client falls back to the original + /// (unresolved) address and logs the exception at . + /// + /// The resolver is invoked once per address at initial connection time. It is not + /// invoked on subsequent reconnection attempts. + /// + public delegate (string host, ushort port) AddressResolverDelegate(string host, ushort port); + #region Structs and Enums definitions internal record ConnectionConfig @@ -43,6 +61,7 @@ internal record ConnectionConfig public CompressionConfig? CompressionConfig; public bool ReadOnly; public ClientSideCacheConfig? ClientSideCacheConfig; + public AddressResolverDelegate? AddressResolver; internal FFI.ConnectionConfig ToFfi() => new( @@ -852,7 +871,6 @@ public T WithCompression(CompressionConfig compressionConfig) } #endregion - #region Client-Side Cache /// @@ -875,6 +893,27 @@ public T WithClientSideCache(ClientSideCacheConfig clientSideCacheConfig) return (T)this; } + #endregion + #region Address Resolver + + /// + /// 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. + /// + /// + public AddressResolverDelegate? AddressResolver + { + get => Config.AddressResolver; + set => Config.AddressResolver = value; + } + + /// + public T WithAddressResolver(AddressResolverDelegate addressResolver) + { + AddressResolver = addressResolver; + return (T)this; + } + #endregion internal ConnectionConfig Build() => Config; diff --git a/sources/Valkey.Glide/Internals/FFI.methods.cs b/sources/Valkey.Glide/Internals/FFI.methods.cs index aebf0aac..c4bb61f1 100644 --- a/sources/Valkey.Glide/Internals/FFI.methods.cs +++ b/sources/Valkey.Glide/Internals/FFI.methods.cs @@ -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)])] @@ -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); diff --git a/tests/Valkey.Glide.IntegrationTests/AddressResolverTests.cs b/tests/Valkey.Glide.IntegrationTests/AddressResolverTests.cs new file mode 100644 index 00000000..1d150c0a --- /dev/null +++ b/tests/Valkey.Glide.IntegrationTests/AddressResolverTests.cs @@ -0,0 +1,83 @@ +// Copyright Valkey GLIDE Project Contributors - SPDX Identifier: Apache-2.0 + +using Valkey.Glide.TestUtils; + +using static Valkey.Glide.TestUtils.Client; +using static Valkey.Glide.TestUtils.Config; +using static Valkey.Glide.TestUtils.Data; + +namespace Valkey.Glide.IntegrationTests; + +/// +/// Integration tests for the address resolver callback. +/// +public class AddressResolverTests +{ + [Theory] + [MemberData(nameof(ClusterMode), MemberType = typeof(Data))] + public async Task AddressResolver_IdentityResolver_IsInvokedAndConnects(bool useCluster) + { + var address = useCluster ? TestConfiguration.CLUSTER_ADDRESS : TestConfiguration.STANDALONE_ADDRESS; + + int count = 0; + (string, ushort) Resolver(string host, ushort port) + { + _ = Interlocked.Increment(ref count); + return (address.Host, address.Port); + } + + var config = BuildConfig(useCluster, address, addressResolver: Resolver); + await using var client = await CreateClient(config); + + await AssertConnected(client); + Assert.True(count > 0); + } + + [Theory] + [MemberData(nameof(ClusterMode), MemberType = typeof(Data))] + public async Task AddressResolver_ThrowsException_FallsBackToOriginalAddress(bool useCluster) + { + var address = useCluster ? TestConfiguration.CLUSTER_ADDRESS : TestConfiguration.STANDALONE_ADDRESS; + + var config = BuildConfig(useCluster, address, addressResolver: (_, _) => throw new InvalidOperationException("Resolution failed!")); + await using var client = await CreateClient(config); + + await AssertConnected(client); + } + + [Theory] + [MemberData(nameof(ClusterMode), MemberType = typeof(Data))] + public async Task AddressResolver_ReturnsEmptyHost_FallsBackToOriginalAddress(bool useCluster) + { + var address = useCluster ? TestConfiguration.CLUSTER_ADDRESS : TestConfiguration.STANDALONE_ADDRESS; + + var config = BuildConfig(useCluster, address, addressResolver: (_, _) => ("", address.Port)); + await using var client = await CreateClient(config); + + await AssertConnected(client); + } + + [Theory] + [MemberData(nameof(ClusterMode), MemberType = typeof(Data))] + public async Task AddressResolver_ReturnsPortZero_FallsBackToOriginalAddress(bool useCluster) + { + var address = useCluster ? TestConfiguration.CLUSTER_ADDRESS : TestConfiguration.STANDALONE_ADDRESS; + + var config = BuildConfig(useCluster, address, addressResolver: (_, _) => (address.Host, 0)); + await using var client = await CreateClient(config); + + await AssertConnected(client); + } + + [Theory] + [MemberData(nameof(ClusterMode), MemberType = typeof(Data))] + public async Task AddressResolver_ReturnsOversizedHost_FallsBackToOriginalAddress(bool useCluster) + { + var address = useCluster ? TestConfiguration.CLUSTER_ADDRESS : TestConfiguration.STANDALONE_ADDRESS; + + var config = BuildConfig(useCluster, address, addressResolver: (_, _) => (new string('a', 2048), address.Port)); + await using var client = await CreateClient(config); + + await AssertConnected(client); + } +} diff --git a/tests/Valkey.Glide.IntegrationTests/ClusterClientTests.cs b/tests/Valkey.Glide.IntegrationTests/ClusterClientTests.cs index d1f60d44..2fdc9aa9 100644 --- a/tests/Valkey.Glide.IntegrationTests/ClusterClientTests.cs +++ b/tests/Valkey.Glide.IntegrationTests/ClusterClientTests.cs @@ -627,11 +627,10 @@ public async Task EagerConnect() public async Task Connect_WithIpAddress_Succeeds(string address) { using var server = new ClusterServer(useTls: false); - var port = server.Addresses.First().Port; - var configBuilder = new ConnectionConfiguration.ClusterClientConfigurationBuilder() - .WithAddress(address, port); + var builder = new ConnectionConfiguration.ClusterClientConfigurationBuilder() + .WithAddress(address, server.Address.Port); - await using var client = await GlideClusterClient.CreateClient(configBuilder.Build()); + await using var client = await GlideClusterClient.CreateClient(builder.Build()); await Client.AssertConnected(client); } } diff --git a/tests/Valkey.Glide.IntegrationTests/DnsTests.cs b/tests/Valkey.Glide.IntegrationTests/DnsTests.cs index 7b4cb761..6d72682e 100644 --- a/tests/Valkey.Glide.IntegrationTests/DnsTests.cs +++ b/tests/Valkey.Glide.IntegrationTests/DnsTests.cs @@ -88,10 +88,8 @@ private async Task BuildClient(bool useCluster, bool useTls, string if (useCluster) { var server = useTls ? fixture.TlsClusterServer! : fixture.ClusterServer!; - var port = server.Addresses.First().Port; - var builder = new ClusterClientConfigurationBuilder() - .WithAddress(host, port); + .WithAddress(host, server.Address.Port); if (useTls) { @@ -105,10 +103,8 @@ private async Task BuildClient(bool useCluster, bool useTls, string else { var server = useTls ? fixture.TlsStandaloneServer! : fixture.StandaloneServer!; - var port = server.Addresses.First().Port; - var builder = new StandaloneClientConfigurationBuilder() - .WithAddress(host, port); + .WithAddress(host, server.Address.Port); if (useTls) { diff --git a/tests/Valkey.Glide.IntegrationTests/StackExchange/GenericCommandsTests.cs b/tests/Valkey.Glide.IntegrationTests/StackExchange/GenericCommandsTests.cs index 0df5299a..ba211582 100644 --- a/tests/Valkey.Glide.IntegrationTests/StackExchange/GenericCommandsTests.cs +++ b/tests/Valkey.Glide.IntegrationTests/StackExchange/GenericCommandsTests.cs @@ -794,15 +794,14 @@ public class GenericCommandsFixture : IDisposable { private readonly StandaloneServer _standaloneServer; private readonly ConnectionMultiplexer _connection; - private readonly GlideClient _client; public IDatabase Database { get; } - public GlideClient Client => _client; + public GlideClient Client { get; } public GenericCommandsFixture() { _standaloneServer = new(); - var (host, port) = _standaloneServer.Addresses.First(); + var (host, port) = _standaloneServer.Address; ConfigurationOptions config = new(); config.EndPoints.Add(host, port); @@ -814,12 +813,12 @@ public GenericCommandsFixture() var glideConfig = new StandaloneClientConfigurationBuilder() .WithAddress(host, port) .Build(); - _client = GlideClient.CreateClient(glideConfig).GetAwaiter().GetResult(); + Client = GlideClient.CreateClient(glideConfig).GetAwaiter().GetResult(); } public void Dispose() { - _client.Dispose(); + Client.Dispose(); _connection.Dispose(); _standaloneServer.Dispose(); } diff --git a/tests/Valkey.Glide.IntegrationTests/StackExchange/ServerManagementTests.cs b/tests/Valkey.Glide.IntegrationTests/StackExchange/ServerManagementTests.cs index 1b2ffa70..e15f522a 100644 --- a/tests/Valkey.Glide.IntegrationTests/StackExchange/ServerManagementTests.cs +++ b/tests/Valkey.Glide.IntegrationTests/StackExchange/ServerManagementTests.cs @@ -96,8 +96,8 @@ public class ServerManagementFixture : IDisposable public ServerManagementFixture() { ConfigurationOptions config = new(); - (string host, ushort port) = _standaloneServer.Addresses.First(); - config.EndPoints.Add(host, port); + var address = _standaloneServer.Address; + config.EndPoints.Add(address.Host, address.Port); ConnectionMultiplexer conn = ConnectionMultiplexer.Connect(config); Server = conn.GetServers().First(); } diff --git a/tests/Valkey.Glide.IntegrationTests/StackExchange/ValkeyServerTests.cs b/tests/Valkey.Glide.IntegrationTests/StackExchange/ValkeyServerTests.cs index 1721eb7f..a0f040f5 100644 --- a/tests/Valkey.Glide.IntegrationTests/StackExchange/ValkeyServerTests.cs +++ b/tests/Valkey.Glide.IntegrationTests/StackExchange/ValkeyServerTests.cs @@ -165,7 +165,7 @@ public class ValkeyServerFixture : IDisposable public ValkeyServerFixture() { _standaloneServer = new(); - var (host, port) = _standaloneServer.Addresses.First(); + var (host, port) = _standaloneServer.Address; ConfigurationOptions config = new(); config.EndPoints.Add(host, port); diff --git a/tests/Valkey.Glide.IntegrationTests/StandaloneClientTests.cs b/tests/Valkey.Glide.IntegrationTests/StandaloneClientTests.cs index 35a7a982..1eff5a72 100644 --- a/tests/Valkey.Glide.IntegrationTests/StandaloneClientTests.cs +++ b/tests/Valkey.Glide.IntegrationTests/StandaloneClientTests.cs @@ -353,10 +353,8 @@ public async Task ConfigSetAsync_SetsConfiguration(GlideClient client) [Theory(DisableDiscoveryEnumeration = true)] [MemberData(nameof(Config.TestStandaloneClients), MemberType = typeof(TestConfiguration))] public async Task ConfigResetStatisticsAsync_ResetsStats(GlideClient client) - { // This should not throw - await client.ConfigResetStatisticsAsync(); - } + => await client.ConfigResetStatisticsAsync(); [Theory(DisableDiscoveryEnumeration = true)] [MemberData(nameof(Config.TestStandaloneClients), MemberType = typeof(TestConfiguration))] @@ -492,11 +490,10 @@ public async Task EagerConnect() public async Task Connect_WithIpAddress_Succeeds(string address) { using var server = new StandaloneServer(useTls: false); - var port = server.Addresses.First().Port; - var configBuilder = new ConnectionConfiguration.StandaloneClientConfigurationBuilder() - .WithAddress(address, port); + var builder = new ConnectionConfiguration.StandaloneClientConfigurationBuilder() + .WithAddress(address, server.Address.Port); - await using var client = await GlideClient.CreateClient(configBuilder.Build()); + await using var client = await GlideClient.CreateClient(builder.Build()); await AssertConnected(client); } } diff --git a/tests/Valkey.Glide.IntegrationTests/TlsTests.cs b/tests/Valkey.Glide.IntegrationTests/TlsTests.cs index c5e18ae8..dcdd2a3e 100644 --- a/tests/Valkey.Glide.IntegrationTests/TlsTests.cs +++ b/tests/Valkey.Glide.IntegrationTests/TlsTests.cs @@ -23,7 +23,7 @@ public class TlsTests(ServerFixture serverFixture, TlsServerFixture tlsServerFix public async Task Cluster_WithCertificateData_NotTrusted_Throws() { var server = tlsServerFixture.ClusterServer; - var address = server.Addresses.First(); + var address = server.Address; var config = new ClusterClientConfigurationBuilder() .WithAddress(address.Host, address.Port) @@ -39,7 +39,7 @@ public async Task Cluster_WithCertificateData_NotTrusted_Throws() public async Task Cluster_WithCertificateData_Malformed_Throws() { var server = tlsServerFixture.ClusterServer; - var address = server.Addresses.First(); + var address = server.Address; var config = new ClusterClientConfigurationBuilder() .WithAddress(address.Host, address.Port) @@ -60,7 +60,7 @@ public void Cluster_WithCertificatePath_NotFound_Throws() public async Task Cluster_NoCertificate_TlsServer_Throws() { var server = tlsServerFixture.ClusterServer; - var address = server.Addresses.First(); + var address = server.Address; var config = new ClusterClientConfigurationBuilder() .WithAddress(address.Host, address.Port) @@ -75,7 +75,7 @@ public async Task Cluster_NoCertificate_TlsServer_Throws() public async Task Cluster_WithCertificate_NonTlsServer_Throws() { var server = serverFixture.ClusterServer; - var address = server.Addresses.First(); + var address = server.Address; var config = new ClusterClientConfigurationBuilder() .WithAddress(address.Host, address.Port) @@ -90,7 +90,7 @@ public async Task Cluster_WithCertificate_NonTlsServer_Throws() public async Task Cluster_WithCertificateData_Trusted_Succeeds() { var server = tlsServerFixture.ClusterServer; - var address = server.Addresses.First(); + var address = server.Address; var config = new ClusterClientConfigurationBuilder() .WithAddress(address.Host, address.Port) @@ -106,7 +106,7 @@ public async Task Cluster_WithCertificateData_Trusted_Succeeds() public async Task Cluster_WithCertificatePath_Trusted_Succeeds() { var server = tlsServerFixture.ClusterServer; - var address = server.Addresses.First(); + var address = server.Address; var config = new ClusterClientConfigurationBuilder() .WithAddress(address.Host, address.Port) @@ -122,7 +122,7 @@ public async Task Cluster_WithCertificatePath_Trusted_Succeeds() public async Task Cluster_WithInsecureTls_WithTlsServer_Succeeds() { var server = tlsServerFixture.ClusterServer; - var address = server.Addresses.First(); + var address = server.Address; var config = new ClusterClientConfigurationBuilder() .WithAddress(address.Host, address.Port) @@ -138,7 +138,7 @@ public async Task Cluster_WithInsecureTls_WithTlsServer_Succeeds() public async Task Cluster_WithInsecureTls_WithNonTlsServer_Throws() { var server = serverFixture.ClusterServer; - var address = server.Addresses.First(); + var address = server.Address; var config = new ClusterClientConfigurationBuilder() .WithAddress(address.Host, address.Port) @@ -155,10 +155,9 @@ public async Task Cluster_WithInsecureTls_WithNonTlsServer_Throws() public async Task Cluster_WithIpAddress_Succeeds(string address) { var server = tlsServerFixture.ClusterServer; - var port = server.Addresses.First().Port; var config = new ClusterClientConfigurationBuilder() - .WithAddress(address, port) + .WithAddress(address, server.Address.Port) .WithTls() .WithTrustedCertificate(server.CertificateData!) .Build(); @@ -174,7 +173,7 @@ public async Task Cluster_WithIpAddress_Succeeds(string address) public async Task Standalone_WithCertificateData_NotTrusted_Throws() { var server = tlsServerFixture.StandaloneServer; - var address = server.Addresses.First(); + var address = server.Address; var config = new StandaloneClientConfigurationBuilder() .WithAddress(address.Host, address.Port) @@ -190,7 +189,7 @@ public async Task Standalone_WithCertificateData_NotTrusted_Throws() public async Task Standalone_WithCertificateData_Malformed_Throws() { var server = tlsServerFixture.StandaloneServer; - var address = server.Addresses.First(); + var address = server.Address; var config = new StandaloneClientConfigurationBuilder() .WithAddress(address.Host, address.Port) @@ -211,7 +210,7 @@ public void Standalone_WithCertificatePath_InvalidThrows() public async Task Standalone_NoCertificate_Throws() { var server = tlsServerFixture.StandaloneServer; - var address = server.Addresses.First(); + var address = server.Address; var config = new StandaloneClientConfigurationBuilder() .WithAddress(address.Host, address.Port) @@ -226,7 +225,7 @@ public async Task Standalone_NoCertificate_Throws() public async Task Standalone_WithCertificate_NonTlsServer_Throws() { var server = serverFixture.StandaloneServer; - var address = server.Addresses.First(); + var address = server.Address; var config = new StandaloneClientConfigurationBuilder() .WithAddress(address.Host, address.Port) @@ -242,7 +241,7 @@ public async Task Standalone_WithCertificate_NonTlsServer_Throws() public async Task Standalone_WithCertificateData_Trusted_Succeeds() { var server = tlsServerFixture.StandaloneServer; - var address = server.Addresses.First(); + var address = server.Address; var config = new StandaloneClientConfigurationBuilder() .WithAddress(address.Host, address.Port) @@ -258,7 +257,7 @@ public async Task Standalone_WithCertificateData_Trusted_Succeeds() public async Task Standalone_WithCertificatePath_Trusted_Succeeds() { var server = tlsServerFixture.StandaloneServer; - var address = server.Addresses.First(); + var address = server.Address; var config = new StandaloneClientConfigurationBuilder() .WithAddress(address.Host, address.Port) @@ -274,7 +273,7 @@ public async Task Standalone_WithCertificatePath_Trusted_Succeeds() public async Task Standalone_WithInsecureTls_WithTlsServer_Succeeds() { var server = tlsServerFixture.StandaloneServer; - var address = server.Addresses.First(); + var address = server.Address; var config = new StandaloneClientConfigurationBuilder() .WithAddress(address.Host, address.Port) @@ -290,7 +289,7 @@ public async Task Standalone_WithInsecureTls_WithTlsServer_Succeeds() public async Task Standalone_WithInsecureTls_WithNonTlsServer_Throws() { var server = serverFixture.StandaloneServer; - var address = server.Addresses.First(); + var address = server.Address; var config = new StandaloneClientConfigurationBuilder() .WithAddress(address.Host, address.Port) @@ -307,10 +306,9 @@ public async Task Standalone_WithInsecureTls_WithNonTlsServer_Throws() public async Task Standalone_WithIpAddress_Succeeds(string address) { var server = tlsServerFixture.StandaloneServer; - var port = server.Addresses.First().Port; var config = new StandaloneClientConfigurationBuilder() - .WithAddress(address, port) + .WithAddress(address, server.Address.Port) .WithTls() .WithTrustedCertificate(server.CertificateData!) .Build(); diff --git a/tests/Valkey.Glide.TestUtils/Client.cs b/tests/Valkey.Glide.TestUtils/Client.cs index e760f5ac..311c99f2 100644 --- a/tests/Valkey.Glide.TestUtils/Client.cs +++ b/tests/Valkey.Glide.TestUtils/Client.cs @@ -1,5 +1,7 @@ // Copyright Valkey GLIDE Project Contributors - SPDX Identifier: Apache-2.0 +using static Valkey.Glide.ConnectionConfiguration; + namespace Valkey.Glide.TestUtils; /// @@ -105,4 +107,16 @@ client is GlideClient standaloneClient string versionLine = lines.FirstOrDefault(static l => l.Contains("valkey_version")) ?? lines.First(static l => l.Contains("redis_version")); return new(versionLine.Split(':')[1]); } + + /// + /// Creates a client for the given configuration. + /// + /// The client configuration. + public static async Task CreateClient(BaseClientConfiguration config) + => config switch + { + StandaloneClientConfiguration standalone => await GlideClient.CreateClient(standalone), + ClusterClientConfiguration cluster => await GlideClusterClient.CreateClient(cluster), + _ => throw new ArgumentException($"Unknown configuration type: {config.GetType().Name}") + }; } diff --git a/tests/Valkey.Glide.TestUtils/Config.cs b/tests/Valkey.Glide.TestUtils/Config.cs new file mode 100644 index 00000000..961059db --- /dev/null +++ b/tests/Valkey.Glide.TestUtils/Config.cs @@ -0,0 +1,124 @@ +// Copyright Valkey GLIDE Project Contributors - SPDX Identifier: Apache-2.0 + +using static Valkey.Glide.ConnectionConfiguration; + +namespace Valkey.Glide.TestUtils; + +/// +/// Test utilities for building client configurations. +/// +public static class Config +{ + #region Constants + + /// + /// Timeout for client connection and reconnection attempts. + /// Use a longer timeout to allow for slower connections in CI environments. + /// + private static readonly TimeSpan ConnectionTimeout = TimeSpan.FromSeconds(30); + + /// + /// Retry strategy for client connections. + /// Allow retries for transient connection timeouts in CI environments. + /// + private static readonly RetryStrategy RetryStrategy = new(numberOfRetries: 5, factor: 100, exponentBase: 2); + + #endregion + #region Public Methods + + /// + /// Builds a standalone client configuration builder with the given address and options. + /// + public static StandaloneClientConfigurationBuilder BuildStandaloneConfig( + Address address, + AddressResolverDelegate? addressResolver = null, + bool useTls = false, + TimeSpan? connectionTimeout = null, + RetryStrategy? retryStrategy = null, + byte[]? trustedCertificate = null, + string? password = null) + { + StandaloneClientConfigurationBuilder builder = new() + { + UseTls = useTls, + ConnectionTimeout = connectionTimeout ?? ConnectionTimeout, + ConnectionRetryStrategy = retryStrategy ?? RetryStrategy, + }; + + _ = builder.WithAddress(address.Host, address.Port); + + if (trustedCertificate is not null) + { + _ = builder.WithTrustedCertificate(trustedCertificate); + } + + if (password is not null) + { + _ = builder.WithAuthentication(password); + } + + if (addressResolver is not null) + { + _ = builder.WithAddressResolver(addressResolver); + } + + return builder; + } + + /// + /// Builds a cluster client configuration builder with the given address and options. + /// + public static ClusterClientConfigurationBuilder BuildClusterConfig( + Address address, + AddressResolverDelegate? addressResolver = null, + bool useTls = false, + TimeSpan? connectionTimeout = null, + RetryStrategy? retryStrategy = null, + byte[]? trustedCertificate = null, + string? password = null) + { + ClusterClientConfigurationBuilder builder = new() + { + UseTls = useTls, + ConnectionTimeout = connectionTimeout ?? ConnectionTimeout, + ConnectionRetryStrategy = retryStrategy ?? RetryStrategy, + }; + + _ = builder.WithAddress(address.Host, address.Port); + + if (trustedCertificate is not null) + { + _ = builder.WithTrustedCertificate(trustedCertificate); + } + + if (password is not null) + { + _ = builder.WithAuthentication(password); + } + + if (addressResolver is not null) + { + _ = builder.WithAddressResolver(addressResolver); + } + + return builder; + } + + /// + /// Builds a client configuration for the given mode, address, and options. + /// + public static BaseClientConfiguration BuildConfig( + bool clusterMode, + Address address, + AddressResolverDelegate? addressResolver = null, + bool useTls = false, + TimeSpan? connectionTimeout = null, + RetryStrategy? retryStrategy = null, + byte[]? trustedCertificate = null, + string? password = null) + => clusterMode + ? BuildClusterConfig(address, addressResolver, useTls, connectionTimeout, retryStrategy, trustedCertificate, password).Build() + : BuildStandaloneConfig(address, addressResolver, useTls, connectionTimeout, retryStrategy, trustedCertificate, password).Build(); + + #endregion +} diff --git a/tests/Valkey.Glide.TestUtils/Server.cs b/tests/Valkey.Glide.TestUtils/Server.cs index 3f2e72d1..5371d827 100644 --- a/tests/Valkey.Glide.TestUtils/Server.cs +++ b/tests/Valkey.Glide.TestUtils/Server.cs @@ -11,18 +11,6 @@ public abstract class Server : IDisposable { #region Constants - /// - /// Timeout for client connection and reconnection attempts. - /// Use a longer timeout to allow for slower connections in CI environments. - /// - protected static readonly TimeSpan ConnectionTimeout = TimeSpan.FromSeconds(30); - - /// - /// Retry strategy for client connections. - /// Allow retries for transient connection timeouts in CI environments. - /// - protected static readonly RetryStrategy RetryStrategy = new(numberOfRetries: 5, factor: 100, exponentBase: 2); - /// /// Custom command arguments to kill all normal clients. /// @@ -37,7 +25,7 @@ public abstract class Server : IDisposable private readonly string _name = $"Server_{Guid.NewGuid():N}"; /// - /// Indicates whether the server has been stopped. + /// Whether the server has been stopped. /// See . /// private bool _disposed = false; @@ -51,9 +39,9 @@ public abstract class Server : IDisposable #region Public Properties /// - /// Addresses of the server instances. + /// Address of the server. /// - public IList
Addresses { get; init; } + public Address Address { get; init; } /// /// Indicates whether the server uses TLS. @@ -81,7 +69,7 @@ public abstract class Server : IDisposable protected Server(bool useClusterMode, bool useTls) { UseTls = useTls; - Addresses = ServerManager.StartServer(_name, useClusterMode: useClusterMode, useTls: UseTls); + Address = ServerManager.StartServer(_name, useClusterMode: useClusterMode, useTls: UseTls).First(); if (UseTls) { @@ -148,31 +136,11 @@ public sealed class ClusterServer(bool useTls = false) : Server(useClusterMode: /// Builds and returns a cluster client configuration builder for this server. /// public ClusterClientConfigurationBuilder CreateConfigBuilder() - { - ClusterClientConfigurationBuilder configBuilder = new() - { - UseTls = UseTls, - ConnectionTimeout = ConnectionTimeout, - ConnectionRetryStrategy = RetryStrategy - }; - - if (UseTls) - { - _ = configBuilder.WithTrustedCertificate(CertificateData!); - } - - if (_password is not null) - { - _ = configBuilder.WithAuthentication(_password); - } - - foreach ((string host, ushort port) in Addresses) - { - _ = configBuilder.WithAddress(host, port); - } - - return configBuilder; - } + => Config.BuildClusterConfig( + address: Address, + useTls: UseTls, + trustedCertificate: UseTls ? CertificateData : null, + password: _password); /// public override async Task CreateClientAsync() @@ -218,31 +186,11 @@ public sealed class StandaloneServer(bool useTls = false) : Server(useClusterMod /// Builds and returns a standalone client configuration builder for this server. ///
public StandaloneClientConfigurationBuilder CreateConfigBuilder() - { - StandaloneClientConfigurationBuilder configBuilder = new() - { - UseTls = UseTls, - ConnectionTimeout = ConnectionTimeout, - ConnectionRetryStrategy = RetryStrategy - }; - - if (UseTls) - { - _ = configBuilder.WithTrustedCertificate(CertificateData!); - } - - if (_password is not null) - { - _ = configBuilder.WithAuthentication(_password); - } - - foreach ((string host, ushort port) in Addresses) - { - _ = configBuilder.WithAddress(host, port); - } - - return configBuilder; - } + => Config.BuildStandaloneConfig( + address: Address, + useTls: UseTls, + trustedCertificate: UseTls ? CertificateData : null, + password: _password); /// public override async Task CreateClientAsync() diff --git a/tests/Valkey.Glide.UnitTests/ConnectionConfigurationTests.cs b/tests/Valkey.Glide.UnitTests/ConnectionConfigurationTests.cs index 20737a0a..eb2453c4 100644 --- a/tests/Valkey.Glide.UnitTests/ConnectionConfigurationTests.cs +++ b/tests/Valkey.Glide.UnitTests/ConnectionConfigurationTests.cs @@ -27,6 +27,10 @@ public class ConnectionConfigurationTests // IAM auth constants. private const uint RefreshInterval = 300; + // Address resolver constants. + private static readonly (string, ushort) Resolved = ("resolved-host", 9999); + private static readonly AddressResolverDelegate Resolver = (host, port) => Resolved; + #endregion #region Authentication & Credentials Tests @@ -672,6 +676,41 @@ public void WithConnectionRetryStrategy_Cluster_UintParams_WithJitter() Assert.Equal(JitterPercent, strategy.JitterPercent); } + #endregion + #region Address Resolver Tests + + [Fact] + public void WithAddressResolver_Standalone_SetsResolver() + { + var config = new StandaloneClientConfigurationBuilder().WithAddressResolver(Resolver).Build(); + Assert.Equal(Resolver, config.Request.AddressResolver); + Assert.Equal(Resolved, config.Request.AddressResolver!("localhost", 6379)); + } + + [Fact] + public void WithAddressResolver_Cluster_SetsResolver() + { + var config = new ClusterClientConfigurationBuilder().WithAddressResolver(Resolver).Build(); + Assert.Equal(Resolver, config.Request.AddressResolver); + Assert.Equal(Resolved, config.Request.AddressResolver!("localhost", 6379)); + } + + [Fact] + public void AddressResolver_Standalone_NotSet_IsNull() + => Assert.Null(new StandaloneClientConfigurationBuilder().Build().Request.AddressResolver); + + [Fact] + public void AddressResolver_Cluster_NotSet_IsNull() + => Assert.Null(new ClusterClientConfigurationBuilder().Build().Request.AddressResolver); + + [Fact] + public void AddressResolver_Standalone_SetToNull_IsNull() + => Assert.Null(new StandaloneClientConfigurationBuilder { AddressResolver = null }.Build().Request.AddressResolver); + + [Fact] + public void AddressResolver_Cluster_SetToNull_IsNull() + => Assert.Null(new ClusterClientConfigurationBuilder { AddressResolver = null }.Build().Request.AddressResolver); + #endregion #region Helpers diff --git a/tests/Valkey.Glide.UnitTests/PubSubMemoryLeakFixValidationTests.cs b/tests/Valkey.Glide.UnitTests/PubSubMemoryLeakFixValidationTests.cs index 17761de2..f49b6d04 100644 --- a/tests/Valkey.Glide.UnitTests/PubSubMemoryLeakFixValidationTests.cs +++ b/tests/Valkey.Glide.UnitTests/PubSubMemoryLeakFixValidationTests.cs @@ -17,12 +17,8 @@ public void MarshalPubSubMessage_ProcessMultipleMessages_NoMemoryLeak() // Arrange const int messageCount = 10_000; - // Force initial GC to get baseline - GC.Collect(); - GC.WaitForPendingFinalizers(); - GC.Collect(); - - long initialMemory = GC.GetTotalMemory(false); + // Force initial GC to get a stable baseline + long initialMemory = GC.GetTotalMemory(forceFullCollection: true); // Act: Process multiple messages for (int i = 0; i < messageCount; i++) @@ -33,12 +29,8 @@ public void MarshalPubSubMessage_ProcessMultipleMessages_NoMemoryLeak() ProcessSingleMessage(message, channel, null); } - // Force GC to clean up any leaked memory - GC.Collect(); - GC.WaitForPendingFinalizers(); - GC.Collect(); - - long finalMemory = GC.GetTotalMemory(false); + // Force GC to clean up any leaked memory. + long finalMemory = GC.GetTotalMemory(forceFullCollection: true); long memoryGrowth = finalMemory - initialMemory; Console.WriteLine($"Processed {messageCount:N0} messages"); diff --git a/valkey-glide b/valkey-glide index bf74288b..b4b34a1d 160000 --- a/valkey-glide +++ b/valkey-glide @@ -1 +1 @@ -Subproject commit bf74288bfa51c02b67904e43b1e20be61a29a09b +Subproject commit b4b34a1dc16d97a3fae32fc19bc1d42b3ec2f224