diff --git a/CHANGELOG.md b/CHANGELOG.md index ce878587..a4008929 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Circuit breaker configuration (#474) - Support additional commands (#435): - `BGREWRITEAOF` (#444) - `BGSAVE CANCEL` (#436) @@ -34,7 +35,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `RESET` (#435) - `SAVE` (#440) - Custom socket address resolution support via callback (#392) -- `NodeDiscoveryMode` configuration option for standalone clients, with `Standard`, `Static` (proxy-compatible, e.g. Envoy), and `DiscoverAll` modes (#131) +- `NodeDiscoveryMode` configuration option for standalone clients (#131) ## 1.1.0 diff --git a/rust/src/ffi.rs b/rust/src/ffi.rs index 1df9e85c..1bad19f0 100644 --- a/rust/src/ffi.rs +++ b/rust/src/ffi.rs @@ -105,6 +105,18 @@ pub struct ClientSideCacheConfig { pub server_assisted: bool, } +/// A mirror of [`glide_core::client::ClientCircuitBreakerConfig`] adopted for FFI. +#[repr(C)] +#[derive(Clone, Copy)] +pub struct CircuitBreakerConfig { + pub window_size_ms: u32, + pub failure_rate_threshold: f32, + pub min_errors: u32, + pub open_timeout_ms: u32, + pub count_timeouts: bool, + pub consecutive_successes: u32, +} + /// A mirror of [`ConnectionRequest`] adopted for FFI. #[repr(C)] #[derive(Clone, Copy)] @@ -144,10 +156,16 @@ pub struct ConnectionConfig { pub has_compression_config: bool, pub compression_config: CompressionConfig, + pub read_only: bool, + pub node_discovery_mode: NodeDiscoveryMode, + pub has_client_side_cache_config: bool, pub client_side_cache_config: ClientSideCacheConfig, + + pub has_circuit_breaker_config: bool, + pub circuit_breaker_config: CircuitBreakerConfig, } #[repr(C)] @@ -269,14 +287,10 @@ pub(crate) unsafe fn create_connection_request( ServiceType::ElastiCache => glide_core::iam::ServiceType::ElastiCache, ServiceType::MemoryDB => glide_core::iam::ServiceType::MemoryDB, }, - refresh_interval_seconds: if auth_info + refresh_interval_seconds: auth_info .iam_credentials .has_refresh_interval_seconds - { - Some(auth_info.iam_credentials.refresh_interval_seconds) - } else { - None - }, + .then_some(auth_info.iam_credentials.refresh_interval_seconds), }) } else { None @@ -335,8 +349,16 @@ pub(crate) unsafe fn create_connection_request( .then_some(config.compression_config.max_decompressed_size as usize), }, ), + read_only: config.read_only, + // Node discovery mode + node_discovery_mode: match config.node_discovery_mode { + NodeDiscoveryMode::Standard => glide_core::client::NodeDiscoveryMode::Standard, + NodeDiscoveryMode::Static => glide_core::client::NodeDiscoveryMode::Static, + NodeDiscoveryMode::DiscoverAll => glide_core::client::NodeDiscoveryMode::DiscoverAll, + }, + // Client-side cache configuration client_side_cache: if config.has_client_side_cache_config { use redis::cache::EvictionPolicy as CoreEvictionPolicy; @@ -345,14 +367,12 @@ pub(crate) unsafe fn create_connection_request( cache_id: unsafe { ptr_to_str(csc.cache_id) }?, max_cache_kb: csc.max_cache_kb, entry_ttl_ms: csc.entry_ttl_ms, - eviction_policy: if csc.has_eviction_policy { - Some(match csc.eviction_policy { + eviction_policy: csc + .has_eviction_policy + .then_some(match csc.eviction_policy { EvictionPolicy::Lru => CoreEvictionPolicy::Lru, EvictionPolicy::Lfu => CoreEvictionPolicy::Lfu, - }) - } else { - None - }, + }), enable_metrics: csc.enable_metrics, server_assisted: csc.server_assisted, }) @@ -360,11 +380,17 @@ pub(crate) unsafe fn create_connection_request( None }, - node_discovery_mode: match config.node_discovery_mode { - NodeDiscoveryMode::Standard => glide_core::client::NodeDiscoveryMode::Standard, - NodeDiscoveryMode::Static => glide_core::client::NodeDiscoveryMode::Static, - NodeDiscoveryMode::DiscoverAll => glide_core::client::NodeDiscoveryMode::DiscoverAll, - }, + // Circuit breaker configuration + client_circuit_breaker: config.has_circuit_breaker_config.then_some( + glide_core::client::ClientCircuitBreakerConfig { + window_size_ms: config.circuit_breaker_config.window_size_ms, + failure_rate_threshold: config.circuit_breaker_config.failure_rate_threshold, + min_errors: config.circuit_breaker_config.min_errors, + open_timeout_ms: config.circuit_breaker_config.open_timeout_ms, + count_timeouts: config.circuit_breaker_config.count_timeouts, + consecutive_successes: config.circuit_breaker_config.consecutive_successes, + }, + ), // Unimplemented configuration options. client_cert: Vec::new(), @@ -376,7 +402,6 @@ pub(crate) unsafe fn create_connection_request( periodic_checks: None, // TODO #485: Expose periodic_checks in ClusterClientConfiguration. inflight_requests_limit: None, // TODO #484: Expose inflight_requests_limit in ConnectionConfiguration. address_resolver: None, - client_circuit_breaker: None, }) } diff --git a/sources/Valkey.Glide/Abstract/Database.SetCommands.cs b/sources/Valkey.Glide/Abstract/Database.SetCommands.cs index 9dba9ad7..96b5e7a1 100644 --- a/sources/Valkey.Glide/Abstract/Database.SetCommands.cs +++ b/sources/Valkey.Glide/Abstract/Database.SetCommands.cs @@ -147,7 +147,7 @@ public IAsyncEnumerable SetScanAsync( CommandFlags flags = CommandFlags.None) { GuardClauses.ThrowIfCommandFlags(flags); - GuardClauses.ThrowIfNotPositive(pageSize, nameof(pageSize)); + ArgumentOutOfRangeException.ThrowIfLessThan(pageSize, 0, nameof(pageSize)); var options = new ScanOptions { MatchPattern = pattern, Count = pageSize }; return SetScanAsync(key, cursor, options).SkipAsync(pageOffset); diff --git a/sources/Valkey.Glide/Abstract/Database.SortedSetCommands.cs b/sources/Valkey.Glide/Abstract/Database.SortedSetCommands.cs index a27f1f2c..e7625802 100644 --- a/sources/Valkey.Glide/Abstract/Database.SortedSetCommands.cs +++ b/sources/Valkey.Glide/Abstract/Database.SortedSetCommands.cs @@ -300,7 +300,7 @@ public IAsyncEnumerable SortedSetScanAsync( CommandFlags flags = CommandFlags.None) { GuardClauses.ThrowIfCommandFlags(flags); - GuardClauses.ThrowIfNotPositive(pageSize, nameof(pageSize)); + ArgumentOutOfRangeException.ThrowIfLessThan(pageSize, 0, nameof(pageSize)); var options = new ScanOptions { MatchPattern = pattern, Count = pageSize }; return SortedSetScanAsync(key, cursor, options).SkipAsync(pageOffset); diff --git a/sources/Valkey.Glide/Abstract/Database.StreamCommands.cs b/sources/Valkey.Glide/Abstract/Database.StreamCommands.cs index 590470c8..6281bb2d 100644 --- a/sources/Valkey.Glide/Abstract/Database.StreamCommands.cs +++ b/sources/Valkey.Glide/Abstract/Database.StreamCommands.cs @@ -202,7 +202,7 @@ public Task StreamAutoClaimIdsOnlyAsync(ValkeyKey k public Task StreamTrimAsync(ValkeyKey key, int maxLength, bool useApproximateMaxLength = false, CommandFlags flags = CommandFlags.None) { GuardClauses.ThrowIfCommandFlags(flags); - GuardClauses.ThrowIfNegative(maxLength, nameof(maxLength)); + ArgumentOutOfRangeException.ThrowIfLessThan(maxLength, 0, nameof(maxLength)); return StreamTrimAsync(key, new StreamTrimOptions.MaxLen { diff --git a/sources/Valkey.Glide/Abstract/ValkeyServer.cs b/sources/Valkey.Glide/Abstract/ValkeyServer.cs index 71a139f8..5446c745 100644 --- a/sources/Valkey.Glide/Abstract/ValkeyServer.cs +++ b/sources/Valkey.Glide/Abstract/ValkeyServer.cs @@ -290,7 +290,7 @@ public IAsyncEnumerable KeysAsync( CommandFlags flags = CommandFlags.None) { GuardClauses.ThrowIfCommandFlags(flags); - GuardClauses.ThrowIfNotPositive(pageSize, nameof(pageSize)); + ArgumentOutOfRangeException.ThrowIfLessThan(pageSize, 0, nameof(pageSize)); var options = new ScanOptions { MatchPattern = pattern, Count = pageSize }; return ScanAsync(cursor.ToString(), options).SkipAsync(pageOffset); diff --git a/sources/Valkey.Glide/CircuitBreakerConfig.cs b/sources/Valkey.Glide/CircuitBreakerConfig.cs new file mode 100644 index 00000000..b3db428e --- /dev/null +++ b/sources/Valkey.Glide/CircuitBreakerConfig.cs @@ -0,0 +1,200 @@ +// Copyright Valkey GLIDE Project Contributors - SPDX Identifier: Apache-2.0 + +using Valkey.Glide.Internals; + +namespace Valkey.Glide; + +/// +/// Configuration for the client-wide circuit breaker. +/// +/// Valkey GLIDE – Configure a Circuit Breaker +public sealed class CircuitBreakerConfig +{ + #region Constants + + /// + /// TimeSpan parameters are marshalled to the Rust core as u32 milliseconds, + /// so values exceeding this limit will throw an exception during configuration. + /// + public static readonly TimeSpan MaxTimeSpan = TimeSpan.FromMilliseconds(uint.MaxValue); + + /// + /// Default sliding window duration for error rate calculation (10 seconds). + /// + public static readonly TimeSpan DefaultWindowSize = TimeSpan.FromSeconds(10); + + /// + /// Default failure rate threshold within the window to trip the breaker (50%). + /// + public const float DefaultFailureRateThreshold = 0.5f; + + /// + /// Default minimum number of errors within the window before the rate is evaluated (50). + /// + public const uint DefaultMinErrors = 50; + + /// + /// Default time in Open state before allowing a probe request (5 seconds). + /// + public static readonly TimeSpan DefaultOpenTimeout = TimeSpan.FromSeconds(5); + + /// + /// Default number of consecutive successful probe requests needed before closing the breaker (3). + /// + public const uint DefaultConsecutiveSuccesses = 3; + + #endregion + #region Public Properties + + /// + /// Sliding window duration for error rate calculation. + /// + public TimeSpan WindowSize { get; private set; } = DefaultWindowSize; + + /// + /// Failure rate threshold (0.0, 1.0] within the window to trip the breaker. + /// + public float FailureRateThreshold { get; private set; } = DefaultFailureRateThreshold; + + /// + /// Minimum number of errors within the window before the rate is evaluated. + /// Prevents tripping on small sample sizes. + /// + public uint MinErrors { get; private set; } = DefaultMinErrors; + + /// + /// Time in Open state before allowing a probe request. + /// + public TimeSpan OpenTimeout { get; private set; } = DefaultOpenTimeout; + + /// + /// Whether command timeouts count toward tripping the breaker. Set to true only if + /// timeouts reliably indicate server-side issues rather than client-side thread pool starvation. + /// + public bool CountTimeouts { get; private set; } = false; + + /// + /// Number of consecutive successful probe requests needed before closing the breaker. + /// Provides a grace period to prevent flapping. + /// + public uint ConsecutiveSuccesses { get; private set; } = DefaultConsecutiveSuccesses; + + #endregion + #region Public Methods + + /// + /// Sets the sliding window duration for error rate calculation. + /// + /// The window size. + /// This instance for method chaining. + /// + /// Thrown if is not positive or exceeds . + /// + public CircuitBreakerConfig WithWindowSize(TimeSpan windowSize) + { + ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(windowSize, TimeSpan.Zero, nameof(windowSize)); + ArgumentOutOfRangeException.ThrowIfGreaterThan(windowSize, MaxTimeSpan, nameof(windowSize)); + + WindowSize = windowSize; + return this; + } + + /// + /// Sets the error rate threshold (0.0, 1.0] within the window to trip the breaker. + /// + /// The threshold value. + /// This instance for method chaining. + /// + /// Thrown when is zero or negative. + /// + public CircuitBreakerConfig WithFailureRateThreshold(float threshold) + { + ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(threshold, 0.0f, nameof(threshold)); + + FailureRateThreshold = threshold; + return this; + } + + /// + /// Sets the minimum number of errors within the window before the rate is evaluated. + /// + /// The minimum error count. + /// This instance for method chaining. + /// + /// Thrown when is zero. + /// + public CircuitBreakerConfig WithMinErrors(uint minErrors) + { + ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(minErrors, 0u, nameof(minErrors)); + + MinErrors = minErrors; + return this; + } + + /// + /// Sets the time in Open state before allowing a probe request. + /// + /// The open timeout duration. + /// This instance for method chaining. + /// + /// Thrown if is not positive or exceeds . + /// + public CircuitBreakerConfig WithOpenTimeout(TimeSpan openTimeout) + { + ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(openTimeout, TimeSpan.Zero, nameof(openTimeout)); + ArgumentOutOfRangeException.ThrowIfGreaterThan(openTimeout, MaxTimeSpan, nameof(openTimeout)); + + OpenTimeout = openTimeout; + return this; + } + + /// + /// Sets whether command timeouts count toward tripping the breaker. + /// + /// Whether to count timeouts. + /// This instance for method chaining. + public CircuitBreakerConfig WithCountTimeouts(bool countTimeouts = true) + { + CountTimeouts = countTimeouts; + return this; + } + + /// + /// Sets the number of consecutive successful probe requests needed before closing the breaker. + /// + /// The number of successes. + /// This instance for method chaining. + /// + /// Thrown when is zero. + /// + public CircuitBreakerConfig WithConsecutiveSuccesses(uint consecutiveSuccesses) + { + ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(consecutiveSuccesses, 0u, nameof(consecutiveSuccesses)); + + ConsecutiveSuccesses = consecutiveSuccesses; + return this; + } + + #endregion + #region Internal Methods + + /// + /// Converts to the FFI representation for marshalling to Rust core. + /// + internal FFI.CircuitBreakerConfig ToFfi() + { + // Casts to uint are safe: validated by WithWindowSize and WithOpenTimeout. + var windowSize = (uint)WindowSize.TotalMilliseconds; + var openTimeout = (uint)OpenTimeout.TotalMilliseconds; + + return new( + windowSize, + FailureRateThreshold, + MinErrors, + openTimeout, + CountTimeouts, + ConsecutiveSuccesses); + } + + #endregion +} diff --git a/sources/Valkey.Glide/ClientSideCacheConfig.cs b/sources/Valkey.Glide/ClientSideCacheConfig.cs index 7671b289..9b13cb5b 100644 --- a/sources/Valkey.Glide/ClientSideCacheConfig.cs +++ b/sources/Valkey.Glide/ClientSideCacheConfig.cs @@ -96,7 +96,7 @@ public sealed class ClientSideCacheConfig /// Maximum size of the cache in kilobytes (KB). Must be positive. /// Time-To-Live for cached entries. Use to disable expiration. /// Thrown when is zero. - /// Thrown when is negative. + /// Thrown when is negative. /// /// /// var cache = new ClientSideCacheConfig(1024, TimeSpan.FromMinutes(1)) @@ -111,12 +111,8 @@ public sealed class ClientSideCacheConfig /// public ClientSideCacheConfig(ulong maxCacheKb, TimeSpan entryTtl) { - if (maxCacheKb == 0) - { - throw new ArgumentOutOfRangeException(nameof(maxCacheKb), "maxCacheKb must be positive."); - } - - GuardClauses.ThrowIfNegative(entryTtl); + ArgumentOutOfRangeException.ThrowIfEqual(maxCacheKb, 0u, nameof(maxCacheKb)); + ArgumentOutOfRangeException.ThrowIfLessThan(entryTtl, TimeSpan.Zero, nameof(entryTtl)); MaxCacheKb = maxCacheKb; EntryTtl = entryTtl; diff --git a/sources/Valkey.Glide/ConnectionConfiguration.cs b/sources/Valkey.Glide/ConnectionConfiguration.cs index 0e6eacc3..b8b69792 100644 --- a/sources/Valkey.Glide/ConnectionConfiguration.cs +++ b/sources/Valkey.Glide/ConnectionConfiguration.cs @@ -62,6 +62,7 @@ internal record ConnectionConfig public bool ReadOnly; public NodeDiscoveryMode NodeDiscoveryMode = NodeDiscoveryMode.Standard; public ClientSideCacheConfig? ClientSideCacheConfig; + public CircuitBreakerConfig? CircuitBreakerConfig; public AddressResolverDelegate? AddressResolver; internal FFI.ConnectionConfig ToFfi() => @@ -85,7 +86,8 @@ internal FFI.ConnectionConfig ToFfi() => CompressionConfig?.ToFfi(), ReadOnly, NodeDiscoveryMode, - ClientSideCacheConfig?.ToFfi() + ClientSideCacheConfig?.ToFfi(), + CircuitBreakerConfig?.ToFfi() ); } @@ -895,6 +897,28 @@ public T WithClientSideCache(ClientSideCacheConfig clientSideCacheConfig) return (T)this; } + #endregion + #region Circuit Breaker + + /// + /// Circuit breaker configuration for the client. When set, enables a circuit breaker + /// that detects unhealthy core state and rejects requests at the client boundary. + /// + /// + public CircuitBreakerConfig? CircuitBreakerConfig + { + get => Config.CircuitBreakerConfig; + set => Config.CircuitBreakerConfig = value; + } + + /// + public T WithCircuitBreaker(CircuitBreakerConfig circuitBreakerConfig) + { + ArgumentNullException.ThrowIfNull(circuitBreakerConfig, nameof(circuitBreakerConfig)); + CircuitBreakerConfig = circuitBreakerConfig; + return (T)this; + } + #endregion #region Address Resolver diff --git a/sources/Valkey.Glide/Errors.cs b/sources/Valkey.Glide/Errors.cs index 26dfc3e6..548905c3 100644 --- a/sources/Valkey.Glide/Errors.cs +++ b/sources/Valkey.Glide/Errors.cs @@ -176,19 +176,46 @@ public ConfigurationError() { } public ConfigurationError(string message) : base(message) { } /// - /// Initializes a new instance of the class with a specified error message and a reference to the inner exception that is the cause of this exception. + /// Initializes a new instance of the class with a specified + /// error message and a reference to the inner exception that is the cause of this exception. /// /// The message that describes the error. /// The exception that is the cause of the current exception. public ConfigurationError(string message, Exception innerException) : base(message, innerException) { } } + /// + /// An error that is thrown when the request is rejected because the circuit breaker is open. + /// + public sealed class CircuitBreakerException : GlideException + { + /// + /// Initializes a new instance of the class. + /// + public CircuitBreakerException() : base() { } + + /// + /// Initializes a new instance of the class with a specified error message. + /// + /// The message that describes the error. + public CircuitBreakerException(string message) : base(message) { } + + /// + /// Initializes a new instance of the class with a specified + /// error message and a reference to the inner exception that is the cause of this exception. + /// + /// The message that describes the error. + /// The exception that is the cause of the current exception. + public CircuitBreakerException(string message, Exception innerException) : base(message, innerException) { } + } + internal static GlideException Create(RequestErrorType type, string message) => type switch { RequestErrorType.Unspecified => new RequestException(message), RequestErrorType.ExecAbort => new ExecAbortException(message), RequestErrorType.Timeout => new TimeoutException(message), RequestErrorType.Disconnect => new ConnectionException(message), + RequestErrorType.CircuitBreakerOpen => new CircuitBreakerException(message), _ => new RequestException(message), }; } @@ -199,4 +226,5 @@ internal enum RequestErrorType : uint ExecAbort = 1, Timeout = 2, Disconnect = 3, + CircuitBreakerOpen = 4, } diff --git a/sources/Valkey.Glide/Internals/FFI.structs.cs b/sources/Valkey.Glide/Internals/FFI.structs.cs index d7e08153..26b03b9a 100644 --- a/sources/Valkey.Glide/Internals/FFI.structs.cs +++ b/sources/Valkey.Glide/Internals/FFI.structs.cs @@ -226,7 +226,8 @@ public ConnectionConfig( CompressionConfig? compressionConfig, bool readOnly, NodeDiscoveryMode nodeDiscoveryMode, - ClientSideCacheConfig? clientSideCacheConfig) + ClientSideCacheConfig? clientSideCacheConfig, + CircuitBreakerConfig? circuitBreakerConfig) { _request = new() { @@ -263,6 +264,8 @@ public ConnectionConfig( NodeDiscoveryMode = nodeDiscoveryMode, HasClientSideCacheConfig = clientSideCacheConfig.HasValue, ClientSideCacheConfig = clientSideCacheConfig ?? default, + HasCircuitBreakerConfig = circuitBreakerConfig.HasValue, + CircuitBreakerConfig = circuitBreakerConfig ?? default, }; } @@ -1144,6 +1147,10 @@ private struct ConnectionRequest [MarshalAs(UnmanagedType.U1)] public bool HasClientSideCacheConfig; public ClientSideCacheConfig ClientSideCacheConfig; + + [MarshalAs(UnmanagedType.U1)] + public bool HasCircuitBreakerConfig; + public CircuitBreakerConfig CircuitBreakerConfig; } [StructLayout(LayoutKind.Sequential)] @@ -1269,6 +1276,47 @@ internal readonly struct ClientSideCacheConfig( public readonly bool ServerAssisted = serverAssisted; } + [StructLayout(LayoutKind.Sequential)] + internal readonly struct CircuitBreakerConfig( + uint windowSizeMs, + float failureRateThreshold, + uint minErrors, + uint openTimeoutMs, + bool countTimeouts, + uint consecutiveSuccesses) + { + /// + /// Sliding window duration in milliseconds for error counting (0 = default). + /// + public readonly uint WindowSizeMs = windowSizeMs; + + /// + /// Error rate threshold that triggers the circuit breaker (0 = default). + /// + public readonly float FailureRateThreshold = failureRateThreshold; + + /// + /// Minimum errors in the window before the failure rate is evaluated (0 = default). + /// + public readonly uint MinErrors = minErrors; + + /// + /// Duration in milliseconds the circuit breaker stays open before probing (0 = default). + /// + public readonly uint OpenTimeoutMs = openTimeoutMs; + + /// + /// Whether timeout errors count toward tripping the circuit breaker. + /// + [MarshalAs(UnmanagedType.U1)] + public readonly bool CountTimeouts = countTimeouts; + + /// + /// Consecutive successful probes needed to close the circuit breaker (0 = default). + /// + public readonly uint ConsecutiveSuccesses = consecutiveSuccesses; + } + [StructLayout(LayoutKind.Sequential)] internal readonly struct Statistics { diff --git a/sources/Valkey.Glide/Internals/GuardClauses.cs b/sources/Valkey.Glide/Internals/GuardClauses.cs index 7eb79b2b..d1ea4c3c 100644 --- a/sources/Valkey.Glide/Internals/GuardClauses.cs +++ b/sources/Valkey.Glide/Internals/GuardClauses.cs @@ -33,47 +33,6 @@ public static void ThrowIfCommandFlags(CommandFlags flags) } } - /// - /// Throws an if the given time span is negative. - /// - /// The time span value to validate. - /// Thrown if is negative. - public static void ThrowIfNegative(TimeSpan value) - { - if (value < TimeSpan.Zero) - { - throw new ArgumentException("Time span cannot be negative."); - } - } - - /// - /// Throws an if the given value is negative. - /// - /// The value to validate. - /// The parameter name for the exception. - /// Thrown if is negative. - public static void ThrowIfNegative(long value, string name) - { - if (value < 0) - { - throw new ArgumentOutOfRangeException(name, value, "Value cannot be negative."); - } - } - - /// - /// Throws an if the given value is not positive. - /// - /// The value to validate. - /// The parameter name for the exception. - /// Thrown if is zero or negative. - public static void ThrowIfNotPositive(long value, string name) - { - if (value <= 0) - { - throw new ArgumentOutOfRangeException(name, value, "Value must be positive."); - } - } - /// /// Throws a if the stream trim mode is not supported. /// diff --git a/sources/Valkey.Glide/Internals/TimeUtils.cs b/sources/Valkey.Glide/Internals/TimeUtils.cs index b4d75160..e0311a93 100644 --- a/sources/Valkey.Glide/Internals/TimeUtils.cs +++ b/sources/Valkey.Glide/Internals/TimeUtils.cs @@ -10,22 +10,22 @@ internal static class TimeUtils /// /// Converts a to milliseconds. /// - /// Thrown if is negative. + /// Thrown if is negative. public static ulong ToMilliseconds(TimeSpan timeSpan) { - GuardClauses.ThrowIfNegative(timeSpan); + ArgumentOutOfRangeException.ThrowIfLessThan(timeSpan, TimeSpan.Zero, nameof(timeSpan)); // Use tick-based arithmetic to avoid floating-point precision loss. return (ulong)(timeSpan.Ticks / TimeSpan.TicksPerMillisecond); } /// - /// Converts a to seconds. + /// Converts a to seconds. /// - /// Thrown if is negative. + /// Thrown if is negative. public static double ToSeconds(TimeSpan timeSpan) { - GuardClauses.ThrowIfNegative(timeSpan); + ArgumentOutOfRangeException.ThrowIfLessThan(timeSpan, TimeSpan.Zero, nameof(timeSpan)); return timeSpan.TotalSeconds; } } diff --git a/tests/Valkey.Glide.IntegrationTests/GenericCommandTests.cs b/tests/Valkey.Glide.IntegrationTests/GenericCommandTests.cs index 437a4f2c..65108333 100644 --- a/tests/Valkey.Glide.IntegrationTests/GenericCommandTests.cs +++ b/tests/Valkey.Glide.IntegrationTests/GenericCommandTests.cs @@ -662,7 +662,7 @@ public async Task TestWait(BaseClient client) [Theory(DisableDiscoveryEnumeration = true)] [MemberData(nameof(Config.TestClients), MemberType = typeof(TestConfiguration))] public async Task TestWait_NegativeTimeout(BaseClient client) - => await Assert.ThrowsAsync( + => await Assert.ThrowsAsync( () => client.WaitAsync(1, TimeSpan.FromMilliseconds(-1))); [Theory(DisableDiscoveryEnumeration = true)] diff --git a/tests/Valkey.Glide.UnitTests/CircuitBreakerConfigTests.cs b/tests/Valkey.Glide.UnitTests/CircuitBreakerConfigTests.cs new file mode 100644 index 00000000..b5194495 --- /dev/null +++ b/tests/Valkey.Glide.UnitTests/CircuitBreakerConfigTests.cs @@ -0,0 +1,237 @@ +// Copyright Valkey GLIDE Project Contributors - SPDX Identifier: Apache-2.0 + +namespace Valkey.Glide.UnitTests; + +/// +/// Unit tests for . +/// +public class CircuitBreakerConfigTests +{ + #region Constants + + private static readonly TimeSpan TooLarge = + CircuitBreakerConfig.MaxTimeSpan + TimeSpan.FromMilliseconds(1); + + #endregion + #region Default Values + + [Fact] + public void DefaultConfig_AllPropertiesCorrect() + { + var config = new CircuitBreakerConfig(); + + Assert.Equal(TimeSpan.FromSeconds(10), config.WindowSize); + Assert.Equal(0.5f, config.FailureRateThreshold); + Assert.Equal(50u, config.MinErrors); + Assert.Equal(TimeSpan.FromSeconds(5), config.OpenTimeout); + Assert.False(config.CountTimeouts); + Assert.Equal(3u, config.ConsecutiveSuccesses); + } + + #endregion + #region WithWindowSize + + [Fact] + public void WithWindowSize_ValidValue_SetsProperty() + { + var config = new CircuitBreakerConfig() + .WithWindowSize(TimeSpan.FromSeconds(15)); + + Assert.Equal(TimeSpan.FromSeconds(15), config.WindowSize); + } + + [Fact] + public void WithWindowSize_Zero_Throws() + { + var config = new CircuitBreakerConfig(); + _ = Assert.Throws(() => config.WithWindowSize(TimeSpan.Zero)); + } + + [Fact] + public void WithWindowSize_Negative_Throws() + { + var config = new CircuitBreakerConfig(); + _ = Assert.Throws(() => config.WithWindowSize(TimeSpan.FromSeconds(-1))); + } + + [Fact] + public void WithWindowSize_ExceedsMax_Throws() + { + var config = new CircuitBreakerConfig(); + _ = Assert.Throws(() => config.WithWindowSize(TooLarge)); + } + + #endregion + #region WithFailureRateThreshold + + [Fact] + public void WithFailureRateThreshold_ValidValue_SetsProperty() + { + var config = new CircuitBreakerConfig() + .WithFailureRateThreshold(0.6f); + + Assert.Equal(0.6f, config.FailureRateThreshold); + } + + [Fact] + public void WithFailureRateThreshold_One_SetsProperty() + { + var config = new CircuitBreakerConfig() + .WithFailureRateThreshold(1.0f); + + Assert.Equal(1.0f, config.FailureRateThreshold); + } + + [Fact] + public void WithFailureRateThreshold_Zero_Throws() + { + var config = new CircuitBreakerConfig(); + _ = Assert.Throws(() => config.WithFailureRateThreshold(0.0f)); + } + + [Fact] + public void WithFailureRateThreshold_Negative_Throws() + { + var config = new CircuitBreakerConfig(); + _ = Assert.Throws(() => config.WithFailureRateThreshold(-0.5f)); + } + + #endregion + #region WithMinErrors + + [Fact] + public void WithMinErrors_ValidValue_SetsProperty() + { + var config = new CircuitBreakerConfig() + .WithMinErrors(100); + + Assert.Equal(100u, config.MinErrors); + } + + [Fact] + public void WithMinErrors_Zero_Throws() + { + var config = new CircuitBreakerConfig(); + _ = Assert.Throws(() => config.WithMinErrors(0)); + } + + #endregion + #region WithOpenTimeout + + [Fact] + public void WithOpenTimeout_ValidValue_SetsProperty() + { + var config = new CircuitBreakerConfig() + .WithOpenTimeout(TimeSpan.FromSeconds(10)); + + Assert.Equal(TimeSpan.FromSeconds(10), config.OpenTimeout); + } + + [Fact] + public void WithOpenTimeout_Zero_Throws() + { + var config = new CircuitBreakerConfig(); + _ = Assert.Throws(() => config.WithOpenTimeout(TimeSpan.Zero)); + } + + [Fact] + public void WithOpenTimeout_Negative_Throws() + { + var config = new CircuitBreakerConfig(); + _ = Assert.Throws(() => config.WithOpenTimeout(TimeSpan.FromSeconds(-1))); + } + + [Fact] + public void WithOpenTimeout_ExceedsMax_Throws() + { + var config = new CircuitBreakerConfig(); + _ = Assert.Throws(() => config.WithOpenTimeout(TooLarge)); + } + + #endregion + #region WithCountTimeouts + + [Fact] + public void WithCountTimeouts_True_SetsProperty() + { + var config = new CircuitBreakerConfig() + .WithCountTimeouts(true); + + Assert.True(config.CountTimeouts); + } + + [Fact] + public void WithCountTimeouts_DefaultParam_SetsTrue() + { + var config = new CircuitBreakerConfig() + .WithCountTimeouts(); + + Assert.True(config.CountTimeouts); + } + + [Fact] + public void WithCountTimeouts_False_SetsProperty() + { + var config = new CircuitBreakerConfig() + .WithCountTimeouts(false); + + Assert.False(config.CountTimeouts); + } + + #endregion + #region WithConsecutiveSuccesses + + [Fact] + public void WithConsecutiveSuccesses_ValidValue_SetsProperty() + { + var config = new CircuitBreakerConfig() + .WithConsecutiveSuccesses(5); + + Assert.Equal(5u, config.ConsecutiveSuccesses); + } + + [Fact] + public void WithConsecutiveSuccesses_Zero_Throws() + { + var config = new CircuitBreakerConfig(); + _ = Assert.Throws(() => config.WithConsecutiveSuccesses(0)); + } + + #endregion + #region ToFfi + + [Fact] + public void ToFfi_DefaultConfig() + { + var ffi = new CircuitBreakerConfig().ToFfi(); + + Assert.Equal(10_000u, ffi.WindowSizeMs); + Assert.Equal(0.5f, ffi.FailureRateThreshold); + Assert.Equal(50u, ffi.MinErrors); + Assert.Equal(5_000u, ffi.OpenTimeoutMs); + Assert.False(ffi.CountTimeouts); + Assert.Equal(3u, ffi.ConsecutiveSuccesses); + } + + [Fact] + public void ToFfi_CustomConfig() + { + var ffi = new CircuitBreakerConfig() + .WithWindowSize(TimeSpan.FromSeconds(30)) + .WithFailureRateThreshold(0.75f) + .WithMinErrors(200) + .WithOpenTimeout(TimeSpan.FromMilliseconds(2500)) + .WithCountTimeouts(true) + .WithConsecutiveSuccesses(5) + .ToFfi(); + + Assert.Equal(30_000u, ffi.WindowSizeMs); + Assert.Equal(0.75f, ffi.FailureRateThreshold); + Assert.Equal(200u, ffi.MinErrors); + Assert.Equal(2_500u, ffi.OpenTimeoutMs); + Assert.True(ffi.CountTimeouts); + Assert.Equal(5u, ffi.ConsecutiveSuccesses); + } + + #endregion +} diff --git a/tests/Valkey.Glide.UnitTests/ClientSideCacheConfigTests.cs b/tests/Valkey.Glide.UnitTests/ClientSideCacheConfigTests.cs index 7ae97516..4b6a3313 100644 --- a/tests/Valkey.Glide.UnitTests/ClientSideCacheConfigTests.cs +++ b/tests/Valkey.Glide.UnitTests/ClientSideCacheConfigTests.cs @@ -10,7 +10,7 @@ public void ClientSideCacheConfig_MaxCacheKbMustBePositive() [Fact] public void ClientSideCacheConfig_EntryTtlMustNotBeNegative() - => _ = Assert.Throws(() => BuildConfig(entryTtl: TimeSpan.FromTicks(-1))); + => _ = Assert.Throws(() => BuildConfig(entryTtl: TimeSpan.FromTicks(-1))); [Fact] public void ClientSideCacheConfig_EntryTtlZeroIsAllowed() diff --git a/tests/Valkey.Glide.UnitTests/Options/FailoverOptionsTests.cs b/tests/Valkey.Glide.UnitTests/Options/FailoverOptionsTests.cs index cb08ecf5..3eb6099b 100644 --- a/tests/Valkey.Glide.UnitTests/Options/FailoverOptionsTests.cs +++ b/tests/Valkey.Glide.UnitTests/Options/FailoverOptionsTests.cs @@ -18,7 +18,7 @@ public void ToArgs_Success() => Assert.Multiple( [Fact] public void ToArgs_Failure() => Assert.Multiple( - () => Assert.Throws(() => FailoverOptions.Timeout(TimeSpan.FromSeconds(-1)).ToArgs()), - () => Assert.Throws(() => FailoverOptions.To("localhost", 6380, TimeSpan.FromSeconds(-1)).ToArgs()), - () => Assert.Throws(() => FailoverOptions.Forced("localhost", 6380, TimeSpan.FromSeconds(-1)).ToArgs())); + () => Assert.Throws(() => FailoverOptions.Timeout(TimeSpan.FromSeconds(-1)).ToArgs()), + () => Assert.Throws(() => FailoverOptions.To("localhost", 6380, TimeSpan.FromSeconds(-1)).ToArgs()), + () => Assert.Throws(() => FailoverOptions.Forced("localhost", 6380, TimeSpan.FromSeconds(-1)).ToArgs())); }